diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallFragment.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallFragment.kt index 2b7307d61..17ee806ed 100644 --- a/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallFragment.kt +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallFragment.kt @@ -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() { +class InstallFragment : Fragment(), ViewModelHolder { - override val layoutRes = R.layout.fragment_install_md2 - override val viewModel by viewModel() + 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) } + } } } diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallScreen.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallScreen.kt new file mode 100644 index 000000000..53f63d5ea --- /dev/null +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallScreen.kt @@ -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, + ) + } +} diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt index 6508ce4e1..0955272d2 100644 --- a/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt @@ -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.asStateFlow() val data: LiveData 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(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() } } diff --git a/app/apk/src/main/res/layout/fragment_install_md2.xml b/app/apk/src/main/res/layout/fragment_install_md2.xml deleted file mode 100644 index 7a7364379..000000000 --- a/app/apk/src/main/res/layout/fragment_install_md2.xml +++ /dev/null @@ -1,245 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - -