Migrate Install screen to Jetpack Compose with miuix

Replace data binding and @Bindable properties with StateFlow<UiState>.
Implement InstallScreen composable with options card, method selection,
and release notes. Remove fragment_install_md2.xml layout.

Made-with: Cursor
This commit is contained in:
LoveSy
2026-03-03 12:55:40 +08:00
committed by topjohnwu
parent 45c7cf19c5
commit 08d0b9be27
5 changed files with 301 additions and 322 deletions
@@ -1,18 +1,58 @@
package com.topjohnwu.magisk.ui.install
import com.topjohnwu.magisk.R
import com.topjohnwu.magisk.arch.BaseFragment
import com.topjohnwu.magisk.arch.viewModel
import com.topjohnwu.magisk.databinding.FragmentInstallMd2Binding
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
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.ui.theme.MagiskTheme
import com.topjohnwu.magisk.core.R as CoreR
class InstallFragment : BaseFragment<FragmentInstallMd2Binding>() {
class InstallFragment : Fragment(), ViewModelHolder {
override val layoutRes = R.layout.fragment_install_md2
override val viewModel by viewModel<InstallViewModel>()
override val viewModel by lazy {
ViewModelProvider(this, VMFactory)[InstallViewModel::class.java]
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startObserveLiveData()
}
override fun onStart() {
super.onStart()
requireActivity().setTitle(CoreR.string.install)
(activity as? NavigationActivity<*>)?.setTitle(CoreR.string.install)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MagiskTheme {
InstallScreen(viewModel = viewModel as InstallViewModel)
}
}
}
}
override fun onEventDispatched(event: ViewEvent) {
when (event) {
is ContextExecutor -> event(requireContext())
is ActivityExecutor -> (activity as? UIActivity<*>)?.let { event(it) }
}
}
}
@@ -0,0 +1,202 @@
package com.topjohnwu.magisk.ui.install
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
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.unit.dp
import com.topjohnwu.magisk.core.Config
import com.topjohnwu.magisk.core.Info
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Checkbox
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 InstallScreen(viewModel: InstallViewModel) {
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp)
.padding(top = 8.dp, bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (!viewModel.skipOptions) {
OptionsCard(uiState = uiState, viewModel = viewModel)
}
MethodCard(uiState = uiState, viewModel = viewModel)
if (uiState.notes.isNotEmpty()) {
NotesCard(notes = uiState.notes)
}
}
}
@Composable
private fun OptionsCard(uiState: InstallViewModel.UiState, viewModel: InstallViewModel) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(CoreR.string.install_options_title),
style = MiuixTheme.textStyles.headline2,
)
if (uiState.step == 0) {
TextButton(
text = stringResource(CoreR.string.install_next),
onClick = { viewModel.nextStep() }
)
}
}
if (uiState.step == 0) {
Spacer(Modifier.height(8.dp))
if (!Info.isSAR) {
CheckboxRow(
label = stringResource(CoreR.string.keep_dm_verity),
checked = Config.keepVerity,
onCheckedChange = { Config.keepVerity = it }
)
}
if (Info.isFDE) {
CheckboxRow(
label = stringResource(CoreR.string.keep_force_encryption),
checked = Config.keepEnc,
onCheckedChange = { Config.keepEnc = it }
)
}
if (!Info.ramdisk) {
CheckboxRow(
label = stringResource(CoreR.string.recovery_mode),
checked = Config.recovery,
onCheckedChange = { Config.recovery = it }
)
}
}
}
}
}
@Composable
private fun MethodCard(uiState: InstallViewModel.UiState, viewModel: InstallViewModel) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(CoreR.string.install_method_title),
style = MiuixTheme.textStyles.headline2,
)
if (uiState.step == 1) {
TextButton(
text = stringResource(CoreR.string.install_start),
onClick = { viewModel.install() },
enabled = viewModel.canInstall
)
}
}
if (uiState.step == 1) {
Spacer(Modifier.height(8.dp))
MethodRadioRow(
label = stringResource(CoreR.string.select_patch_file),
selected = uiState.method == InstallViewModel.Method.PATCH,
onClick = { viewModel.selectMethod(InstallViewModel.Method.PATCH) }
)
if (viewModel.isRooted) {
MethodRadioRow(
label = stringResource(CoreR.string.direct_install),
selected = uiState.method == InstallViewModel.Method.DIRECT,
onClick = { viewModel.selectMethod(InstallViewModel.Method.DIRECT) }
)
}
if (!viewModel.noSecondSlot) {
MethodRadioRow(
label = stringResource(CoreR.string.install_inactive_slot),
selected = uiState.method == InstallViewModel.Method.INACTIVE_SLOT,
onClick = { viewModel.selectMethod(InstallViewModel.Method.INACTIVE_SLOT) }
)
}
}
}
}
}
@Composable
private fun NotesCard(notes: String) {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = notes,
style = MiuixTheme.textStyles.body2,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
modifier = Modifier.padding(16.dp)
)
}
}
@Composable
private fun CheckboxRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = checked,
onCheckedChange = { onCheckedChange(it) }
)
Text(
text = label,
style = MiuixTheme.textStyles.body1,
)
}
}
@Composable
private fun MethodRadioRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Checkbox(
checked = selected,
onCheckedChange = { onClick() }
)
Text(
text = label,
style = MiuixTheme.textStyles.body1,
)
}
}
@@ -1,17 +1,10 @@
package com.topjohnwu.magisk.ui.install
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.text.Spanned
import android.text.SpannedString
import android.widget.Toast
import androidx.databinding.Bindable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
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.AppContext
import com.topjohnwu.magisk.core.BuildConfig.APP_VERSION_CODE
@@ -20,12 +13,15 @@ import com.topjohnwu.magisk.core.Info
import com.topjohnwu.magisk.core.base.ContentResultCallback
import com.topjohnwu.magisk.core.ktx.toast
import com.topjohnwu.magisk.core.repository.NetworkService
import com.topjohnwu.magisk.databinding.set
import com.topjohnwu.magisk.dialog.SecondSlotWarningDialog
import com.topjohnwu.magisk.events.GetContentEvent
import com.topjohnwu.magisk.ui.flash.FlashFragment
import io.noties.markwon.Markwon
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
@@ -36,36 +32,24 @@ import com.topjohnwu.magisk.core.R as CoreR
class InstallViewModel(svc: NetworkService, markwon: Markwon) : BaseViewModel() {
enum class Method { NONE, PATCH, DIRECT, INACTIVE_SLOT }
data class UiState(
val step: Int = 0,
val method: Method = Method.NONE,
val notes: String = "",
val patchUri: Uri? = null,
)
val isRooted get() = Info.isRooted
val skipOptions = Info.isEmulator || (Info.isSAR && !Info.isFDE && Info.ramdisk)
val noSecondSlot = !isRooted || !Info.isAB || Info.isEmulator
@get:Bindable
var step = if (skipOptions) 1 else 0
set(value) = set(value, field, { field = it }, BR.step)
private var methodId = -1
@get:Bindable
var method
get() = methodId
set(value) = set(value, methodId, { methodId = it }, BR.method) {
when (it) {
R.id.method_patch -> {
GetContentEvent("*/*", UriCallback()).publish()
}
R.id.method_inactive_slot -> {
SecondSlotWarningDialog().show()
}
}
}
private val _uiState = MutableStateFlow(UiState(step = if (skipOptions) 1 else 0))
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
val data: LiveData<Uri?> get() = uri
@get:Bindable
var notes: Spanned = SpannedString("")
set(value) = set(value, field, { field = it }, BR.notes)
init {
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -81,44 +65,53 @@ class InstallViewModel(svc: NetworkService, markwon: Markwon) : BaseViewModel()
}
val spanned = markwon.toMarkdown(noteText)
withContext(Dispatchers.Main) {
notes = spanned
_uiState.update { it.copy(notes = spanned.toString()) }
}
} catch (e: IOException) {
Timber.e(e)
}
}
uri.observeForever { newUri ->
_uiState.update { it.copy(patchUri = newUri) }
}
}
fun nextStep() {
_uiState.update { it.copy(step = 1) }
}
fun selectMethod(method: Method) {
_uiState.update { it.copy(method = method) }
when (method) {
Method.PATCH -> {
GetContentEvent("*/*", UriCallback()).publish()
}
Method.INACTIVE_SLOT -> {
SecondSlotWarningDialog().show()
}
else -> {}
}
}
fun install() {
when (method) {
R.id.method_patch -> FlashFragment.patch(data.value!!).navigate(true)
R.id.method_direct -> FlashFragment.flash(false).navigate(true)
R.id.method_inactive_slot -> FlashFragment.flash(true).navigate(true)
else -> error("Unknown value")
when (_uiState.value.method) {
Method.PATCH -> FlashFragment.patch(data.value!!).navigate(true)
Method.DIRECT -> FlashFragment.flash(false).navigate(true)
Method.INACTIVE_SLOT -> FlashFragment.flash(true).navigate(true)
else -> error("Unknown method")
}
}
override fun onSaveState(state: Bundle) {
state.putParcelable(
INSTALL_STATE_KEY, InstallState(
methodId,
step,
Config.keepVerity,
Config.keepEnc,
Config.recovery
)
)
}
override fun onRestoreState(state: Bundle) {
state.getParcelable<InstallState>(INSTALL_STATE_KEY)?.let {
methodId = it.method
step = it.step
Config.keepVerity = it.keepVerity
Config.keepEnc = it.keepEnc
Config.recovery = it.recovery
val canInstall: Boolean
get() {
val state = _uiState.value
return when (state.method) {
Method.PATCH -> state.patchUri != null
Method.DIRECT, Method.INACTIVE_SLOT -> true
Method.NONE -> false
}
}
}
@Parcelize
class UriCallback : ContentResultCallback {
@@ -131,17 +124,7 @@ class InstallViewModel(svc: NetworkService, markwon: Markwon) : BaseViewModel()
}
}
@Parcelize
class InstallState(
val method: Int,
val step: Int,
val keepVerity: Boolean,
val keepEnc: Boolean,
val recovery: Boolean,
) : Parcelable
companion object {
private const val INSTALL_STATE_KEY = "install_state"
private val uri = MutableLiveData<Uri?>()
}
}
@@ -1,245 +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>
<import type="com.topjohnwu.magisk.core.Info" />
<import type="com.topjohnwu.magisk.core.Config" />
<variable
name="viewModel"
type="com.topjohnwu.magisk.ui.install.InstallViewModel" />
</data>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:fillViewport="true"
android:paddingTop="@dimen/internal_action_bar_size"
android:paddingBottom="@dimen/l2"
app:fitsSystemWindowsInsets="top|bottom"
tools:paddingTop="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingTop="@dimen/l_50">
<com.google.android.material.card.MaterialCardView
style="@style/WidgetFoundation.Card"
gone="@{viewModel.skipOptions}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/l1"
android:layout_marginEnd="@dimen/l1"
android:focusable="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
style="@style/WidgetFoundation.Icon"
isSelected="@{viewModel.step > 0}"
android:layout_marginStart="@dimen/l_25"
app:srcCompat="@drawable/ic_check_circle_md2" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="@dimen/l1"
android:layout_weight="1"
android:gravity="center_vertical"
android:text="@string/install_options_title"
android:textAppearance="@style/AppearanceFoundation.Body"
android:textStyle="bold" />
<Button
style="@style/WidgetFoundation.Button.Text"
gone="@{viewModel.step != 0}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="@{() -> viewModel.setStep(1)}"
android:text="@string/install_next" />
</LinearLayout>
<LinearLayout
gone="@{viewModel.step != 0}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/l1"
android:layout_marginTop="@dimen/l_50"
android:layout_marginEnd="@dimen/l1"
android:layout_marginBottom="@dimen/l_50"
android:orientation="vertical"
android:paddingStart="3dp"
android:paddingEnd="3dp"
tools:layout_gravity="center">
<CheckBox
style="@style/WidgetFoundation.Checkbox"
gone="@{Info.isSAR}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@={Config.keepVerity}"
android:text="@string/keep_dm_verity"
tools:checked="true" />
<CheckBox
style="@style/WidgetFoundation.Checkbox"
goneUnless="@{Info.isFDE}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@={Config.keepEnc}"
android:text="@string/keep_force_encryption"
app:tint="?colorPrimary" />
<CheckBox
style="@style/WidgetFoundation.Checkbox"
gone="@{Info.ramdisk}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:checked="@={Config.recovery}"
android:text="@string/recovery_mode"
app:tint="?colorPrimary" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
style="@style/WidgetFoundation.Card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/l1"
android:layout_marginTop="@dimen/l1"
android:layout_marginEnd="@dimen/l1"
android:focusable="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
style="@style/WidgetFoundation.Icon"
isSelected="@{viewModel.step > 1}"
android:layout_marginStart="@dimen/l_25"
app:srcCompat="@drawable/ic_check_circle_md2" />
<TextView
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="@dimen/l1"
android:layout_weight="1"
android:gravity="center_vertical"
android:text="@string/install_method_title"
android:textAppearance="@style/AppearanceFoundation.Body"
android:textStyle="bold" />
<Button
style="@style/WidgetFoundation.Button.Text"
gone="@{viewModel.step != 1}"
isEnabled="@{viewModel.method == @id/method_patch ? viewModel.data != null : viewModel.method != -1}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:onClick="@{() -> viewModel.install()}"
android:text="@string/install_start"
app:icon="@drawable/ic_forth_md2"
app:iconGravity="textEnd" />
</LinearLayout>
<RadioGroup
gone="@{viewModel.step != 1}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/l1"
android:layout_marginTop="@dimen/l_50"
android:layout_marginEnd="@dimen/l1"
android:layout_marginBottom="@dimen/l_50"
android:checkedButton="@={viewModel.method}"
android:paddingStart="3dp"
android:paddingEnd="3dp">
<RadioButton
android:id="@+id/method_patch"
style="@style/WidgetFoundation.RadioButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/select_patch_file" />
<RadioButton
android:id="@+id/method_direct"
style="@style/WidgetFoundation.RadioButton"
gone="@{!viewModel.rooted}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/direct_install" />
<RadioButton
android:id="@+id/method_inactive_slot"
style="@style/WidgetFoundation.RadioButton"
gone="@{viewModel.noSecondSlot}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/install_inactive_slot" />
</RadioGroup>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
style="@style/WidgetFoundation.Card"
gone="@{viewModel.notes.length == 0}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/l1"
android:layout_marginTop="@dimen/l1"
android:layout_marginEnd="@dimen/l1"
android:focusable="false">
<TextView
android:id="@+id/release_notes"
markdownText="@{viewModel.notes}"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="15dp"
android:breakStrategy="simple"
android:hyphenationFrequency="none"
android:textAppearance="@style/AppearanceFoundation.Caption"
tools:ignore="UnusedAttribute"
tools:maxLines="5"
tools:text="@tools:sample/lorem/random"
tools:visibility="visible" />
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</layout>
+1 -2
View File
@@ -59,8 +59,7 @@
<fragment
android:id="@+id/installFragment"
android:name="com.topjohnwu.magisk.ui.install.InstallFragment"
android:label="InstallFragment"
tools:layout="@layout/fragment_install_md2" />
android:label="InstallFragment" />
<fragment
android:id="@+id/logFragment"