Migrate Flash screen to Jetpack Compose with miuix

Replace Fragment data-binding UI with a Compose-based FlashScreen
using LazyColumn for monospace console output with auto-scroll,
a save log TextButton, and miuix FloatingActionButton for reboot.
FlashViewModel now uses mutableStateListOf for console lines and
StateFlow for flash state. Back press blocking uses
OnBackPressedCallback instead of BaseFragment override.

Made-with: Cursor
This commit is contained in:
LoveSy
2026-03-03 12:49:25 +08:00
committed by topjohnwu
parent 390d529168
commit 4204fc56d4
5 changed files with 158 additions and 153 deletions
@@ -5,75 +5,88 @@ import android.content.Context
import android.content.pm.ActivityInfo
import android.net.Uri
import android.os.Bundle
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.LayoutInflater
import android.view.View
import androidx.core.view.MenuProvider
import androidx.core.view.isVisible
import android.view.ViewGroup
import androidx.activity.OnBackPressedCallback
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavDeepLinkBuilder
import com.topjohnwu.magisk.MainDirections
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.arch.ActivityExecutor
import com.topjohnwu.magisk.arch.ContextExecutor
import com.topjohnwu.magisk.arch.NavigationActivity
import com.topjohnwu.magisk.arch.UIActivity
import com.topjohnwu.magisk.arch.VMFactory
import com.topjohnwu.magisk.arch.ViewEvent
import com.topjohnwu.magisk.arch.ViewModelHolder
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.cmp
import com.topjohnwu.magisk.databinding.FragmentFlashMd2Binding
import com.topjohnwu.magisk.ui.MainActivity
import com.topjohnwu.magisk.ui.theme.MagiskTheme
import com.topjohnwu.magisk.core.R as CoreR
class FlashFragment : BaseFragment<FragmentFlashMd2Binding>(), MenuProvider {
class FlashFragment : Fragment(), ViewModelHolder {
override val layoutRes = R.layout.fragment_flash_md2
override val viewModel by viewModel<FlashViewModel>()
override val snackbarView: View get() = binding.snackbarContainer
override val snackbarAnchorView: View?
get() = if (binding.restartBtn.isShown) binding.restartBtn else super.snackbarAnchorView
override val viewModel by lazy {
ViewModelProvider(this, VMFactory)[FlashViewModel::class.java]
}
private var defaultOrientation = -1
private val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if ((viewModel as FlashViewModel).flashing.value != true) {
isEnabled = false
activity?.onBackPressedDispatcher?.onBackPressed()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.args = FlashFragmentArgs.fromBundle(requireArguments())
startObserveLiveData()
(viewModel as FlashViewModel).args = FlashFragmentArgs.fromBundle(requireArguments())
activity?.onBackPressedDispatcher?.addCallback(this, backCallback)
}
override fun onStart() {
super.onStart()
activity?.setTitle(CoreR.string.flash_screen_title)
(activity as? NavigationActivity<*>)?.setTitle(CoreR.string.flash_screen_title)
viewModel.state.observe(this) {
activity?.supportActionBar?.setSubtitle(
(viewModel as FlashViewModel).state.observe(this) {
(activity as? androidx.appcompat.app.AppCompatActivity)?.supportActionBar?.setSubtitle(
when (it) {
FlashViewModel.State.FLASHING -> CoreR.string.flashing
FlashViewModel.State.SUCCESS -> CoreR.string.done
FlashViewModel.State.FAILED -> CoreR.string.failure
}
)
if (it == FlashViewModel.State.SUCCESS && viewModel.showReboot) {
binding.restartBtn.apply {
if (!this.isVisible) this.show()
if (!this.isFocused) this.requestFocus()
}
}
}
}
override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_flash, menu)
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
return viewModel.onMenuItemClicked(item)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
defaultOrientation = activity?.requestedOrientation ?: -1
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
if (savedInstanceState == null) {
viewModel.startFlashing()
(viewModel as FlashViewModel).startFlashing()
}
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MagiskTheme {
FlashScreen(viewModel = viewModel as FlashViewModel)
}
}
}
}
@@ -85,22 +98,13 @@ class FlashFragment : BaseFragment<FragmentFlashMd2Binding>(), MenuProvider {
super.onDestroyView()
}
override fun onKeyEvent(event: KeyEvent): Boolean {
return when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN -> true
else -> false
override fun onEventDispatched(event: ViewEvent) {
when (event) {
is ContextExecutor -> event(requireContext())
is ActivityExecutor -> (activity as? UIActivity<*>)?.let { event(it) }
}
}
override fun onBackPressed(): Boolean {
if (viewModel.flashing.value == true)
return true
return super.onBackPressed()
}
override fun onPreBind(binding: FragmentFlashMd2Binding) = Unit
companion object {
private fun createIntent(context: Context, args: FlashFragmentArgs) =
@@ -114,27 +118,19 @@ class FlashFragment : BaseFragment<FragmentFlashMd2Binding>(), MenuProvider {
private fun flashType(isSecondSlot: Boolean) =
if (isSecondSlot) Const.Value.FLASH_INACTIVE_SLOT else Const.Value.FLASH_MAGISK
/* Flashing is understood as installing / flashing magisk itself */
fun flash(isSecondSlot: Boolean) = MainDirections.actionFlashFragment(
action = flashType(isSecondSlot)
)
/* Patching is understood as injecting img files with magisk */
fun patch(uri: Uri) = MainDirections.actionFlashFragment(
action = Const.Value.PATCH_FILE,
additionalData = uri
)
/* Uninstalling is understood as removing magisk entirely */
fun uninstall() = MainDirections.actionFlashFragment(
action = Const.Value.UNINSTALL
)
/* Installing is understood as flashing modules / zips */
fun installIntent(context: Context, file: Uri) = FlashFragmentArgs(
action = Const.Value.FLASH_ZIP,
additionalData = file,
@@ -145,5 +141,4 @@ class FlashFragment : BaseFragment<FragmentFlashMd2Binding>(), MenuProvider {
additionalData = file,
)
}
}
@@ -0,0 +1,85 @@
package com.topjohnwu.magisk.ui.flash
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import top.yukonga.miuix.kmp.basic.FloatingActionButton
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.theme.MiuixTheme
import com.topjohnwu.magisk.core.R as CoreR
@Composable
fun FlashScreen(viewModel: FlashViewModel) {
val flashState by viewModel.flashState.collectAsState()
val showReboot by viewModel.showReboot.collectAsState()
val items = viewModel.consoleItems
val listState = rememberLazyListState()
LaunchedEffect(items.size) {
if (items.isNotEmpty()) {
listState.animateScrollToItem(items.size - 1)
}
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
itemsIndexed(items) { _, line ->
Text(
text = line,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 16.sp,
color = MiuixTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth()
)
}
}
if (flashState != FlashViewModel.State.FLASHING) {
TextButton(
text = stringResource(CoreR.string.menuSaveLog),
onClick = { viewModel.saveLog() },
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
)
}
if (flashState == FlashViewModel.State.SUCCESS && showReboot) {
FloatingActionButton(
onClick = { viewModel.restartPressed() },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
Text(
text = stringResource(CoreR.string.reboot),
modifier = Modifier.padding(horizontal = 16.dp)
)
}
}
}
}
@@ -1,14 +1,10 @@
package com.topjohnwu.magisk.ui.flash
import android.view.MenuItem
import androidx.databinding.Bindable
import androidx.databinding.ObservableArrayList
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import com.topjohnwu.magisk.BR
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseViewModel
import com.topjohnwu.magisk.core.Const
import com.topjohnwu.magisk.core.Info
@@ -20,10 +16,12 @@ import com.topjohnwu.magisk.core.tasks.FlashZip
import com.topjohnwu.magisk.core.tasks.MagiskInstaller
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.events.SnackbarEvent
import com.topjohnwu.superuser.CallbackList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class FlashViewModel : BaseViewModel() {
@@ -36,18 +34,20 @@ class FlashViewModel : BaseViewModel() {
val state: LiveData<State> get() = _state
val flashing = state.map { it == State.FLASHING }
@get:Bindable
var showReboot = Info.isRooted
set(value) = set(value, field, { field = it }, BR.showReboot)
private val _flashState = MutableStateFlow(State.FLASHING)
val flashState: StateFlow<State> = _flashState.asStateFlow()
val items = ObservableArrayList<ConsoleItem>()
private val _showReboot = MutableStateFlow(Info.isRooted)
val showReboot: StateFlow<Boolean> = _showReboot.asStateFlow()
val consoleItems = mutableStateListOf<String>()
lateinit var args: FlashFragmentArgs
private val logItems = mutableListOf<String>().synchronized()
private val outItems = object : CallbackList<String>() {
override fun onAddElement(e: String?) {
e ?: return
items.add(ConsoleItem(e))
consoleItems.add(e)
logItems.add(e)
}
}
@@ -62,7 +62,7 @@ class FlashViewModel : BaseViewModel() {
FlashZip(uri, outItems, logItems).exec()
}
Const.Value.UNINSTALL -> {
showReboot = false
_showReboot.value = false
MagiskInstaller.Uninstall(outItems, logItems).exec()
}
Const.Value.FLASH_MAGISK -> {
@@ -72,12 +72,12 @@ class FlashViewModel : BaseViewModel() {
MagiskInstaller.Direct(outItems, logItems).exec()
}
Const.Value.FLASH_INACTIVE_SLOT -> {
showReboot = false
_showReboot.value = false
MagiskInstaller.SecondSlot(outItems, logItems).exec()
}
Const.Value.PATCH_FILE -> {
uri ?: return@launch
showReboot = false
_showReboot.value = false
MagiskInstaller.Patch(uri, outItems, logItems).exec()
}
else -> {
@@ -90,17 +90,12 @@ class FlashViewModel : BaseViewModel() {
}
private fun onResult(success: Boolean) {
_state.value = if (success) State.SUCCESS else State.FAILED
val newState = if (success) State.SUCCESS else State.FAILED
_state.value = newState
_flashState.value = newState
}
fun onMenuItemClicked(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_save -> savePressed()
}
return true
}
private fun savePressed() = withExternalRW {
fun saveLog() = withExternalRW {
viewModelScope.launch(Dispatchers.IO) {
val name = "magisk_install_log_%s.log".format(
System.currentTimeMillis().toTime(timeFormatStandard)
@@ -1,69 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.topjohnwu.magisk.ui.flash.FlashViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/internal_action_bar_size"
app:layout_fitsSystemWindowsInsets="top"
tools:layout_marginTop="@dimen/internal_action_bar_size">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/flash_content"
scrollToLast="@{true}"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
app:fitsSystemWindowsInsets="start|end|bottom"
app:items="@{viewModel.items}"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/item_console_md2" />
</HorizontalScrollView>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/restart_btn"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/l1"
android:layout_marginBottom="@dimen/l1"
android:clickable="@{!viewModel.flashing}"
android:enabled="@{!viewModel.flashing}"
android:focusable="true"
android:onClick="@{() -> viewModel.restartPressed()}"
android:text="@string/reboot"
android:textAllCaps="false"
android:textColor="?colorOnPrimary"
android:textStyle="bold"
app:backgroundTint="?colorPrimary"
app:icon="@drawable/ic_restart"
app:iconTint="?colorOnPrimary"
app:layout_fitsSystemWindowsInsets="bottom" />
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/snackbar_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:fitsSystemWindowsInsets="top|bottom" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
+1 -2
View File
@@ -28,8 +28,7 @@
<fragment
android:id="@+id/flashFragment"
android:name="com.topjohnwu.magisk.ui.flash.FlashFragment"
android:label="FlashFragment"
tools:layout="@layout/fragment_flash_md2">
android:label="FlashFragment">
<argument
android:name="action"