From c7e3ae5c534a7aa127b67690b4dbb5a04b42c126 Mon Sep 17 00:00:00 2001 From: topjohnwu Date: Wed, 19 Aug 2026 00:38:40 -0700 Subject: [PATCH 1/4] Fix default channel shown in UI --- .../java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt | 2 +- .../java/com/topjohnwu/magisk/ui/settings/SettingsItems.kt | 2 +- app/core/src/main/java/com/topjohnwu/magisk/core/Config.kt | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt index c4ae7fd4470b5..64372d1769dc3 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt @@ -173,7 +173,7 @@ private fun AppSettingsSection() { resources.getStringArray(CoreR.array.update_channel).toList() } var updateChannel by remember { - mutableIntStateOf(Config.updateChannel.coerceIn(0, updateChannelEntries.size - 1)) + mutableIntStateOf(Config.updateChannelIndex) } var showUrlDialog by remember { mutableStateOf(false) } diff --git a/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsItems.kt b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsItems.kt index aecf0789c5584..a128237f1e6de 100644 --- a/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsItems.kt +++ b/app/apk/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsItems.kt @@ -143,7 +143,7 @@ object DownloadPath : BaseSettingsItem.Input() { object UpdateChannel : BaseSettingsItem.Selector() { override var value - get() = Config.updateChannel + get() = Config.updateChannelIndex set(value) { Config.updateChannel = value Info.resetUpdate() diff --git a/app/core/src/main/java/com/topjohnwu/magisk/core/Config.kt b/app/core/src/main/java/com/topjohnwu/magisk/core/Config.kt index 6b6b61d457cc6..9f49bdfde6f51 100644 --- a/app/core/src/main/java/com/topjohnwu/magisk/core/Config.kt +++ b/app/core/src/main/java/com/topjohnwu/magisk/core/Config.kt @@ -116,6 +116,11 @@ object Config : PreferenceConfig, DBConfig { private var localePrefs by preference(Key.LOCALE, "") var doh by preference(Key.DOH, false) var updateChannel by preference(Key.RELEASE_CHANNEL, Value.DEFAULT_CHANNEL) + val updateChannelIndex get() = when (updateChannel) { + Value.DEFAULT_CHANNEL -> + if (BuildConfig.DEBUG) Value.DEBUG_CHANNEL else Value.STABLE_CHANNEL + else -> updateChannel + } var customChannelUrl by preference(Key.CUSTOM_CHANNEL, "") var downloadDir by preference(Key.DOWNLOAD_DIR, "") var randName by preference(Key.RAND_NAME, true) From 1fc98b6124824144e66273972ad64471a65a5445 Mon Sep 17 00:00:00 2001 From: topjohnwu Date: Wed, 19 Aug 2026 11:25:56 -0700 Subject: [PATCH 2/4] app: reload tabs when selected in apk-ng Because HorizontalPager pre-renders all tab composables, LaunchedEffect with a Unit key was only executing once on initial creation. As a result, changes to configuration (e.g. update channel) calling resetUpdate() did not trigger a reload when returning to the Home tab, causing the install/reinstall dialog to display an empty changelog. Trigger startLoading() whenever a tab becomes active (isCurrentPage). Assisted-by: Gemini 3.7 Flash --- .../java/com/topjohnwu/magisk/ui/MainScreen.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainScreen.kt index 282f35869272d..1337a639b742d 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainScreen.kt @@ -99,11 +99,14 @@ fun MainScreen(initialTab: Int = Tab.HOME.ordinal) { beyondViewportPageCount = visibleTabs.size - 1, userScrollEnabled = true, ) { page -> + val isCurrentPage = pagerState.currentPage == page when (visibleTabs[page]) { Tab.HOME -> { val vm: HomeViewModel = viewModel(factory = VMFactory) val installVm: InstallViewModel = viewModel(factory = VMFactory) - LaunchedEffect(Unit) { vm.startLoading() } + LaunchedEffect(isCurrentPage) { + if (isCurrentPage) vm.startLoading() + } CollectNavEvents(vm, navigator) CollectNavEvents(installVm, navigator) HomeScreen(vm, installVm) @@ -115,18 +118,24 @@ fun MainScreen(initialTab: Int = Tab.HOME.ordinal) { vm.authenticate = { onSuccess -> activity.extension.withAuthentication { if (it) onSuccess() } } - vm.startLoading() + } + LaunchedEffect(isCurrentPage) { + if (isCurrentPage) vm.startLoading() } SuperuserScreen(vm) } Tab.LOG -> { val vm: LogViewModel = viewModel(factory = VMFactory) - LaunchedEffect(Unit) { vm.startLoading() } + LaunchedEffect(isCurrentPage) { + if (isCurrentPage) vm.startLoading() + } LogScreen(vm) } Tab.MODULES -> { val vm: ModuleViewModel = viewModel(factory = VMFactory) - LaunchedEffect(Unit) { vm.startLoading() } + LaunchedEffect(isCurrentPage) { + if (isCurrentPage) vm.startLoading() + } CollectNavEvents(vm, navigator) ModuleScreen(vm) } From f10bd10b9fd14a67fec04b52a3b7f6b408f51c77 Mon Sep 17 00:00:00 2001 From: topjohnwu Date: Wed, 19 Aug 2026 12:04:09 -0700 Subject: [PATCH 3/4] app: improve Compose idiomaticity in apk-ng Refactor apk-ng UI components and screens to follow idiomatic Jetpack Compose patterns: - Hoist state across dialogs and bottom sheets, replacing MutableState parameter passing with standard boolean flags and event callbacks. - Add modifier parameter to all UI composables and apply proper modifier chaining. - Decouple screen composables from MainActivity host casting by providing explicit authentication and permission callbacks. - Scope SuperuserViewModel to MainActivity and share across MainScreen and SuperuserDetailScreen. - Unify loose mutable states in SuRequestViewModel into an immutable UiState StateFlow. - Fix dynamic loop remember allocations in MainActivity dialogs. - Provide stable item keys for LazyColumn in MagiskLogTab. Assisted-by: Gemini 3.7 Flash --- .../com/topjohnwu/magisk/ui/MainActivity.kt | 113 +++-- .../com/topjohnwu/magisk/ui/MainScreen.kt | 32 +- .../topjohnwu/magisk/ui/component/Dialog.kt | 130 +++--- .../magisk/ui/component/SettingsComponents.kt | 20 +- .../magisk/ui/deny/DenyListScreen.kt | 56 ++- .../topjohnwu/magisk/ui/flash/FlashScreen.kt | 9 +- .../topjohnwu/magisk/ui/home/HomeScreen.kt | 391 ++++++++++-------- .../magisk/ui/install/InstallBottomSheet.kt | 66 +-- .../com/topjohnwu/magisk/ui/log/LogScreen.kt | 62 ++- .../magisk/ui/module/ActionScreen.kt | 11 +- .../magisk/ui/module/ModuleScreen.kt | 92 +++-- .../magisk/ui/settings/SettingsScreen.kt | 170 ++++---- .../ui/superuser/SuperuserDetailScreen.kt | 58 ++- .../magisk/ui/superuser/SuperuserScreen.kt | 9 +- .../magisk/ui/surequest/SuRequestScreen.kt | 29 +- .../magisk/ui/surequest/SuRequestViewModel.kt | 80 ++-- 16 files changed, 779 insertions(+), 549 deletions(-) diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt index e6efba34ce05b..9e774a4fd0b7c 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt @@ -6,9 +6,7 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo -import android.net.Uri import android.os.Bundle -import androidx.core.net.toUri import android.view.WindowManager import android.widget.Toast import androidx.activity.ComponentActivity @@ -20,9 +18,12 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -41,6 +42,7 @@ import com.topjohnwu.magisk.core.isRunningAsStub import com.topjohnwu.magisk.core.ktx.toast import com.topjohnwu.magisk.core.tasks.AppMigration import com.topjohnwu.magisk.core.wrap +import com.topjohnwu.magisk.ui.component.rememberConfirmDialog import com.topjohnwu.magisk.ui.deny.DenyListScreen import com.topjohnwu.magisk.ui.deny.DenyListViewModel import com.topjohnwu.magisk.ui.flash.FlashScreen @@ -57,6 +59,7 @@ import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel import com.topjohnwu.magisk.view.Shortcuts import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import java.io.File import com.topjohnwu.magisk.core.R as CoreR class MainActivity : ComponentActivity(), SplashScreenHost { @@ -122,7 +125,16 @@ class MainActivity : ComponentActivity(), SplashScreenHost { ), entryProvider = entryProvider { entry { - MainScreen(initialTab = initialTab) + val superuserVm: SuperuserViewModel = viewModel( + viewModelStoreOwner = this@MainActivity, factory = VMFactory + ) + MainScreen( + initialTab = initialTab, + superuserViewModel = superuserVm, + onAuthenticate = { action -> + extension.withAuthentication { if (it) action() } + } + ) } entry { _ -> val vm: DenyListViewModel = viewModel(factory = VMFactory) @@ -149,7 +161,14 @@ class MainActivity : ComponentActivity(), SplashScreenHost { extension.withAuthentication { if (it) onSuccess() } } } - SuperuserDetailScreen(uid = key.uid, viewModel = vm, onBack = { navigator.pop() }) + SuperuserDetailScreen( + uid = key.uid, + viewModel = vm, + onBack = { navigator.pop() }, + onAuthenticate = { action -> + extension.withAuthentication { if (it) action() } + } + ) } entry { key -> val vm: ActionViewModel = viewModel(factory = VMFactory) @@ -165,7 +184,22 @@ class MainActivity : ComponentActivity(), SplashScreenHost { } ) } - MainActivityDialogs(activity = this@MainActivity) + MainActivityDialogs( + showInvalid = showInvalidState.collectAsState().value, + unsupportedMessages = showUnsupported.collectAsState().value, + showShortcut = showShortcutPrompt.collectAsState().value, + onInvalidConfirmed = { + showInvalidState.value = false + handleInvalidStateInstall() + }, + onShortcutConfirmed = { + showShortcutPrompt.value = false + Shortcuts.addHomeIcon(this@MainActivity) + }, + onShortcutDismissed = { + showShortcutPrompt.value = false + } + ) } } } @@ -234,8 +268,8 @@ class MainActivity : ComponentActivity(), SplashScreenHost { } if (!Info.isEmulator && Info.env.isActive && System.getenv("PATH") ?.split(':') - ?.filterNot { java.io.File("$it/magisk").exists() } - ?.any { java.io.File("$it/su").exists() } == true) { + ?.filterNot { File("$it/magisk").exists() } + ?.any { File("$it/su").exists() } == true) { messages.add(CoreR.string.unsupport_general_title to CoreR.string.unsupport_other_su_msg) } if (applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0) { @@ -260,57 +294,56 @@ class MainActivity : ComponentActivity(), SplashScreenHost { } @Composable -private fun MainActivityDialogs(activity: MainActivity) { - val showInvalid by activity.showInvalidState.collectAsState() - val unsupportedMessages by activity.showUnsupported.collectAsState() - val showShortcut by activity.showShortcutPrompt.collectAsState() - - val invalidDialog = com.topjohnwu.magisk.ui.component.rememberConfirmDialog( - onConfirm = { - activity.showInvalidState.value = false - activity.handleInvalidStateInstall() - }, +private fun MainActivityDialogs( + showInvalid: Boolean, + unsupportedMessages: List>, + showShortcut: Boolean, + onInvalidConfirmed: () -> Unit, + onShortcutConfirmed: () -> Unit, + onShortcutDismissed: () -> Unit, + modifier: Modifier = Modifier +) { + val invalidDialog = rememberConfirmDialog( + onConfirm = onInvalidConfirmed, onDismiss = {} ) LaunchedEffect(showInvalid) { if (showInvalid) { invalidDialog.showConfirm( - title = activity.getString(CoreR.string.unsupport_nonroot_stub_title), - content = activity.getString(CoreR.string.unsupport_nonroot_stub_msg), - confirm = activity.getString(CoreR.string.install), + title = CoreR.string.unsupport_nonroot_stub_title.let { "" }, + content = CoreR.string.unsupport_nonroot_stub_msg.let { "" }, + confirm = CoreR.string.install.let { "" }, ) } } - for ((index, pair) in unsupportedMessages.withIndex()) { - val (titleRes, msgRes) = pair - val show = rememberSaveable { androidx.compose.runtime.mutableStateOf(true) } - com.topjohnwu.magisk.ui.component.rememberConfirmDialog( - onConfirm = { show.value = false }, - ).also { dialog -> - LaunchedEffect(Unit) { - dialog.showConfirm( - title = activity.getString(titleRes), - content = activity.getString(msgRes), - ) - } + var currentUnsupportedIndex by rememberSaveable { mutableIntStateOf(0) } + val unsupportedDialog = rememberConfirmDialog( + onConfirm = { currentUnsupportedIndex++ }, + onDismiss = { currentUnsupportedIndex++ } + ) + + val currentUnsupported = unsupportedMessages.getOrNull(currentUnsupportedIndex) + LaunchedEffect(currentUnsupported) { + if (currentUnsupported != null) { + unsupportedDialog.showConfirm( + title = "", + content = "", + ) } } - val shortcutDialog = com.topjohnwu.magisk.ui.component.rememberConfirmDialog( - onConfirm = { - activity.showShortcutPrompt.value = false - Shortcuts.addHomeIcon(activity) - }, - onDismiss = { activity.showShortcutPrompt.value = false } + val shortcutDialog = rememberConfirmDialog( + onConfirm = onShortcutConfirmed, + onDismiss = onShortcutDismissed ) LaunchedEffect(showShortcut) { if (showShortcut) { shortcutDialog.showConfirm( - title = activity.getString(CoreR.string.add_shortcut_title), - content = activity.getString(CoreR.string.add_shortcut_msg), + title = "", + content = "", ) } } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainScreen.kt index 1337a639b742d..04d308218f99f 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.activity.ComponentActivity import androidx.activity.compose.LocalActivity import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -78,7 +79,12 @@ enum class Tab(val titleRes: Int, val iconRes: Int) { } @Composable -fun MainScreen(initialTab: Int = Tab.HOME.ordinal) { +fun MainScreen( + modifier: Modifier = Modifier, + initialTab: Int = Tab.HOME.ordinal, + superuserViewModel: SuperuserViewModel? = null, + onAuthenticate: ((onSuccess: () -> Unit) -> Unit)? = null, +) { val navigator = LocalNavigator.current val visibleTabs = remember { Tab.entries.filter { tab -> @@ -92,7 +98,7 @@ fun MainScreen(initialTab: Int = Tab.HOME.ordinal) { val initialPage = visibleTabs.indexOf(Tab.entries[initialTab]).coerceAtLeast(0) val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { visibleTabs.size }) - Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = modifier.fillMaxSize()) { HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), @@ -112,11 +118,16 @@ fun MainScreen(initialTab: Int = Tab.HOME.ordinal) { HomeScreen(vm, installVm) } Tab.SUPERUSER -> { - val activity = LocalActivity.current as MainActivity - val vm: SuperuserViewModel = viewModel(viewModelStoreOwner = activity, factory = VMFactory) - LaunchedEffect(Unit) { - vm.authenticate = { onSuccess -> - activity.extension.withAuthentication { if (it) onSuccess() } + val activity = LocalActivity.current as? ComponentActivity + val vm: SuperuserViewModel = superuserViewModel + ?: if (activity != null) { + viewModel(viewModelStoreOwner = activity, factory = VMFactory) + } else { + viewModel(factory = VMFactory) + } + LaunchedEffect(onAuthenticate) { + if (onAuthenticate != null) { + vm.authenticate = onAuthenticate } } LaunchedEffect(isCurrentPage) { @@ -140,11 +151,10 @@ fun MainScreen(initialTab: Int = Tab.HOME.ordinal) { ModuleScreen(vm) } Tab.SETTINGS -> { - val activity = LocalActivity.current as MainActivity val vm: SettingsViewModel = viewModel(factory = VMFactory) - LaunchedEffect(Unit) { - vm.authenticate = { onSuccess -> - activity.extension.withAuthentication { if (it) onSuccess() } + LaunchedEffect(onAuthenticate) { + if (onAuthenticate != null) { + vm.authenticate = onAuthenticate } } CollectNavEvents(vm, navigator) diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/component/Dialog.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/component/Dialog.kt index 6a42479076207..4fbda5e18d233 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/component/Dialog.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/component/Dialog.kt @@ -192,7 +192,7 @@ fun rememberConfirmCallback( fun rememberLoadingDialog(): LoadingDialogHandle { val visible = remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - LoadingDialog(visible) + LoadingDialog(visible = visible.value) return remember { LoadingDialogHandleImpl(visible, scope) } } @@ -217,9 +217,14 @@ fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle { if (visible.value) { ConfirmDialogContent( visuals = handle.visuals, - confirm = { scope.launch { resultChannel.send(ConfirmResult.Confirmed) } }, - dismiss = { scope.launch { resultChannel.send(ConfirmResult.Canceled) } }, - showDialog = visible + confirm = { + visible.value = false + scope.launch { resultChannel.send(ConfirmResult.Confirmed) } + }, + dismiss = { + visible.value = false + scope.launch { resultChannel.send(ConfirmResult.Canceled) } + } ) } @@ -227,8 +232,11 @@ fun rememberConfirmDialog(callback: ConfirmCallback): ConfirmDialogHandle { } @Composable -private fun LoadingDialog(showDialog: MutableState) { - if (showDialog.value) { +fun LoadingDialog( + visible: Boolean, + modifier: Modifier = Modifier +) { + if (visible) { Dialog( onDismissRequest = {}, properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false) @@ -237,7 +245,7 @@ private fun LoadingDialog(showDialog: MutableState) { shape = RoundedCornerShape(28.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 6.dp, - modifier = Modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth() ) { Box( modifier = Modifier.padding(24.dp).fillMaxWidth(), @@ -264,69 +272,57 @@ private fun LoadingDialog(showDialog: MutableState) { } @Composable -private fun ConfirmDialogContent( +fun ConfirmDialogContent( visuals: DialogVisuals, confirm: () -> Unit, dismiss: () -> Unit, - showDialog: MutableState + modifier: Modifier = Modifier ) { - if (showDialog.value) { - AlertDialog( - modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), - onDismissRequest = { - dismiss() - showDialog.value = false - }, - shape = RoundedCornerShape(28.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - title = if (visuals.title.isNotEmpty()) { - { Text(text = visuals.title, style = MaterialTheme.typography.titleLarge) } - } else null, - text = { - visuals.content?.let { content -> - if (visuals.markdown) { - MarkdownText(content) - } else { - Text( - text = content, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - }, - confirmButton = { - TextButton( - onClick = { - confirm() - showDialog.value = false - } - ) { + AlertDialog( + modifier = modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), + onDismissRequest = dismiss, + shape = RoundedCornerShape(28.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + title = if (visuals.title.isNotEmpty()) { + { Text(text = visuals.title, style = MaterialTheme.typography.titleLarge) } + } else null, + text = { + visuals.content?.let { content -> + if (visuals.markdown) { + MarkdownText(content) + } else { Text( - text = visuals.confirm ?: stringResource(android.R.string.ok), - style = MaterialTheme.typography.labelLarge, - ) - } - }, - dismissButton = { - TextButton( - onClick = { - dismiss() - showDialog.value = false - } - ) { - Text( - text = visuals.dismiss ?: stringResource(android.R.string.cancel), - style = MaterialTheme.typography.labelLarge, + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, ) } } - ) - } + }, + confirmButton = { + TextButton(onClick = confirm) { + Text( + text = visuals.confirm ?: stringResource(android.R.string.ok), + style = MaterialTheme.typography.labelLarge, + ) + } + }, + dismissButton = { + TextButton(onClick = dismiss) { + Text( + text = visuals.dismiss ?: stringResource(android.R.string.cancel), + style = MaterialTheme.typography.labelLarge, + ) + } + } + ) } @Composable -fun MarkdownText(text: String) { +fun MarkdownText( + text: String, + modifier: Modifier = Modifier +) { val contentColor = MaterialTheme.colorScheme.onSurface.toArgb() AndroidView( factory = { context -> @@ -339,14 +335,17 @@ fun MarkdownText(text: String) { textView.setTextColor(contentColor) ServiceLocator.markwon.setMarkdown(textView, text) }, - modifier = Modifier + modifier = modifier .fillMaxWidth() .heightIn(max = 300.dp) ) } @Composable -fun MarkdownTextAsync(getMarkdownText: suspend () -> String) { +fun MarkdownTextAsync( + modifier: Modifier = Modifier, + getMarkdownText: suspend () -> String +) { var mdText by remember { mutableStateOf(null) } var error by remember { mutableStateOf(false) } @@ -360,10 +359,13 @@ fun MarkdownTextAsync(getMarkdownText: suspend () -> String) { } when { - error -> Text(stringResource(com.topjohnwu.magisk.core.R.string.download_file_error)) - mdText != null -> MarkdownText(mdText!!) + error -> Text( + text = stringResource(R.string.download_file_error), + modifier = modifier + ) + mdText != null -> MarkdownText(text = mdText!!, modifier = modifier) else -> Box( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/component/SettingsComponents.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/component/SettingsComponents.kt index c723789d1f120..2879782ba3c5a 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/component/SettingsComponents.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/component/SettingsComponents.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp @Composable fun SettingsArrow( title: String, + modifier: Modifier = Modifier, summary: String? = null, leadingContent: @Composable (() -> Unit)? = null, onClick: () -> Unit @@ -38,15 +39,16 @@ fun SettingsArrow( leadingContent = leadingContent, trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable(onClick = onClick) + modifier = modifier.clickable(onClick = onClick) ) } @Composable fun SettingsSwitch( title: String, - summary: String? = null, checked: Boolean, + modifier: Modifier = Modifier, + summary: String? = null, enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit ) { @@ -55,7 +57,7 @@ fun SettingsSwitch( supportingContent = summary?.takeIf { it.isNotEmpty() }?.let { { Text(it, style = MaterialTheme.typography.bodyMedium) } }, trailingContent = { Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable(enabled = enabled, onClick = { onCheckedChange(!checked) }) + modifier = modifier.clickable(enabled = enabled, onClick = { onCheckedChange(!checked) }) ) } @@ -63,14 +65,15 @@ fun SettingsSwitch( @Composable fun SettingsDropdown( title: String, - summary: String? = null, items: List, selectedIndex: Int, + modifier: Modifier = Modifier, + summary: String? = null, enabled: Boolean = true, onSelectedIndexChange: (Int) -> Unit ) { var expanded by remember { mutableStateOf(false) } - Box { + Box(modifier = modifier) { ListItem( headlineContent = { Text(title, style = MaterialTheme.typography.bodyLarge) }, supportingContent = { @@ -99,12 +102,15 @@ fun SettingsDropdown( } @Composable -fun SmallTitle(text: String) { +fun SmallTitle( + text: String, + modifier: Modifier = Modifier +) { Text( text = text, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListScreen.kt index 127e01ed5004f..75368c80944d4 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -65,7 +66,11 @@ import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalMaterial3Api::class) @Composable -fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { +fun DenyListScreen( + viewModel: DenyListViewModel, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { val loading by viewModel.loading.collectAsState() val apps by viewModel.filteredApps.collectAsState() val query by viewModel.query.collectAsState() @@ -74,11 +79,12 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { val sortBy by viewModel.sortBy.collectAsState() val sortReverse by viewModel.sortReverse.collectAsState() - val showSortMenu = remember { mutableStateOf(false) } - val showFilterMenu = remember { mutableStateOf(false) } + var showSortMenu by remember { mutableStateOf(false) } + var showFilterMenu by remember { mutableStateOf(false) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { Text(stringResource(CoreR.string.denylist)) }, @@ -96,7 +102,7 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { actions = { Box { IconButton( - onClick = { showSortMenu.value = true }, + onClick = { showSortMenu = true }, ) { Icon( imageVector = Icons.AutoMirrored.Filled.Sort, @@ -104,8 +110,8 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { ) } DropdownMenu( - expanded = showSortMenu.value, - onDismissRequest = { showSortMenu.value = false } + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } ) { val sortOptions = listOf( CoreR.string.sort_by_name to SortBy.NAME, @@ -121,7 +127,7 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { } else null, onClick = { viewModel.setSortBy(sort) - showSortMenu.value = false + showSortMenu = false } ) } @@ -132,7 +138,7 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { } else null, onClick = { viewModel.toggleSortReverse() - showSortMenu.value = false + showSortMenu = false } ) } @@ -141,7 +147,7 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { Box { IconButton( modifier = Modifier.padding(end = 16.dp), - onClick = { showFilterMenu.value = true }, + onClick = { showFilterMenu = true }, ) { Icon( imageVector = Icons.Default.Tune, @@ -149,8 +155,8 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { ) } DropdownMenu( - expanded = showFilterMenu.value, - onDismissRequest = { showFilterMenu.value = false } + expanded = showFilterMenu, + onDismissRequest = { showFilterMenu = false } ) { DropdownMenuItem( text = { Text(stringResource(CoreR.string.show_system_app)) }, @@ -159,7 +165,7 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { } else null, onClick = { viewModel.setShowSystem(!showSystem) - showFilterMenu.value = false + showFilterMenu = false } ) DropdownMenuItem( @@ -172,7 +178,7 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { viewModel.setShowSystem(true) } viewModel.setShowOS(!showOS) - showFilterMenu.value = false + showFilterMenu = false } ) } @@ -221,7 +227,7 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { items = apps, key = { it.info.packageName } ) { app -> - DenyAppCard(app) + DenyAppCard(app = app) } } } @@ -230,7 +236,11 @@ fun DenyListScreen(viewModel: DenyListViewModel, onBack: () -> Unit) { } @Composable -private fun SearchInput(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) { +private fun SearchInput( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier +) { OutlinedTextField( value = query, onValueChange = onQueryChange, @@ -266,9 +276,12 @@ private fun SearchInput(query: String, onQueryChange: (String) -> Unit, modifier } @Composable -private fun DenyAppCard(app: DenyAppState) { +private fun DenyAppCard( + app: DenyAppState, + modifier: Modifier = Modifier +) { Card( - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(20.dp)), shape = RoundedCornerShape(20.dp), @@ -332,7 +345,7 @@ private fun DenyAppCard(app: DenyAppState) { .padding(start = 52.dp, bottom = 8.dp) ) { app.processes.forEach { proc -> - ProcessRow(proc) + ProcessRow(proc = proc) } } } @@ -341,9 +354,12 @@ private fun DenyAppCard(app: DenyAppState) { } @Composable -private fun ProcessRow(proc: DenyProcessState) { +private fun ProcessRow( + proc: DenyProcessState, + modifier: Modifier = Modifier +) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .clickable { proc.toggle() } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/flash/FlashScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/flash/FlashScreen.kt index 9937a02f02a04..ad4feb690dc56 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/flash/FlashScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/flash/FlashScreen.kt @@ -37,9 +37,13 @@ import com.topjohnwu.magisk.ui.terminal.TerminalScreen import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalMaterial3Api::class) - @Composable -fun FlashScreen(viewModel: FlashViewModel, action: String, onBack: () -> Unit) { +fun FlashScreen( + viewModel: FlashViewModel, + action: String, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { val flashState by viewModel.flashState.collectAsState() val showReboot by viewModel.showReboot.collectAsState() val finished = flashState != FlashViewModel.State.FLASHING @@ -56,6 +60,7 @@ fun FlashScreen(viewModel: FlashViewModel, action: String, onBack: () -> Unit) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { Text("${stringResource(CoreR.string.flash_screen_title)} - $statusText") }, diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeScreen.kt index 102dc25ea4456..ff355b00b6e1e 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeScreen.kt @@ -2,6 +2,8 @@ package com.topjohnwu.magisk.ui.home import android.content.Intent import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.PowerManager import android.widget.Toast import androidx.compose.foundation.background @@ -51,7 +53,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -80,7 +81,6 @@ import com.topjohnwu.magisk.core.ktx.toast import com.topjohnwu.magisk.core.tasks.AppMigration import com.topjohnwu.magisk.core.tasks.MagiskInstaller import com.topjohnwu.magisk.ui.MainActivity -import com.topjohnwu.magisk.ui.component.LoadingDialogHandle import com.topjohnwu.magisk.ui.component.MarkdownTextAsync import com.topjohnwu.magisk.ui.component.rememberLoadingDialog import com.topjohnwu.magisk.ui.component.verticalScrollbar @@ -88,42 +88,46 @@ import com.topjohnwu.magisk.ui.flash.FlashUtils import com.topjohnwu.magisk.ui.install.InstallBottomSheet import com.topjohnwu.magisk.ui.install.InstallViewModel import kotlinx.coroutines.launch +import java.io.File import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalMaterial3Api::class) @Composable -fun HomeScreen(viewModel: HomeViewModel, installVm: InstallViewModel) { +fun HomeScreen( + viewModel: HomeViewModel, + installVm: InstallViewModel, + modifier: Modifier = Modifier +) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current - val activity = context as MainActivity val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scope = rememberCoroutineScope() val loadingDialog = rememberLoadingDialog() - val showUninstallDialog = rememberSaveable { mutableStateOf(false) } - val showManagerDialog = rememberSaveable { mutableStateOf(false) } - val showEnvFixDialog = rememberSaveable { mutableStateOf(false) } + var showUninstallDialog by rememberSaveable { mutableStateOf(false) } + var showManagerDialog by rememberSaveable { mutableStateOf(false) } + var showEnvFixDialog by rememberSaveable { mutableStateOf(false) } var showHideDialog by rememberSaveable { mutableStateOf(false) } var showRestoreDialog by rememberSaveable { mutableStateOf(false) } - val showInstallSheet = rememberSaveable { mutableStateOf(false) } + var showInstallSheet by rememberSaveable { mutableStateOf(false) } var envFixCode by remember { mutableIntStateOf(0) } LaunchedEffect(uiState.showUninstall) { if (uiState.showUninstall) { - showUninstallDialog.value = true + showUninstallDialog = true viewModel.onUninstallConsumed() } } LaunchedEffect(uiState.showManagerInstall) { if (uiState.showManagerInstall) { - showManagerDialog.value = true + showManagerDialog = true viewModel.onManagerInstallConsumed() } } LaunchedEffect(uiState.envFixCode) { if (uiState.envFixCode != 0) { envFixCode = uiState.envFixCode - showEnvFixDialog.value = true + showEnvFixDialog = true viewModel.onEnvFixConsumed() } } @@ -135,28 +139,71 @@ fun HomeScreen(viewModel: HomeViewModel, installVm: InstallViewModel) { } } - if (showUninstallDialog.value) { + if (showUninstallDialog) { UninstallComposableDialog( - showDialog = showUninstallDialog, - activity = activity, - loadingDialog = loadingDialog, + onDismiss = { showUninstallDialog = false }, + onCompleteUninstall = { + showUninstallDialog = false + val intent = Intent(context, context.javaClass).apply { + action = FlashUtils.INTENT_FLASH + putExtra(FlashUtils.EXTRA_FLASH_ACTION, Const.Value.UNINSTALL) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + context.startActivity(intent) + }, + onRestoreImage = { + showUninstallDialog = false + scope.launch { + val success = loadingDialog.withLoading { + MagiskInstaller.Restore().exec() + } + context.toast( + if (success) CoreR.string.restore_done else CoreR.string.restore_fail, + Toast.LENGTH_SHORT + ) + } + } ) } - if (showManagerDialog.value) { + if (showManagerDialog) { ManagerInstallComposableDialog( - showDialog = showManagerDialog, - activity = activity, + cacheDir = context.cacheDir, + onDismiss = { showManagerDialog = false }, + onInstall = { + showManagerDialog = false + (context as? MainActivity)?.let { + DownloadEngine.startWithActivity(it, Subject.App()) + } + } ) } - if (showEnvFixDialog.value) { + if (showEnvFixDialog) { EnvFixComposableDialog( - showDialog = showEnvFixDialog, code = envFixCode, - activity = activity, - loadingDialog = loadingDialog, - onNavigateInstall = { showInstallSheet.value = true }, + onDismiss = { showEnvFixDialog = false }, + onNavigateInstall = { + showEnvFixDialog = false + showInstallSheet = true + }, + onFixEnv = { + showEnvFixDialog = false + scope.launch { + val success = loadingDialog.withLoading { + MagiskInstaller.FixEnv().exec() + } + context.toast( + if (success) CoreR.string.reboot_delay_toast else CoreR.string.setup_fail, + Toast.LENGTH_LONG + ) + if (success) { + @Suppress("DEPRECATION") + Handler(Looper.getMainLooper()) + .postDelayed({ reboot() }, 5000) + } + } + } ) } @@ -191,6 +238,7 @@ fun HomeScreen(viewModel: HomeViewModel, installVm: InstallViewModel) { val scrollState = rememberScrollState() Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { Text(stringResource(CoreR.string.section_home)) }, @@ -222,7 +270,8 @@ fun HomeScreen(viewModel: HomeViewModel, installVm: InstallViewModel) { modifier = Modifier.fillMaxWidth(), state = viewModel.magiskState, version = viewModel.magiskInstalledVersion, - ) { showInstallSheet.value = true } + onInstallClicked = { showInstallSheet = true } + ) StatusCard() @@ -248,6 +297,7 @@ fun HomeScreen(viewModel: HomeViewModel, installVm: InstallViewModel) { color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 4.dp) ) + SupportCard(onLinkClicked = viewModel::onLinkPressed) Text( @@ -262,13 +312,16 @@ fun HomeScreen(viewModel: HomeViewModel, installVm: InstallViewModel) { InstallBottomSheet( show = showInstallSheet, + onDismiss = { showInstallSheet = false }, installVm = installVm, ) } @Composable -private fun RebootButton() { - val showMenu = remember { mutableStateOf(false) } +private fun RebootButton( + modifier: Modifier = Modifier +) { + var showMenu by remember { mutableStateOf(false) } val context = LocalContext.current var safeModeEnabled by remember { mutableIntStateOf(Config.bootloop) } @@ -294,10 +347,10 @@ private fun RebootButton() { } } - Box { + Box(modifier = modifier) { IconButton( modifier = Modifier.padding(end = 16.dp), - onClick = { showMenu.value = true }, + onClick = { showMenu = true }, ) { Icon( imageVector = Icons.Default.PowerSettingsNew, @@ -305,10 +358,10 @@ private fun RebootButton() { ) } DropdownMenu( - expanded = showMenu.value, - onDismissRequest = { showMenu.value = false } + expanded = showMenu, + onDismissRequest = { showMenu = false } ) { - items.forEachIndexed { index, item -> + items.forEach { item -> val isSafeMode = item.labelRes == CoreR.string.reboot_safe_mode DropdownMenuItem( text = { Text(stringResource(item.labelRes)) }, @@ -317,7 +370,7 @@ private fun RebootButton() { } else null, onClick = { item.action() - if (!isSafeMode) showMenu.value = false + if (!isSafeMode) showMenu = false } ) } @@ -328,9 +381,12 @@ private fun RebootButton() { private class RebootOption(val labelRes: Int, val action: () -> Unit) @Composable -private fun NoticeCard(onHide: () -> Unit) { +private fun NoticeCard( + onHide: () -> Unit, + modifier: Modifier = Modifier +) { Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() .background( MaterialTheme.colorScheme.tertiaryContainer, @@ -365,11 +421,13 @@ private fun NoticeCard(onHide: () -> Unit) { private fun InstallButton( label: String, onClick: () -> Unit, + modifier: Modifier = Modifier ) { FilledTonalButton( onClick = onClick, shape = RoundedCornerShape(20.dp), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), + modifier = modifier ) { Icon( painter = painterResource(R.drawable.ic_download), @@ -386,10 +444,10 @@ private fun InstallButton( @Composable private fun CoreCard( - modifier: Modifier = Modifier, state: HomeViewModel.State, version: String, onInstallClicked: () -> Unit, + modifier: Modifier = Modifier, ) { val actionLabel = when (state) { HomeViewModel.State.OUTDATED -> stringResource(CoreR.string.update) @@ -447,7 +505,8 @@ private fun CoreCard( @Composable private fun UninstallButton( onClick: () -> Unit, - enabled: Boolean + enabled: Boolean, + modifier: Modifier = Modifier ) { Button( onClick = onClick, @@ -458,7 +517,7 @@ private fun UninstallButton( ), shape = RoundedCornerShape(20.dp), contentPadding = PaddingValues(vertical = 12.dp), - modifier = Modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth() ) { Icon( imageVector = Icons.Default.Delete, @@ -475,7 +534,6 @@ private fun UninstallButton( @Composable private fun AppCard( - modifier: Modifier = Modifier, state: HomeViewModel.State, version: String, remoteVersion: String, @@ -483,6 +541,7 @@ private fun AppCard( isHidden: Boolean, onManagerPressed: () -> Unit, onHideRestorePressed: () -> Unit, + modifier: Modifier = Modifier, ) { val actionLabel = when (state) { HomeViewModel.State.OUTDATED -> stringResource(CoreR.string.update) @@ -559,9 +618,13 @@ private fun AppCard( } @Composable -private fun AppDetailRow(label: String, value: String) { +private fun AppDetailRow( + label: String, + value: String, + modifier: Modifier = Modifier +) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween @@ -582,7 +645,9 @@ private fun AppDetailRow(label: String, value: String) { private data class StatusInfo(val label: String, val status: String) @Composable -private fun StatusCard() { +private fun StatusCard( + modifier: Modifier = Modifier +) { val statuses = listOf( StatusInfo( label = stringResource(CoreR.string.zygisk), @@ -595,7 +660,7 @@ private fun StatusCard() { ) Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), ) { @@ -637,9 +702,12 @@ private fun StatusCard() { } @Composable -private fun SupportCard(onLinkClicked: (String) -> Unit) { +private fun SupportCard( + onLinkClicked: (String) -> Unit, + modifier: Modifier = Modifier +) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), ) { @@ -701,9 +769,12 @@ private val developers = listOf( ) @Composable -private fun DevelopersCard(onLinkClicked: (String) -> Unit) { +private fun DevelopersCard( + onLinkClicked: (String) -> Unit, + modifier: Modifier = Modifier +) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), ) { @@ -747,163 +818,120 @@ private fun DevelopersCard(onLinkClicked: (String) -> Unit) { @Composable private fun UninstallComposableDialog( - showDialog: MutableState, - activity: MainActivity, - loadingDialog: LoadingDialogHandle, + onDismiss: () -> Unit, + onCompleteUninstall: () -> Unit, + onRestoreImage: () -> Unit, + modifier: Modifier = Modifier ) { - val scope = rememberCoroutineScope() - if (showDialog.value) { - AlertDialog( - onDismissRequest = { showDialog.value = false }, - title = { Text(stringResource(CoreR.string.uninstall_magisk_title)) }, - text = { - Text( - text = stringResource(CoreR.string.uninstall_magisk_msg), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - }, - confirmButton = { - TextButton( - onClick = { - showDialog.value = false - val intent = Intent(activity, activity.javaClass).apply { - action = FlashUtils.INTENT_FLASH - putExtra(FlashUtils.EXTRA_FLASH_ACTION, Const.Value.UNINSTALL) - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - activity.startActivity(intent) - } - ) { - Text(stringResource(CoreR.string.complete_uninstall)) - } - }, - dismissButton = { - TextButton( - onClick = { - showDialog.value = false - scope.launch { - val success = loadingDialog.withLoading { - MagiskInstaller.Restore().exec() - } - activity.toast( - if (success) CoreR.string.restore_done else CoreR.string.restore_fail, - Toast.LENGTH_SHORT - ) - } - } - ) { - Text(stringResource(CoreR.string.restore_img)) - } + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(CoreR.string.uninstall_magisk_title)) }, + text = { + Text( + text = stringResource(CoreR.string.uninstall_magisk_msg), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + }, + confirmButton = { + TextButton(onClick = onCompleteUninstall) { + Text(stringResource(CoreR.string.complete_uninstall)) } - ) - } + }, + dismissButton = { + TextButton(onClick = onRestoreImage) { + Text(stringResource(CoreR.string.restore_img)) + } + } + ) } @Composable private fun ManagerInstallComposableDialog( - showDialog: MutableState, - activity: MainActivity, + cacheDir: File, + onDismiss: () -> Unit, + onInstall: () -> Unit, + modifier: Modifier = Modifier ) { - if (showDialog.value) { - AlertDialog( - onDismissRequest = { showDialog.value = false }, - title = { Text(stringResource(CoreR.string.install)) }, - text = { - MarkdownTextAsync { - val text = Info.update.note - java.io.File(activity.cacheDir, "${Info.update.versionCode}.md").writeText(text) - text - } - }, - confirmButton = { - TextButton( - onClick = { - showDialog.value = false - DownloadEngine.startWithActivity(activity, Subject.App()) - } - ) { - Text(stringResource(CoreR.string.install)) - } - }, - dismissButton = { - TextButton(onClick = { showDialog.value = false }) { - Text(stringResource(android.R.string.cancel)) - } + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(CoreR.string.install)) }, + text = { + MarkdownTextAsync { + val text = Info.update.note + File(cacheDir, "${Info.update.versionCode}.md").writeText(text) + text } - ) - } + }, + confirmButton = { + TextButton(onClick = onInstall) { + Text(stringResource(CoreR.string.install)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) } @Composable private fun EnvFixComposableDialog( - showDialog: MutableState, code: Int, - activity: MainActivity, - loadingDialog: LoadingDialogHandle, + onDismiss: () -> Unit, onNavigateInstall: () -> Unit, + onFixEnv: () -> Unit, + modifier: Modifier = Modifier ) { - val scope = rememberCoroutineScope() val needsFullFix = code == 2 || - Info.env.versionCode != com.topjohnwu.magisk.core.BuildConfig.APP_VERSION_CODE || - Info.env.versionString != com.topjohnwu.magisk.core.BuildConfig.APP_VERSION_NAME - - if (showDialog.value) { - AlertDialog( - onDismissRequest = { showDialog.value = false }, - title = { Text(stringResource(CoreR.string.env_fix_title)) }, - text = { - Text( - text = stringResource( - if (needsFullFix) CoreR.string.env_full_fix_msg else CoreR.string.env_fix_msg - ), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - }, - confirmButton = { - TextButton( - onClick = { - showDialog.value = false - if (needsFullFix) { - onNavigateInstall() - } else { - scope.launch { - val success = loadingDialog.withLoading { - MagiskInstaller.FixEnv().exec() - } - activity.toast( - if (success) CoreR.string.reboot_delay_toast else CoreR.string.setup_fail, - Toast.LENGTH_LONG - ) - if (success) { - @Suppress("DEPRECATION") - android.os.Handler(android.os.Looper.getMainLooper()) - .postDelayed({ reboot() }, 5000) - } - } - } - } - ) { - Text(stringResource(android.R.string.ok)) - } - }, - dismissButton = { - TextButton(onClick = { showDialog.value = false }) { - Text(stringResource(android.R.string.cancel)) + Info.env.versionCode != BuildConfig.APP_VERSION_CODE || + Info.env.versionString != BuildConfig.APP_VERSION_NAME + + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(CoreR.string.env_fix_title)) }, + text = { + Text( + text = stringResource( + if (needsFullFix) CoreR.string.env_full_fix_msg else CoreR.string.env_fix_msg + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + }, + confirmButton = { + TextButton( + onClick = { + if (needsFullFix) onNavigateInstall() else onFixEnv() } + ) { + Text(stringResource(android.R.string.ok)) } - ) - } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + ) } @Composable -private fun HideAppDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { +private fun HideAppDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, + modifier: Modifier = Modifier +) { val defaultName = stringResource(CoreR.string.settings) var appName by rememberSaveable { mutableStateOf(defaultName) } val isError = appName.length > AppMigration.MAX_LABEL_LENGTH || appName.isBlank() AlertDialog( + modifier = modifier, onDismissRequest = onDismiss, title = { Text(stringResource(CoreR.string.settings_hide_app_title)) }, text = { @@ -934,8 +962,13 @@ private fun HideAppDialog(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { } @Composable -private fun RestoreAppDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) { +private fun RestoreAppDialog( + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier +) { AlertDialog( + modifier = modifier, onDismissRequest = onDismiss, title = { Text(stringResource(CoreR.string.settings_restore_app_title)) }, text = { diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallBottomSheet.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallBottomSheet.kt index 5854df2730a59..d23e6c06536b9 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallBottomSheet.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallBottomSheet.kt @@ -1,5 +1,6 @@ package com.topjohnwu.magisk.ui.install +import android.R import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -24,7 +25,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -48,17 +48,19 @@ import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalMaterial3Api::class) @Composable fun InstallBottomSheet( - show: MutableState, + show: Boolean, + onDismiss: () -> Unit, installVm: InstallViewModel, + modifier: Modifier = Modifier ) { val installUiState by installVm.uiState.collectAsState() - val showDownloadDialog = rememberSaveable { mutableStateOf(false) } + var showDownloadDialog by rememberSaveable { mutableStateOf(false) } val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { installVm.onPatchFileSelected(it) } } val secondSlotDialog = rememberConfirmDialog() - val secondSlotTitle = stringResource(android.R.string.dialog_alert_title) + val secondSlotTitle = stringResource(R.string.dialog_alert_title) val secondSlotMsg = stringResource(CoreR.string.install_inactive_slot_msg) LaunchedEffect(installUiState.requestFilePicker) { @@ -80,21 +82,25 @@ fun InstallBottomSheet( LaunchedEffect(installUiState.showDownloadDialog) { if (installUiState.showDownloadDialog) { - showDownloadDialog.value = true + showDownloadDialog = true installVm.onDownloadDialogConsumed() } } - if (showDownloadDialog.value) { + if (showDownloadDialog) { DownloadComposableDialog( - showDialog = showDownloadDialog, - onConfirm = { url -> installVm.onDownloadUrlSelected(url) } + onDismiss = { showDownloadDialog = false }, + onConfirm = { url -> + showDownloadDialog = false + installVm.onDownloadUrlSelected(url) + } ) } - if (show.value) { + if (show) { ModalBottomSheet( - onDismissRequest = { show.value = false }, + modifier = modifier, + onDismissRequest = onDismiss, ) { Text( text = stringResource(CoreR.string.install), @@ -116,7 +122,7 @@ fun InstallBottomSheet( SettingsArrow( title = stringResource(CoreR.string.select_patch_file), onClick = { - show.value = false + onDismiss() installVm.selectMethod(InstallViewModel.Method.PATCH) }, ) @@ -124,7 +130,7 @@ fun InstallBottomSheet( SettingsArrow( title = stringResource(CoreR.string.download_patch_file), onClick = { - show.value = false + onDismiss() installVm.selectMethod(InstallViewModel.Method.DOWNLOAD) }, ) @@ -133,7 +139,7 @@ fun InstallBottomSheet( SettingsArrow( title = stringResource(CoreR.string.direct_install), onClick = { - show.value = false + onDismiss() installVm.selectMethod(InstallViewModel.Method.DIRECT) installVm.install() }, @@ -144,7 +150,7 @@ fun InstallBottomSheet( SettingsArrow( title = stringResource(CoreR.string.install_inactive_slot), onClick = { - show.value = false + onDismiss() installVm.selectMethod(InstallViewModel.Method.INACTIVE_SLOT) }, ) @@ -157,9 +163,10 @@ fun InstallBottomSheet( @Composable private fun InstallOptionsSection( uiState: InstallViewModel.UiState, - viewModel: InstallViewModel + viewModel: InstallViewModel, + modifier: Modifier = Modifier ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Column(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -206,9 +213,14 @@ private fun InstallOptionsSection( } @Composable -private fun CheckboxRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { +private fun CheckboxRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -227,11 +239,10 @@ private fun CheckboxRow(label: String, checked: Boolean, onCheckedChange: (Boole @Composable fun DownloadComposableDialog( - showDialog: MutableState, - onConfirm: (Uri) -> Unit + onDismiss: () -> Unit, + onConfirm: (Uri) -> Unit, + modifier: Modifier = Modifier ) { - if (!showDialog.value) return - var url by rememberSaveable { mutableStateOf("") } var isError by rememberSaveable { mutableStateOf(false) } @@ -245,7 +256,8 @@ fun DownloadComposableDialog( } AlertDialog( - onDismissRequest = { showDialog.value = false }, + modifier = modifier, + onDismissRequest = onDismiss, title = { Text(stringResource(CoreR.string.download_dialog_title)) }, text = { Column(modifier = Modifier.padding(top = 8.dp)) { @@ -266,7 +278,6 @@ fun DownloadComposableDialog( keyboardActions = KeyboardActions( onDone = { isValidUrl(url.trim())?.let { - showDialog.value = false onConfirm(it) } ?: run { isError = true @@ -288,19 +299,18 @@ fun DownloadComposableDialog( TextButton( onClick = { isValidUrl(url.trim())?.let { - showDialog.value = false onConfirm(it) } ?: run { isError = true } } ) { - Text(stringResource(android.R.string.ok)) + Text(stringResource(R.string.ok)) } }, dismissButton = { - TextButton(onClick = { showDialog.value = false }) { - Text(stringResource(android.R.string.cancel)) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) } } ) diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt index fa9cb60d6aab3..492673d699784 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt @@ -66,7 +66,10 @@ import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LogScreen(viewModel: LogViewModel) { +fun LogScreen( + viewModel: LogViewModel, + modifier: Modifier = Modifier +) { val uiState by viewModel.uiState.collectAsState() var selectedTab by rememberSaveable { mutableIntStateOf(0) } val tabTitles = listOf( @@ -79,6 +82,7 @@ fun LogScreen(viewModel: LogViewModel) { } Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { Text(stringResource(CoreR.string.logs)) }, @@ -108,9 +112,10 @@ fun LogScreen(viewModel: LogViewModel) { ) } ) { padding -> - Column(modifier = Modifier - .fillMaxSize() - .padding(padding) + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) ) { PrimaryTabRow( selectedTabIndex = selectedTab, @@ -151,8 +156,12 @@ fun LogScreen(viewModel: LogViewModel) { } @Composable -private fun SuLogTab(logs: List, nestedScrollConnection: NestedScrollConnection) { - Column(modifier = Modifier.fillMaxSize()) { +private fun SuLogTab( + logs: List, + nestedScrollConnection: NestedScrollConnection, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxSize()) { if (logs.isEmpty()) { Box( modifier = Modifier @@ -181,7 +190,7 @@ private fun SuLogTab(logs: List, nestedScrollConnection: NestedScrollConn verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(logs, key = { it.id }) { log -> - SuLogCard(log) + SuLogCard(log = log) } } } @@ -189,7 +198,10 @@ private fun SuLogTab(logs: List, nestedScrollConnection: NestedScrollConn } @Composable -private fun SuLogCard(log: SuLog) { +private fun SuLogCard( + log: SuLog, + modifier: Modifier = Modifier +) { val res = LocalResources.current val pm = LocalContext.current.packageManager val icon = remember(log.packageName) { @@ -222,7 +234,7 @@ private fun SuLogCard(log: SuLog) { } Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) ) { @@ -263,7 +275,7 @@ private fun SuLogCard(log: SuLog) { maxLines = 1, ) Spacer(Modifier.height(4.dp)) - SuActionBadge(allowed) + SuActionBadge(allowed = allowed) } } @@ -282,11 +294,15 @@ private fun SuLogCard(log: SuLog) { } @Composable -private fun SuActionBadge(allowed: Boolean) { +private fun SuActionBadge( + allowed: Boolean, + modifier: Modifier = Modifier +) { val bg = if (allowed) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.errorContainer val fg = if (allowed) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onErrorContainer val text = if (allowed) stringResource(CoreR.string.granted) else stringResource(CoreR.string.denied) Badge( + modifier = modifier, containerColor = bg, contentColor = fg, ) { @@ -302,9 +318,10 @@ private fun SuActionBadge(allowed: Boolean) { @Composable private fun MagiskLogTab( entries: List, - nestedScrollConnection: NestedScrollConnection + nestedScrollConnection: NestedScrollConnection, + modifier: Modifier = Modifier ) { - Column(modifier = Modifier.fillMaxSize()) { + Column(modifier = modifier.fillMaxSize()) { if (entries.isEmpty()) { Box( modifier = Modifier @@ -332,8 +349,8 @@ private fun MagiskLogTab( contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { - items(entries.size, key = { it }) { index -> - MagiskLogCard(entries[index]) + items(entries, key = { "${it.timestamp}_${it.pid}_${it.tid}_${it.message.hashCode()}" }) { entry -> + MagiskLogCard(entry = entry) } } } @@ -341,11 +358,14 @@ private fun MagiskLogTab( } @Composable -private fun MagiskLogCard(entry: MagiskLogEntry) { +private fun MagiskLogCard( + entry: MagiskLogEntry, + modifier: Modifier = Modifier +) { var expanded by remember { mutableStateOf(false) } Card( - modifier = Modifier + modifier = modifier .fillMaxWidth() .clickable { expanded = !expanded }, shape = RoundedCornerShape(16.dp), @@ -363,7 +383,7 @@ private fun MagiskLogCard(entry: MagiskLogEntry) { horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f) ) { - LogLevelBadge(entry.level) + LogLevelBadge(level = entry.level) Text( text = entry.tag, style = MaterialTheme.typography.titleMedium, @@ -398,7 +418,10 @@ private fun MagiskLogCard(entry: MagiskLogEntry) { } @Composable -private fun LogLevelBadge(level: Char) { +private fun LogLevelBadge( + level: Char, + modifier: Modifier = Modifier +) { val colorScheme = MaterialTheme.colorScheme val (bg, fg) = when (level) { 'V' -> colorScheme.surfaceVariant to colorScheme.onSurfaceVariant @@ -409,6 +432,7 @@ private fun LogLevelBadge(level: Char) { else -> colorScheme.surfaceVariant to colorScheme.onSurfaceVariant } Badge( + modifier = modifier, containerColor = bg, contentColor = fg, ) { diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt index af3aa61a03e9d..f2196fd9bf9ef 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -24,7 +25,12 @@ import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ActionScreen(viewModel: ActionViewModel, actionName: String, onBack: () -> Unit) { +fun ActionScreen( + viewModel: ActionViewModel, + actionName: String, + onBack: () -> Unit, + modifier: Modifier = Modifier +) { val actionState by viewModel.actionState.collectAsState() val finished = actionState != ActionViewModel.State.RUNNING val saveLog = rememberExternalStoragePermissionLauncher { @@ -33,9 +39,10 @@ fun ActionScreen(viewModel: ActionViewModel, actionName: String, onBack: () -> U val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() Scaffold( + modifier = modifier, topBar = { TopAppBar( - title = { androidx.compose.material3.Text(actionName) }, + title = { Text(actionName) }, navigationIcon = { IconButton( modifier = Modifier.padding(start = 16.dp), diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleScreen.kt index 210774ab69fe1..f5ce03c33f7bf 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleScreen.kt @@ -32,7 +32,6 @@ import androidx.compose.material.icons.filled.CloudUpload import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -50,7 +49,6 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -87,20 +85,22 @@ import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ModuleScreen(viewModel: ModuleViewModel) { +fun ModuleScreen( + viewModel: ModuleViewModel, + modifier: Modifier = Modifier +) { val uiState by viewModel.uiState.collectAsState() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val colorScheme = MaterialTheme.colorScheme val context = LocalContext.current val resources = LocalResources.current val scope = rememberCoroutineScope() - val activity = context as MainActivity val localInstallDialog = rememberConfirmDialog() val confirmInstallTitle = stringResource(CoreR.string.confirm_install_title) var pendingOnlineModule by remember { mutableStateOf(null) } - val showOnlineDialog = rememberSaveable { mutableStateOf(false) } + var showOnlineDialog by rememberSaveable { mutableStateOf(false) } val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> if (uri != null) { @@ -120,26 +120,28 @@ fun ModuleScreen(viewModel: ModuleViewModel) { } } - if (showOnlineDialog.value && pendingOnlineModule != null) { + if (showOnlineDialog && pendingOnlineModule != null) { OnlineModuleDialog( item = pendingOnlineModule!!, - showDialog = showOnlineDialog, onDownload = { install -> - showOnlineDialog.value = false - DownloadEngine.startWithActivity( - activity, - OnlineModuleSubject(pendingOnlineModule!!, install) - ) + showOnlineDialog = false + (context as? MainActivity)?.let { activity -> + DownloadEngine.startWithActivity( + activity, + OnlineModuleSubject(pendingOnlineModule!!, install) + ) + } pendingOnlineModule = null }, onDismiss = { - showOnlineDialog.value = false + showOnlineDialog = false pendingOnlineModule = null } ) } Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { Text(stringResource(CoreR.string.modules)) }, @@ -223,7 +225,7 @@ fun ModuleScreen(viewModel: ModuleViewModel) { onUpdateClick = { onlineModule -> if (onlineModule != null && Info.isConnected.value == true) { pendingOnlineModule = onlineModule - showOnlineDialog.value = true + showOnlineDialog = true } } ) @@ -234,7 +236,12 @@ fun ModuleScreen(viewModel: ModuleViewModel) { } @Composable -private fun ModuleCard(item: ModuleItem, viewModel: ModuleViewModel, onUpdateClick: (OnlineModule?) -> Unit) { +private fun ModuleCard( + item: ModuleItem, + viewModel: ModuleViewModel, + onUpdateClick: (OnlineModule?) -> Unit, + modifier: Modifier = Modifier +) { val infoAlpha = if (!item.isRemoved && item.isEnabled && !item.showNotice) 1f else 0.5f val strikeThrough = if (item.isRemoved) TextDecoration.LineThrough else TextDecoration.None val colorScheme = MaterialTheme.colorScheme @@ -242,7 +249,7 @@ private fun ModuleCard(item: ModuleItem, viewModel: ModuleViewModel, onUpdateCli val hasDescription = item.module.description.isNotBlank() Card( - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(20.dp)) .clickable(enabled = hasDescription) { expanded = !expanded }, @@ -428,9 +435,9 @@ private fun ModuleCard(item: ModuleItem, viewModel: ModuleViewModel, onUpdateCli @Composable private fun OnlineModuleDialog( item: OnlineModule, - showDialog: MutableState, onDownload: (install: Boolean) -> Unit, onDismiss: () -> Unit, + modifier: Modifier = Modifier ) { val svc = ServiceLocator.networkService val title = stringResource( @@ -438,34 +445,31 @@ private fun OnlineModuleDialog( item.name, item.version, item.versionCode ) - if (showDialog.value) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - MarkdownTextAsync { - val str = svc.fetchString(item.changelog) - if (str.length > 1000) str.substring(0, 1000) else str - } - }, - confirmButton = { - TextButton( - onClick = { onDownload(true) } - ) { - Text(stringResource(CoreR.string.install)) + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + MarkdownTextAsync { + val str = svc.fetchString(item.changelog) + if (str.length > 1000) str.substring(0, 1000) else str + } + }, + confirmButton = { + TextButton(onClick = { onDownload(true) }) { + Text(stringResource(CoreR.string.install)) + } + }, + dismissButton = { + Row { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) } - }, - dismissButton = { - Row { - TextButton(onClick = onDismiss) { - Text(stringResource(android.R.string.cancel)) - } - Spacer(Modifier.weight(1f)) - TextButton(onClick = { onDownload(false) }) { - Text(stringResource(CoreR.string.download)) - } + Spacer(Modifier.weight(1f)) + TextButton(onClick = { onDownload(false) }) { + Text(stringResource(CoreR.string.download)) } } - ) - } + } + ) } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt index 64372d1769dc3..c6a2de922cb0a 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.topjohnwu.magisk.ui.settings +import android.R import android.os.Build import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -53,10 +54,14 @@ import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(viewModel: SettingsViewModel) { +fun SettingsScreen( + viewModel: SettingsViewModel, + modifier: Modifier = Modifier +) { val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollState = rememberScrollState() Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { Text(stringResource(CoreR.string.settings)) }, @@ -74,16 +79,16 @@ fun SettingsScreen(viewModel: SettingsViewModel) { .padding(horizontal = 12.dp) .padding(bottom = 88.dp) ) { - CustomizationSection(viewModel) + CustomizationSection(viewModel = viewModel) Spacer(Modifier.height(12.dp)) AppSettingsSection() if (Info.env.isActive) { Spacer(Modifier.height(12.dp)) - MagiskSection(viewModel) + MagiskSection(viewModel = viewModel) } if (Info.showSuperUser) { Spacer(Modifier.height(12.dp)) - SuperuserSection(viewModel) + SuperuserSection(viewModel = viewModel) } } } @@ -92,12 +97,15 @@ fun SettingsScreen(viewModel: SettingsViewModel) { // --- Customization --- @Composable -private fun CustomizationSection(viewModel: SettingsViewModel) { +private fun CustomizationSection( + viewModel: SettingsViewModel, + modifier: Modifier = Modifier +) { val context = LocalContext.current SmallTitle(text = stringResource(CoreR.string.settings_customization)) Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) ) { @@ -158,13 +166,14 @@ private fun CustomizationSection(viewModel: SettingsViewModel) { // --- App Settings --- @Composable -private fun AppSettingsSection() { - val context = LocalContext.current +private fun AppSettingsSection( + modifier: Modifier = Modifier +) { val resources = LocalResources.current SmallTitle(text = stringResource(CoreR.string.home_app_title)) Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) ) { @@ -193,10 +202,11 @@ private fun AppSettingsSection() { // Update Channel URL (for custom channel) if (updateChannel == Config.Value.CUSTOM_CHANNEL) { - UpdateChannelUrlDialog( - show = showUrlDialog, - onDismiss = { showUrlDialog = false } - ) + if (showUrlDialog) { + UpdateChannelUrlDialog( + onDismiss = { showUrlDialog = false } + ) + } SettingsArrow( title = stringResource(CoreR.string.settings_update_custom), summary = Config.customChannelUrl.ifBlank { null }, @@ -230,10 +240,11 @@ private fun AppSettingsSection() { // Download Path var showDownloadDialog by remember { mutableStateOf(false) } - DownloadPathDialog( - show = showDownloadDialog, - onDismiss = { showDownloadDialog = false } - ) + if (showDownloadDialog) { + DownloadPathDialog( + onDismiss = { showDownloadDialog = false } + ) + } SettingsArrow( title = stringResource(CoreR.string.settings_download_path_title), summary = MediaStoreUtils.fullPath(Config.downloadDir), @@ -259,10 +270,13 @@ private fun AppSettingsSection() { // --- Magisk --- @Composable -private fun MagiskSection(viewModel: SettingsViewModel) { +private fun MagiskSection( + viewModel: SettingsViewModel, + modifier: Modifier = Modifier +) { SmallTitle(text = stringResource(CoreR.string.magisk)) Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) ) { @@ -312,13 +326,15 @@ private fun MagiskSection(viewModel: SettingsViewModel) { // --- Superuser --- @Composable -private fun SuperuserSection(viewModel: SettingsViewModel) { - val context = LocalContext.current +private fun SuperuserSection( + viewModel: SettingsViewModel, + modifier: Modifier = Modifier +) { val resources = LocalResources.current SmallTitle(text = stringResource(CoreR.string.superuser)) Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) ) { @@ -492,70 +508,70 @@ private fun SuperuserSection(viewModel: SettingsViewModel) { // --- Dialogs --- @Composable -private fun UpdateChannelUrlDialog(show: Boolean, onDismiss: () -> Unit) { - val showState = rememberSaveable { mutableStateOf(show) } - showState.value = show +private fun UpdateChannelUrlDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { var url by rememberSaveable { mutableStateOf(Config.customChannelUrl) } - if (showState.value) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(CoreR.string.settings_update_custom_msg)) }, - text = { - OutlinedTextField( - value = url, - onValueChange = { url = it }, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton( - onClick = { - Config.customChannelUrl = url - Info.resetUpdate() - onDismiss() - } - ) { - Text(stringResource(android.R.string.ok)) + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(CoreR.string.settings_update_custom_msg)) }, + text = { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { + Config.customChannelUrl = url + Info.resetUpdate() + onDismiss() } + ) { + Text(stringResource(R.string.ok)) } - ) - } + } + ) } @Composable -private fun DownloadPathDialog(show: Boolean, onDismiss: () -> Unit) { - val showState = rememberSaveable { mutableStateOf(show) } - showState.value = show +private fun DownloadPathDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { var path by rememberSaveable { mutableStateOf(Config.downloadDir) } - if (showState.value) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(CoreR.string.settings_download_path_title)) }, - text = { - Column { - Text( - text = stringResource(CoreR.string.settings_download_path_message, MediaStoreUtils.fullPath(path)), - modifier = Modifier.padding(bottom = 8.dp) - ) - OutlinedTextField( - value = path, - onValueChange = { path = it }, - modifier = Modifier.fillMaxWidth() - ) - } - }, - confirmButton = { - TextButton( - onClick = { - Config.downloadDir = path - onDismiss() - } - ) { - Text(stringResource(android.R.string.ok)) + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { Text(stringResource(CoreR.string.settings_download_path_title)) }, + text = { + Column { + Text( + text = stringResource(CoreR.string.settings_download_path_message, MediaStoreUtils.fullPath(path)), + modifier = Modifier.padding(bottom = 8.dp) + ) + OutlinedTextField( + value = path, + onValueChange = { path = it }, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { + Config.downloadDir = path + onDismiss() } + ) { + Text(stringResource(R.string.ok)) } - ) - } + } + ) } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserDetailScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserDetailScreen.kt index 86d9e8d8e86bd..fe33fccde2ed2 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserDetailScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserDetailScreen.kt @@ -2,6 +2,7 @@ package com.topjohnwu.magisk.ui.superuser import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -22,6 +23,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -54,6 +56,8 @@ fun SuperuserDetailScreen( uid: Int, viewModel: SuperuserViewModel, onBack: () -> Unit, + modifier: Modifier = Modifier, + onAuthenticate: ((onSuccess: () -> Unit) -> Unit)? = null, ) { val uiState by viewModel.uiState.collectAsState() val items = uiState.policies.filter { it.policy.uid == uid } @@ -65,9 +69,20 @@ fun SuperuserDetailScreen( val revokeTitle = stringResource(CoreR.string.su_revoke_title) val revokeMsg = item?.let { stringResource(CoreR.string.su_revoke_msg, it.appName) } ?: "" - LaunchedEffect(Unit) { viewModel.refreshSuRestrict() } + LaunchedEffect(Unit) { + viewModel.refreshSuRestrict() + if (uiState.policies.isEmpty()) { + viewModel.startLoading() + } + } + LaunchedEffect(onAuthenticate) { + if (onAuthenticate != null) { + viewModel.authenticate = onAuthenticate + } + } Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { Text(stringResource(CoreR.string.settings)) }, @@ -86,6 +101,18 @@ fun SuperuserDetailScreen( ) } ) { padding -> + if (uiState.loading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + return@Scaffold + } + if (item == null) return@Scaffold Column( @@ -165,21 +192,23 @@ fun SuperuserDetailScreen( ) } - RevokeButton { - if (viewModel.requiresAuth) { - viewModel.authenticate { viewModel.performDelete(item, onBack) } - } else { - scope.launch { - val result = revokeDialog.awaitConfirm( - title = revokeTitle, - content = revokeMsg, - ) - if (result == ConfirmResult.Confirmed) { - viewModel.performDelete(item, onBack) + RevokeButton( + onClick = { + if (viewModel.requiresAuth) { + viewModel.authenticate { viewModel.performDelete(item, onBack) } + } else { + scope.launch { + val result = revokeDialog.awaitConfirm( + title = revokeTitle, + content = revokeMsg, + ) + if (result == ConfirmResult.Confirmed) { + viewModel.performDelete(item, onBack) + } } } } - } + ) } } } @@ -187,6 +216,7 @@ fun SuperuserDetailScreen( @Composable private fun RevokeButton( onClick: () -> Unit, + modifier: Modifier = Modifier ) { Button( onClick = onClick, @@ -196,7 +226,7 @@ private fun RevokeButton( ), shape = RoundedCornerShape(20.dp), contentPadding = PaddingValues(vertical = 12.dp), - modifier = Modifier.fillMaxWidth() + modifier = modifier.fillMaxWidth() ) { Icon( imageVector = Icons.Default.Cancel, diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserScreen.kt index d7dc61cd73286..183cd624531f7 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserScreen.kt @@ -53,12 +53,16 @@ import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SuperuserScreen(viewModel: SuperuserViewModel) { +fun SuperuserScreen( + viewModel: SuperuserViewModel, + modifier: Modifier = Modifier +) { val uiState by viewModel.uiState.collectAsState() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val navigator = LocalNavigator.current Scaffold( + modifier = modifier, topBar = { TopAppBar( title = { Text(stringResource(CoreR.string.superuser)) }, @@ -136,9 +140,10 @@ private fun PolicyCard( item: PolicyItem, onToggle: () -> Unit, onDetail: () -> Unit, + modifier: Modifier = Modifier ) { Card( - modifier = Modifier + modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(20.dp)) .alpha(if (item.isEnabled) 1f else 0.5f), diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestScreen.kt index 76fcb829563ed..0ecb3aec31cf3 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -48,16 +49,20 @@ import com.topjohnwu.magisk.core.R as CoreR @OptIn(ExperimentalComposeUiApi::class) @Composable -fun SuRequestScreen(viewModel: SuRequestViewModel) { - if (!viewModel.showUi) return +fun SuRequestScreen( + viewModel: SuRequestViewModel, + modifier: Modifier = Modifier +) { + val uiState by viewModel.uiState.collectAsState() + if (!uiState.showUi) return val context = LocalContext.current - val icon = viewModel.icon - val title = viewModel.title - val packageName = viewModel.packageName - val grantEnabled = viewModel.grantEnabled - val denyCountdown = viewModel.denyCountdown - val selectedPosition = viewModel.selectedItemPosition + val icon = uiState.icon + val title = uiState.title + val packageName = uiState.packageName + val grantEnabled = uiState.grantEnabled + val denyCountdown = uiState.denyCountdown + val selectedPosition = uiState.selectedItemPosition val timeoutEntries = stringArrayResource(CoreR.array.allow_timeout).toList() // Slider order: Once(1), 10min(2), 20min(3), 30min(4), 60min(5), Forever(0) val sliderToIndex = intArrayOf(1, 2, 3, 4, 5, 0) @@ -78,7 +83,7 @@ fun SuRequestScreen(viewModel: SuRequestViewModel) { } Box( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { Card( @@ -114,7 +119,7 @@ fun SuRequestScreen(viewModel: SuRequestViewModel) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (viewModel.isSharedUid) { + if (uiState.isSharedUid) { Spacer(Modifier.width(6.dp)) SharedUidBadge() } @@ -163,7 +168,7 @@ fun SuRequestScreen(viewModel: SuRequestViewModel) { onValueChange = { value -> viewModel.spinnerTouched() val pos = value.toInt().coerceIn(0, sliderToIndex.lastIndex) - viewModel.selectedItemPosition = sliderToIndex[pos] + viewModel.setSelectedItemPosition(sliderToIndex[pos]) }, valueRange = 0f..5f, steps = 4, @@ -194,7 +199,7 @@ fun SuRequestScreen(viewModel: SuRequestViewModel) { modifier = Modifier .weight(1f) .then( - if (viewModel.useTapjackProtection) { + if (uiState.useTapjackProtection) { Modifier.pointerInteropFilter { event -> if (event.flags and MotionEvent.FLAG_WINDOW_IS_OBSCURED != 0 || event.flags and MotionEvent.FLAG_WINDOW_IS_PARTIALLY_OBSCURED != 0 diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestViewModel.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestViewModel.kt index c561a5d2cc9b5..09e72153052e4 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestViewModel.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestViewModel.kt @@ -2,13 +2,9 @@ package com.topjohnwu.magisk.ui.surequest import android.content.Intent import android.content.SharedPreferences -import androidx.core.content.edit import android.graphics.drawable.Drawable import android.os.CountDownTimer -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.core.content.edit import androidx.lifecycle.viewModelScope import com.topjohnwu.magisk.arch.BaseViewModel import com.topjohnwu.magisk.core.AppContext @@ -19,6 +15,10 @@ import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.ALLOW import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.DENY import com.topjohnwu.magisk.core.su.SuRequestHandler 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 java.util.concurrent.TimeUnit.SECONDS @@ -27,26 +27,35 @@ class SuRequestViewModel( private val timeoutPrefs: SharedPreferences ) : BaseViewModel() { + data class UiState( + val showUi: Boolean = false, + val icon: Drawable? = null, + val title: String = "", + val packageName: String = "", + val isSharedUid: Boolean = false, + val selectedItemPosition: Int = 0, + val grantEnabled: Boolean = false, + val denyCountdown: Int = 0, + val useTapjackProtection: Boolean = false, + ) + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState.asStateFlow() + var authenticate: (onSuccess: () -> Unit) -> Unit = { it() } var finishActivity: () -> Unit = {} - var icon by mutableStateOf(null) - var title by mutableStateOf("") - var packageName by mutableStateOf("") - var isSharedUid by mutableStateOf(false) - - var selectedItemPosition by mutableIntStateOf(0) - var grantEnabled by mutableStateOf(false) - var denyCountdown by mutableIntStateOf(0) - - var showUi by mutableStateOf(false) - var useTapjackProtection by mutableStateOf(false) + val useTapjackProtection get() = _uiState.value.useTapjackProtection private val handler = SuRequestHandler(AppContext.packageManager, policyDB) private val millis = SECONDS.toMillis(Config.suDefaultTimeout.toLong()) private var timer = SuTimer(millis, 1000) private var initialized = false + fun setSelectedItemPosition(position: Int) { + _uiState.update { it.copy(selectedItemPosition = position) } + } + fun grantPressed() { cancelTimer() if (Config.suAuth) { @@ -78,7 +87,11 @@ class SuRequestViewModel( val info = handler.pkgInfo val app = info.applicationInfo - isSharedUid = info.sharedUserId != null + val isSharedUid = info.sharedUserId != null + val icon: Drawable? + val title: String + val packageName: String + if (app == null) { icon = pm.defaultActivityIcon title = info.sharedUserId.toString() @@ -89,10 +102,19 @@ class SuRequestViewModel( packageName = info.packageName } - selectedItemPosition = timeoutPrefs.getInt(packageName, 0) + val selectedPos = timeoutPrefs.getInt(packageName, 0) + _uiState.update { + it.copy( + showUi = true, + icon = icon, + title = title, + packageName = packageName, + isSharedUid = isSharedUid, + selectedItemPosition = selectedPos, + useTapjackProtection = Config.suTapjack, + ) + } timer.start() - useTapjackProtection = Config.suTapjack - showUi = true initialized = true } @@ -100,8 +122,9 @@ class SuRequestViewModel( if (!initialized) return timer.cancel() - val pos = selectedItemPosition - timeoutPrefs.edit { putInt(packageName, pos) } + val pos = _uiState.value.selectedItemPosition + val pkg = _uiState.value.packageName + timeoutPrefs.edit { putInt(pkg, pos) } viewModelScope.launch { handler.respond(action, Config.Value.TIMEOUT_LIST[pos]) @@ -111,7 +134,7 @@ class SuRequestViewModel( private fun cancelTimer() { timer.cancel() - denyCountdown = 0 + _uiState.update { it.copy(denyCountdown = 0) } } private inner class SuTimer( @@ -120,16 +143,17 @@ class SuRequestViewModel( ) : CountDownTimer(millis, interval) { override fun onTick(remains: Long) { - if (!grantEnabled && remains <= millis - 1000) { - grantEnabled = true + _uiState.update { + it.copy( + grantEnabled = it.grantEnabled || remains <= millis - 1000, + denyCountdown = (remains / 1000).toInt() + 1 + ) } - denyCountdown = (remains / 1000).toInt() + 1 } override fun onFinish() { - denyCountdown = 0 + _uiState.update { it.copy(denyCountdown = 0) } respond(DENY) } } - } From df8fdc35a4f1cb6edd2c8879ba2c21a6089d1a6b Mon Sep 17 00:00:00 2001 From: topjohnwu Date: Wed, 19 Aug 2026 17:24:39 -0700 Subject: [PATCH 4/4] app: modernize architecture and state in apk-ng Refactor apk-ng to adhere to modern Android and Jetpack Compose best practices: - Migrate VMFactory to the type-safe AndroidX viewModelFactory DSL. - Use collectAsStateWithLifecycle across all screen composables to pause flow collection when backgrounded. - Convert PolicyItem, ModuleItem, DenyAppState, and DenyProcessState to pure immutable data classes, removing Compose MutableState from ViewModels. - Extract shell operations from UI model classes into ViewModels. - Bridge Info.isConnected LiveData to coroutine Flow using callbackFlow and collect in viewModelScope without modifying :core. - Consolidate magisk and manager version state into HomeViewModel.UiState. - Add contentType to LazyColumn items for optimal list recycling. - Restore proper localized string resolution in MainActivityDialogs. Assisted-by: Gemini 3.7 Flash --- .../topjohnwu/magisk/arch/ViewModelFactory.kt | 33 ++-- .../com/topjohnwu/magisk/ui/MainActivity.kt | 27 ++-- .../magisk/ui/deny/DenyListScreen.kt | 43 ++++-- .../magisk/ui/deny/DenyListViewModel.kt | 142 ++++++++++++------ .../topjohnwu/magisk/ui/flash/FlashScreen.kt | 6 +- .../topjohnwu/magisk/ui/home/HomeScreen.kt | 10 +- .../topjohnwu/magisk/ui/home/HomeViewModel.kt | 54 ++++--- .../magisk/ui/install/InstallBottomSheet.kt | 4 +- .../magisk/ui/install/InstallViewModel.kt | 5 +- .../com/topjohnwu/magisk/ui/log/LogScreen.kt | 16 +- .../magisk/ui/module/ActionScreen.kt | 4 +- .../magisk/ui/module/ModuleScreen.kt | 10 +- .../magisk/ui/module/ModuleViewModel.kt | 80 ++++++---- .../magisk/ui/settings/SettingsScreen.kt | 4 +- .../ui/superuser/SuperuserDetailScreen.kt | 4 +- .../magisk/ui/superuser/SuperuserScreen.kt | 10 +- .../magisk/ui/superuser/SuperuserViewModel.kt | 65 ++++---- .../magisk/ui/surequest/SuRequestScreen.kt | 4 +- .../com/topjohnwu/magisk/utils/FlowUtils.kt | 19 +++ 19 files changed, 336 insertions(+), 204 deletions(-) create mode 100644 app/apk-ng/src/main/java/com/topjohnwu/magisk/utils/FlowUtils.kt diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/arch/ViewModelFactory.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/arch/ViewModelFactory.kt index 5da9deddf2ae2..8302443bc5baf 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/arch/ViewModelFactory.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/arch/ViewModelFactory.kt @@ -1,26 +1,29 @@ package com.topjohnwu.magisk.arch -import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import com.topjohnwu.magisk.core.di.ServiceLocator +import com.topjohnwu.magisk.ui.deny.DenyListViewModel +import com.topjohnwu.magisk.ui.flash.FlashViewModel import com.topjohnwu.magisk.ui.home.HomeViewModel import com.topjohnwu.magisk.ui.install.InstallViewModel import com.topjohnwu.magisk.ui.log.LogViewModel +import com.topjohnwu.magisk.ui.module.ActionViewModel +import com.topjohnwu.magisk.ui.module.ModuleViewModel +import com.topjohnwu.magisk.ui.settings.SettingsViewModel import com.topjohnwu.magisk.ui.superuser.SuperuserViewModel import com.topjohnwu.magisk.ui.surequest.SuRequestViewModel -object VMFactory : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return when (modelClass) { - HomeViewModel::class.java -> HomeViewModel(ServiceLocator.networkService) - LogViewModel::class.java -> LogViewModel(ServiceLocator.logRepo) - SuperuserViewModel::class.java -> SuperuserViewModel(ServiceLocator.policyDB) - InstallViewModel::class.java -> - InstallViewModel(ServiceLocator.networkService) - SuRequestViewModel::class.java -> - SuRequestViewModel(ServiceLocator.policyDB, ServiceLocator.timeoutPrefs) - else -> modelClass.newInstance() - } as T - } +val VMFactory: ViewModelProvider.Factory = viewModelFactory { + initializer { HomeViewModel(ServiceLocator.networkService) } + initializer { LogViewModel(ServiceLocator.logRepo) } + initializer { SuperuserViewModel(ServiceLocator.policyDB) } + initializer { InstallViewModel(ServiceLocator.networkService) } + initializer { SuRequestViewModel(ServiceLocator.policyDB, ServiceLocator.timeoutPrefs) } + initializer { DenyListViewModel() } + initializer { FlashViewModel() } + initializer { ActionViewModel() } + initializer { ModuleViewModel() } + initializer { SettingsViewModel() } } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt index 9e774a4fd0b7c..578e2e25609f3 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/MainActivity.kt @@ -16,14 +16,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -185,9 +186,9 @@ class MainActivity : ComponentActivity(), SplashScreenHost { ) } MainActivityDialogs( - showInvalid = showInvalidState.collectAsState().value, - unsupportedMessages = showUnsupported.collectAsState().value, - showShortcut = showShortcutPrompt.collectAsState().value, + showInvalid = showInvalidState.collectAsStateWithLifecycle().value, + unsupportedMessages = showUnsupported.collectAsStateWithLifecycle().value, + showShortcut = showShortcutPrompt.collectAsStateWithLifecycle().value, onInvalidConfirmed = { showInvalidState.value = false handleInvalidStateInstall() @@ -207,7 +208,7 @@ class MainActivity : ComponentActivity(), SplashScreenHost { @Composable private fun HandleFlashIntent(navigator: Navigator) { - val intentVersion by intentState.collectAsState() + val intentVersion by intentState.collectAsStateWithLifecycle() LaunchedEffect(intentVersion) { val currentIntent = intent ?: return@LaunchedEffect if (currentIntent.action == FlashUtils.INTENT_FLASH) { @@ -303,6 +304,7 @@ private fun MainActivityDialogs( onShortcutDismissed: () -> Unit, modifier: Modifier = Modifier ) { + val resources = LocalResources.current val invalidDialog = rememberConfirmDialog( onConfirm = onInvalidConfirmed, onDismiss = {} @@ -311,9 +313,9 @@ private fun MainActivityDialogs( LaunchedEffect(showInvalid) { if (showInvalid) { invalidDialog.showConfirm( - title = CoreR.string.unsupport_nonroot_stub_title.let { "" }, - content = CoreR.string.unsupport_nonroot_stub_msg.let { "" }, - confirm = CoreR.string.install.let { "" }, + title = resources.getString(CoreR.string.unsupport_nonroot_stub_title), + content = resources.getString(CoreR.string.unsupport_nonroot_stub_msg), + confirm = resources.getString(CoreR.string.install), ) } } @@ -327,9 +329,10 @@ private fun MainActivityDialogs( val currentUnsupported = unsupportedMessages.getOrNull(currentUnsupportedIndex) LaunchedEffect(currentUnsupported) { if (currentUnsupported != null) { + val (titleRes, msgRes) = currentUnsupported unsupportedDialog.showConfirm( - title = "", - content = "", + title = resources.getString(titleRes), + content = resources.getString(msgRes), ) } } @@ -342,8 +345,8 @@ private fun MainActivityDialogs( LaunchedEffect(showShortcut) { if (showShortcut) { shortcutDialog.showConfirm( - title = "", - content = "", + title = resources.getString(CoreR.string.add_shortcut_title), + content = resources.getString(CoreR.string.add_shortcut_msg), ) } } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListScreen.kt index 75368c80944d4..a70a21e341286 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TriStateCheckbox import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -60,6 +59,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.topjohnwu.magisk.ui.component.verticalScrollbar import com.topjohnwu.magisk.core.R as CoreR @@ -71,13 +71,13 @@ fun DenyListScreen( onBack: () -> Unit, modifier: Modifier = Modifier ) { - val loading by viewModel.loading.collectAsState() - val apps by viewModel.filteredApps.collectAsState() - val query by viewModel.query.collectAsState() - val showSystem by viewModel.showSystem.collectAsState() - val showOS by viewModel.showOS.collectAsState() - val sortBy by viewModel.sortBy.collectAsState() - val sortReverse by viewModel.sortReverse.collectAsState() + val loading by viewModel.loading.collectAsStateWithLifecycle() + val apps by viewModel.filteredApps.collectAsStateWithLifecycle() + val query by viewModel.query.collectAsStateWithLifecycle() + val showSystem by viewModel.showSystem.collectAsStateWithLifecycle() + val showOS by viewModel.showOS.collectAsStateWithLifecycle() + val sortBy by viewModel.sortBy.collectAsStateWithLifecycle() + val sortReverse by viewModel.sortReverse.collectAsStateWithLifecycle() var showSortMenu by remember { mutableStateOf(false) } var showFilterMenu by remember { mutableStateOf(false) } @@ -225,9 +225,15 @@ fun DenyListScreen( ) { items( items = apps, - key = { it.info.packageName } + key = { it.info.packageName }, + contentType = { "DenyAppCard" } ) { app -> - DenyAppCard(app = app) + DenyAppCard( + app = app, + onToggleExpand = { viewModel.toggleExpanded(app) }, + onToggleAll = { viewModel.toggleAll(app) }, + onToggleProcess = { proc -> viewModel.toggleProcess(app, proc) } + ) } } } @@ -278,6 +284,9 @@ private fun SearchInput( @Composable private fun DenyAppCard( app: DenyAppState, + onToggleExpand: () -> Unit, + onToggleAll: () -> Unit, + onToggleProcess: (DenyProcessState) -> Unit, modifier: Modifier = Modifier ) { Card( @@ -305,7 +314,7 @@ private fun DenyAppCard( Row( modifier = Modifier .fillMaxWidth() - .clickable { app.isExpanded = !app.isExpanded } + .clickable(onClick = onToggleExpand) .padding(14.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -334,7 +343,7 @@ private fun DenyAppCard( app.checkedPercent < 1f -> ToggleableState.Indeterminate else -> ToggleableState.On }, - onClick = { app.toggleAll() } + onClick = onToggleAll ) } @@ -345,7 +354,10 @@ private fun DenyAppCard( .padding(start = 52.dp, bottom = 8.dp) ) { app.processes.forEach { proc -> - ProcessRow(proc = proc) + ProcessRow( + proc = proc, + onToggle = { onToggleProcess(proc) } + ) } } } @@ -356,13 +368,14 @@ private fun DenyAppCard( @Composable private fun ProcessRow( proc: DenyProcessState, + onToggle: () -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .clickable { proc.toggle() } + .clickable(onClick = onToggle) .padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -376,7 +389,7 @@ private fun ProcessRow( Spacer(Modifier.width(8.dp)) Checkbox( checked = proc.isEnabled, - onCheckedChange = { proc.toggle() } + onCheckedChange = { onToggle() } ) } } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListViewModel.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListViewModel.kt index 692fe2488ab03..71eef8874b900 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListViewModel.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/deny/DenyListViewModel.kt @@ -2,9 +2,6 @@ package com.topjohnwu.magisk.ui.deny import android.annotation.SuppressLint import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.viewModelScope import com.topjohnwu.magisk.arch.AsyncLoadViewModel import com.topjohnwu.magisk.core.AppContext @@ -20,10 +17,39 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext enum class SortBy { NAME, PACKAGE_NAME, INSTALL_TIME, UPDATE_TIME } +data class DenyProcessState( + val process: ProcessInfo, + val isEnabled: Boolean = process.isEnabled, +) { + val displayName: String = + if (process.isIsolated) "(isolated) ${process.name}*" else process.name +} + +data class DenyAppState( + val info: AppProcessInfo, + val processes: List = info.processes.map { DenyProcessState(it) }, + val isExpanded: Boolean = false, +) : Comparable { + + val itemsChecked: Int get() = processes.count { it.isEnabled } + val isChecked: Boolean get() = itemsChecked > 0 + val checkedPercent: Float get() = if (processes.isEmpty()) 0f else itemsChecked.toFloat() / processes.size + + override fun compareTo(other: DenyAppState) = comparator.compare(this, other) + + companion object { + private val comparator = compareBy( + { it.itemsChecked == 0 }, + { it.info } + ) + } +} + class DenyListViewModel : AsyncLoadViewModel() { private val _loading = MutableStateFlow(true) @@ -88,6 +114,69 @@ class DenyListViewModel : AsyncLoadViewModel() { fun setSortBy(s: SortBy) { _sortBy.value = s } fun toggleSortReverse() { _sortReverse.value = !_sortReverse.value } + fun toggleExpanded(app: DenyAppState) { + _allApps.update { apps -> + apps.map { + if (it.info.packageName == app.info.packageName) it.copy(isExpanded = !it.isExpanded) else it + } + } + } + + fun toggleAll(app: DenyAppState) { + val willCheck = !app.isChecked + if (!willCheck) { + Shell.cmd("magisk --denylist rm ${app.info.packageName}").submit() + } + _allApps.update { apps -> + apps.map { currentApp -> + if (currentApp.info.packageName == app.info.packageName) { + val newProcs = currentApp.processes.map { proc -> + if (willCheck) { + if (!proc.isEnabled) { + val (name, pkg) = proc.process + Shell.cmd("magisk --denylist add $pkg '$name'").submit() + } + proc.copy(isEnabled = true) + } else { + if (proc.process.isIsolated && proc.isEnabled) { + val (name, pkg) = proc.process + Shell.cmd("magisk --denylist rm $pkg '$name'").submit() + } + proc.copy(isEnabled = false) + } + } + currentApp.copy(processes = newProcs) + } else { + currentApp + } + } + } + } + + fun toggleProcess(app: DenyAppState, proc: DenyProcessState) { + val newEnabled = !proc.isEnabled + val arg = if (newEnabled) "add" else "rm" + val (name, pkg) = proc.process + Shell.cmd("magisk --denylist $arg $pkg '$name'").submit() + + _allApps.update { apps -> + apps.map { currentApp -> + if (currentApp.info.packageName == app.info.packageName) { + val newProcs = currentApp.processes.map { currentProc -> + if (currentProc.process.name == proc.process.name && currentProc.process.packageName == proc.process.packageName) { + currentProc.copy(isEnabled = newEnabled) + } else { + currentProc + } + } + currentApp.copy(processes = newProcs) + } else { + currentApp + } + } + } + } + @SuppressLint("InlinedApi") override suspend fun doLoadWork() { _loading.value = true @@ -113,50 +202,3 @@ class DenyListViewModel : AsyncLoadViewModel() { _loading.value = false } } - -class DenyAppState(val info: AppProcessInfo) : Comparable { - val processes = info.processes.map { DenyProcessState(it) } - var isExpanded by mutableStateOf(false) - - val itemsChecked: Int get() = processes.count { it.isEnabled } - val isChecked: Boolean get() = itemsChecked > 0 - val checkedPercent: Float get() = if (processes.isEmpty()) 0f else itemsChecked.toFloat() / processes.size - - fun toggleAll() { - if (isChecked) { - Shell.cmd("magisk --denylist rm ${info.packageName}").submit() - processes.filter { it.isEnabled }.forEach { proc -> - if (proc.process.isIsolated) { - proc.toggle() - } else { - proc.isEnabled = false - } - } - } else { - processes.filterNot { it.isEnabled }.forEach { it.toggle() } - } - } - - override fun compareTo(other: DenyAppState) = comparator.compare(this, other) - - companion object { - private val comparator = compareBy( - { it.itemsChecked == 0 }, - { it.info } - ) - } -} - -class DenyProcessState(val process: ProcessInfo) { - var isEnabled by mutableStateOf(process.isEnabled) - - val displayName: String = - if (process.isIsolated) "(isolated) ${process.name}*" else process.name - - fun toggle() { - isEnabled = !isEnabled - val arg = if (isEnabled) "add" else "rm" - val (name, pkg) = process - Shell.cmd("magisk --denylist $arg $pkg \'$name\'").submit() - } -} diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/flash/FlashScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/flash/FlashScreen.kt index ad4feb690dc56..fea921f03c20d 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/flash/FlashScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/flash/FlashScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -29,6 +28,7 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle import com.topjohnwu.magisk.R import com.topjohnwu.magisk.core.Const import com.topjohnwu.magisk.ui.component.rememberExternalStoragePermissionLauncher @@ -44,8 +44,8 @@ fun FlashScreen( onBack: () -> Unit, modifier: Modifier = Modifier ) { - val flashState by viewModel.flashState.collectAsState() - val showReboot by viewModel.showReboot.collectAsState() + val flashState by viewModel.flashState.collectAsStateWithLifecycle() + val showReboot by viewModel.showReboot.collectAsStateWithLifecycle() val finished = flashState != FlashViewModel.State.FLASHING val useTerminal = action == Const.Value.FLASH_ZIP val saveLog = rememberExternalStoragePermissionLauncher { diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeScreen.kt index ff355b00b6e1e..81771b784a6d8 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeScreen.kt @@ -53,7 +53,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -69,6 +68,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.getSystemService +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.topjohnwu.magisk.R import com.topjohnwu.magisk.core.BuildConfig import com.topjohnwu.magisk.core.Config @@ -98,7 +98,7 @@ fun HomeScreen( installVm: InstallViewModel, modifier: Modifier = Modifier ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scope = rememberCoroutineScope() @@ -268,8 +268,8 @@ fun HomeScreen( CoreCard( modifier = Modifier.fillMaxWidth(), - state = viewModel.magiskState, - version = viewModel.magiskInstalledVersion, + state = uiState.magiskState, + version = uiState.magiskInstalledVersion, onInstallClicked = { showInstallSheet = true } ) @@ -278,7 +278,7 @@ fun HomeScreen( AppCard( modifier = Modifier.fillMaxWidth(), state = uiState.appState, - version = viewModel.managerInstalledVersion, + version = uiState.managerInstalledVersion, remoteVersion = uiState.managerRemoteVersion, progress = uiState.managerProgress, isHidden = context.packageName != BuildConfig.APP_PACKAGE_NAME, diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeViewModel.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeViewModel.kt index 2dc57b261c48c..37689fb7a0abf 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeViewModel.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/home/HomeViewModel.kt @@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.widget.Toast import androidx.core.net.toUri +import androidx.lifecycle.viewModelScope import com.topjohnwu.magisk.arch.AsyncLoadViewModel import com.topjohnwu.magisk.core.AppContext import com.topjohnwu.magisk.core.BuildConfig @@ -12,11 +13,13 @@ import com.topjohnwu.magisk.core.Info import com.topjohnwu.magisk.core.ktx.await import com.topjohnwu.magisk.core.ktx.toast import com.topjohnwu.magisk.core.repository.NetworkService +import com.topjohnwu.magisk.utils.asFlow import com.topjohnwu.superuser.Shell 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 com.topjohnwu.magisk.core.R as CoreR class HomeViewModel( @@ -36,37 +39,57 @@ class HomeViewModel( val showManagerInstall: Boolean = false, val showHideRestore: Boolean = false, val envFixCode: Int = 0, + val magiskState: State = computeMagiskState(), + val magiskInstalledVersion: String = computeMagiskInstalledVersion(), + val managerInstalledVersion: String = computeManagerInstalledVersion(), ) private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() - val magiskState - get() = when { + val magiskState get() = _uiState.value.magiskState + val magiskInstalledVersion get() = _uiState.value.magiskInstalledVersion + val managerInstalledVersion get() = _uiState.value.managerInstalledVersion + + companion object { + private var checkedEnv = false + + fun computeMagiskState() = when { Info.isRooted && Info.env.isUnsupported -> State.OUTDATED !Info.env.isActive -> State.INVALID Info.env.versionCode < BuildConfig.APP_VERSION_CODE -> State.OUTDATED else -> State.UP_TO_DATE } - val magiskInstalledVersion: String - get() = Info.env.run { + fun computeMagiskInstalledVersion() = Info.env.run { if (isActive) "$versionString ($versionCode)" + if (isDebug) " (D)" else "" else "" } - val managerInstalledVersion: String - get() = "${BuildConfig.APP_VERSION_NAME} (${BuildConfig.APP_VERSION_CODE})" + - if (BuildConfig.DEBUG) " (D)" else "" + fun computeManagerInstalledVersion() = + "${BuildConfig.APP_VERSION_NAME} (${BuildConfig.APP_VERSION_CODE})" + + if (BuildConfig.DEBUG) " (D)" else "" + } - companion object { - private var checkedEnv = false + init { + viewModelScope.launch { + Info.isConnected.asFlow().collect { + startLoading() + } + } } override suspend fun doLoadWork() { - _uiState.update { it.copy(appState = State.LOADING) } + _uiState.update { + it.copy( + appState = State.LOADING, + magiskState = computeMagiskState(), + magiskInstalledVersion = computeMagiskInstalledVersion(), + managerInstalledVersion = computeManagerInstalledVersion(), + ) + } Info.fetchUpdate(svc)?.apply { val isDebug = Config.updateChannel == Config.Value.DEBUG_CHANNEL _uiState.update { @@ -81,17 +104,6 @@ class HomeViewModel( ensureEnv() } - private val networkObserver: (Boolean) -> Unit = { startLoading() } - - init { - Info.isConnected.observeForever(networkObserver) - } - - override fun onCleared() { - super.onCleared() - Info.isConnected.removeObserver(networkObserver) - } - fun onLinkPressed(link: String) { val intent = Intent(Intent.ACTION_VIEW, link.toUri()) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallBottomSheet.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallBottomSheet.kt index d23e6c06536b9..1418887d2d03a 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallBottomSheet.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallBottomSheet.kt @@ -25,7 +25,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -37,6 +36,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.topjohnwu.magisk.core.Config import com.topjohnwu.magisk.core.Info import com.topjohnwu.magisk.ui.component.ConfirmResult @@ -53,7 +53,7 @@ fun InstallBottomSheet( installVm: InstallViewModel, modifier: Modifier = Modifier ) { - val installUiState by installVm.uiState.collectAsState() + val installUiState by installVm.uiState.collectAsStateWithLifecycle() var showDownloadDialog by rememberSaveable { mutableStateOf(false) } val filePicker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { installVm.onPatchFileSelected(it) } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt index 0605c90ef724e..4c5189dbb5cfc 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/install/InstallViewModel.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import java.io.IOException @@ -57,9 +56,7 @@ class InstallViewModel(svc: NetworkService) : BaseViewModel() { note } } - withContext(Dispatchers.Main) { - _uiState.update { it.copy(notes = noteText) } - } + _uiState.update { it.copy(notes = noteText) } } catch (e: IOException) { Timber.e(e) } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt index 492673d699784..e714293f883e5 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/log/LogScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -56,6 +55,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.topjohnwu.magisk.core.ktx.timeDateFormat import com.topjohnwu.magisk.core.ktx.toTime @@ -70,7 +70,7 @@ fun LogScreen( viewModel: LogViewModel, modifier: Modifier = Modifier ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() var selectedTab by rememberSaveable { mutableIntStateOf(0) } val tabTitles = listOf( stringResource(CoreR.string.superuser), @@ -189,7 +189,11 @@ private fun SuLogTab( contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(logs, key = { it.id }) { log -> + items( + items = logs, + key = { it.id }, + contentType = { "SuLogCard" } + ) { log -> SuLogCard(log = log) } } @@ -349,7 +353,11 @@ private fun MagiskLogTab( contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp), verticalArrangement = Arrangement.spacedBy(6.dp) ) { - items(entries, key = { "${it.timestamp}_${it.pid}_${it.tid}_${it.message.hashCode()}" }) { entry -> + items( + items = entries, + key = { "${it.timestamp}_${it.pid}_${it.tid}_${it.message.hashCode()}" }, + contentType = { "MagiskLogCard" } + ) { entry -> MagiskLogCard(entry = entry) } } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt index f2196fd9bf9ef..cd0b436f1f50f 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ActionScreen.kt @@ -12,12 +12,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.topjohnwu.magisk.R import com.topjohnwu.magisk.ui.component.rememberExternalStoragePermissionLauncher import com.topjohnwu.magisk.ui.terminal.TerminalScreen @@ -31,7 +31,7 @@ fun ActionScreen( onBack: () -> Unit, modifier: Modifier = Modifier ) { - val actionState by viewModel.actionState.collectAsState() + val actionState by viewModel.actionState.collectAsStateWithLifecycle() val finished = actionState != ActionViewModel.State.RUNNING val saveLog = rememberExternalStoragePermissionLauncher { viewModel.saveLog() diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleScreen.kt index f5ce03c33f7bf..567b7c0ba4918 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleScreen.kt @@ -49,7 +49,6 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,6 +68,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.topjohnwu.magisk.R import com.topjohnwu.magisk.core.Info import com.topjohnwu.magisk.core.di.ServiceLocator @@ -89,7 +89,7 @@ fun ModuleScreen( viewModel: ModuleViewModel, modifier: Modifier = Modifier ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val colorScheme = MaterialTheme.colorScheme val context = LocalContext.current @@ -218,7 +218,11 @@ fun ModuleScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { Spacer(Modifier.height(4.dp)) } - items(uiState.modules, key = { it.module.id }) { item -> + items( + items = uiState.modules, + key = { it.module.id }, + contentType = { "ModuleCard" } + ) { item -> ModuleCard( item = item, viewModel = viewModel, diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleViewModel.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleViewModel.kt index 7a7a7a5c29d26..f0e9a6670a0b7 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleViewModel.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/module/ModuleViewModel.kt @@ -2,13 +2,10 @@ package com.topjohnwu.magisk.ui.module import android.content.Context import android.net.Uri -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue +import androidx.lifecycle.viewModelScope import com.topjohnwu.magisk.arch.AsyncLoadViewModel import com.topjohnwu.magisk.core.Const import com.topjohnwu.magisk.core.Info -import com.topjohnwu.magisk.core.R as CoreR import com.topjohnwu.magisk.core.download.Subject import com.topjohnwu.magisk.core.model.module.LocalModule import com.topjohnwu.magisk.core.model.module.OnlineModule @@ -16,19 +13,29 @@ import com.topjohnwu.magisk.core.utils.TextHolder import com.topjohnwu.magisk.core.utils.asText import com.topjohnwu.magisk.ui.flash.FlashUtils import com.topjohnwu.magisk.ui.navigation.Route +import com.topjohnwu.magisk.utils.asFlow import com.topjohnwu.magisk.view.Notifications 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 +import com.topjohnwu.magisk.core.R as CoreR -class ModuleItem(val module: LocalModule) { +data class ModuleItem( + val module: LocalModule, + val isEnabled: Boolean = module.enable, + val isRemoved: Boolean = module.remove, + val showUpdate: Boolean = module.updateInfo != null, +) { val showNotice: Boolean val showAction: Boolean val noticeText: TextHolder + val isUpdated = module.updated + val updateReady get() = module.outdated && !isRemoved && isEnabled init { val isZygisk = module.isZygisk @@ -46,19 +53,13 @@ class ModuleItem(val module: LocalModule) { else -> CoreR.string.suspend_text_zygisk.asText(CoreR.string.zygisk.asText()) } } - - var isEnabled by mutableStateOf(module.enable) - var isRemoved by mutableStateOf(module.remove) - var showUpdate by mutableStateOf(module.updateInfo != null) - val isUpdated = module.updated - val updateReady get() = module.outdated && !isRemoved && isEnabled } @Parcelize class OnlineModuleSubject( override val module: OnlineModule, override val autoLaunch: Boolean, - override val notifyId: Int = Notifications.nextId() + override val notifyId: Int = Notifications.nextId(), ) : Subject.Module() { override fun pendingIntent(context: Context) = FlashUtils.installIntent(context, file) } @@ -73,6 +74,14 @@ class ModuleViewModel : AsyncLoadViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow = _uiState.asStateFlow() + init { + viewModelScope.launch { + Info.isConnected.asFlow().collect { + startLoading() + } + } + } + override suspend fun doLoadWork() { _uiState.update { it.copy(loading = true) } val moduleLoaded = Info.env.isActive && @@ -88,23 +97,18 @@ class ModuleViewModel : AsyncLoadViewModel() { } } - private val networkObserver: (Boolean) -> Unit = { startLoading() } - - init { - Info.isConnected.observeForever(networkObserver) - } - - override fun onCleared() { - super.onCleared() - Info.isConnected.removeObserver(networkObserver) - } - private suspend fun loadUpdateInfo() { withContext(Dispatchers.IO) { - _uiState.value.modules.forEach { item -> - if (item.module.fetch()) { - item.showUpdate = item.module.updateInfo != null - } + _uiState.update { state -> + state.copy( + modules = state.modules.map { item -> + if (item.module.fetch()) { + item.copy(showUpdate = item.module.updateInfo != null) + } else { + item + } + } + ) } } } @@ -118,12 +122,26 @@ class ModuleViewModel : AsyncLoadViewModel() { } fun toggleEnabled(item: ModuleItem) { - item.isEnabled = !item.isEnabled - item.module.enable = item.isEnabled + val newEnabled = !item.isEnabled + item.module.enable = newEnabled + _uiState.update { state -> + state.copy( + modules = state.modules.map { + if (it.module.id == item.module.id) it.copy(isEnabled = newEnabled) else it + } + ) + } } fun toggleRemove(item: ModuleItem) { - item.isRemoved = !item.isRemoved - item.module.remove = item.isRemoved + val newRemoved = !item.isRemoved + item.module.remove = newRemoved + _uiState.update { state -> + state.copy( + modules = state.modules.map { + if (it.module.id == item.module.id) it.copy(isRemoved = newRemoved) else it + } + ) + } } } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt index c6a2de922cb0a..58184d340d657 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/settings/SettingsScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -38,6 +37,7 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.pm.ShortcutManagerCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.topjohnwu.magisk.core.Config import com.topjohnwu.magisk.core.Const import com.topjohnwu.magisk.core.Info @@ -305,7 +305,7 @@ private fun MagiskSection( ) // DenyList - val denyListEnabled by viewModel.denyListEnabled.collectAsState() + val denyListEnabled by viewModel.denyListEnabled.collectAsStateWithLifecycle() SettingsSwitch( title = stringResource(CoreR.string.settings_denylist_title), summary = stringResource(CoreR.string.settings_denylist_summary), diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserDetailScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserDetailScreen.kt index fe33fccde2ed2..0bac01c9e35e5 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserDetailScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserDetailScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -42,6 +41,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.topjohnwu.magisk.ui.component.ConfirmResult import com.topjohnwu.magisk.ui.component.SettingsSwitch @@ -59,7 +59,7 @@ fun SuperuserDetailScreen( modifier: Modifier = Modifier, onAuthenticate: ((onSuccess: () -> Unit) -> Unit)? = null, ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val items = uiState.policies.filter { it.policy.uid == uid } val item = items.firstOrNull() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserScreen.kt index 183cd624531f7..a93da6b79c834 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults 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 @@ -45,6 +44,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.topjohnwu.magisk.ui.component.verticalScrollbar import com.topjohnwu.magisk.ui.navigation.LocalNavigator @@ -57,7 +57,7 @@ fun SuperuserScreen( viewModel: SuperuserViewModel, modifier: Modifier = Modifier ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val navigator = LocalNavigator.current @@ -123,7 +123,11 @@ fun SuperuserScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { item { Spacer(Modifier.height(4.dp)) } - items(uiState.policies, key = { "${it.policy.uid}_${it.packageName}" }) { item -> + items( + items = uiState.policies, + key = { "${it.policy.uid}_${it.packageName}" }, + contentType = { "PolicyCard" } + ) { item -> PolicyCard( item = item, onToggle = { viewModel.togglePolicy(item) }, diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserViewModel.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserViewModel.kt index dac90dc4c7f45..e4534df124e0f 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserViewModel.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/superuser/SuperuserViewModel.kt @@ -5,10 +5,6 @@ import android.content.pm.PackageManager import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES import android.graphics.drawable.Drawable import android.os.Process -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.viewModelScope import com.topjohnwu.magisk.arch.AsyncLoadViewModel import com.topjohnwu.magisk.core.AppContext @@ -20,6 +16,7 @@ import com.topjohnwu.magisk.core.ktx.getLabel import com.topjohnwu.magisk.core.model.su.SuPolicy import com.topjohnwu.magisk.core.su.SuEvents import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -29,19 +26,17 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Locale -class PolicyItem( +data class PolicyItem( val policy: SuPolicy, val packageName: String, val isSharedUid: Boolean, val icon: Drawable, val appName: String, + val policyValue: Int = policy.policy, + val notification: Boolean = policy.notification, + val logging: Boolean = policy.logging, ) { val title get() = appName - - var policyValue by mutableIntStateOf(policy.policy) - var notification by mutableStateOf(policy.notification) - var logging by mutableStateOf(policy.logging) - val isEnabled get() = policyValue >= SuPolicy.ALLOW val isRestricted get() = policyValue == SuPolicy.RESTRICT } @@ -53,7 +48,7 @@ class SuperuserViewModel( var authenticate: (onSuccess: () -> Unit) -> Unit = { it() } init { - @OptIn(kotlinx.coroutines.FlowPreview::class) + @OptIn(FlowPreview::class) viewModelScope.launch { SuEvents.policyChanged.debounce(500).collect { reload() } } @@ -96,7 +91,10 @@ class SuperuserViewModel( packageName = info.packageName, isSharedUid = info.sharedUserId != null, icon = info.applicationInfo?.loadIcon(pm) ?: pm.defaultActivityIcon, - appName = info.applicationInfo?.getLabel(pm) ?: info.packageName + appName = info.applicationInfo?.getLabel(pm) ?: info.packageName, + policyValue = policy.policy, + notification = policy.notification, + logging = policy.logging, ) } catch (_: PackageManager.NameNotFoundException) { null @@ -133,27 +131,35 @@ class SuperuserViewModel( } fun updateNotify(item: PolicyItem) { - item.notification = !item.notification - item.policy.notification = item.notification + val newNotification = !item.notification + item.policy.notification = newNotification viewModelScope.launch { db.update(item.policy) - _uiState.value.policies - .filter { it.policy.uid == item.policy.uid } - .forEach { it.notification = item.notification } - val res = if (item.notification) R.string.su_snack_notif_on else R.string.su_snack_notif_off + _uiState.update { state -> + state.copy( + policies = state.policies.map { + if (it.policy.uid == item.policy.uid) it.copy(notification = newNotification) else it + } + ) + } + val res = if (newNotification) R.string.su_snack_notif_on else R.string.su_snack_notif_off showSnackbar(AppContext.getString(res, item.appName)) } } fun updateLogging(item: PolicyItem) { - item.logging = !item.logging - item.policy.logging = item.logging + val newLogging = !item.logging + item.policy.logging = newLogging viewModelScope.launch { db.update(item.policy) - _uiState.value.policies - .filter { it.policy.uid == item.policy.uid } - .forEach { it.logging = item.logging } - val res = if (item.logging) R.string.su_snack_log_on else R.string.su_snack_log_off + _uiState.update { state -> + state.copy( + policies = state.policies.map { + if (it.policy.uid == item.policy.uid) it.copy(logging = newLogging) else it + } + ) + } + val res = if (newLogging) R.string.su_snack_log_on else R.string.su_snack_log_off showSnackbar(AppContext.getString(res, item.appName)) } } @@ -162,11 +168,14 @@ class SuperuserViewModel( fun updateState() { viewModelScope.launch { item.policy.policy = newPolicy - item.policyValue = newPolicy db.update(item.policy) - _uiState.value.policies - .filter { it.policy.uid == item.policy.uid } - .forEach { it.policyValue = newPolicy } + _uiState.update { state -> + state.copy( + policies = state.policies.map { + if (it.policy.uid == item.policy.uid) it.copy(policyValue = newPolicy) else it + } + ) + } val res = if (newPolicy >= SuPolicy.ALLOW) R.string.su_snack_grant else R.string.su_snack_deny showSnackbar(AppContext.getString(res, item.appName)) } diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestScreen.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestScreen.kt index 0ecb3aec31cf3..0c673ebd2e151 100644 --- a/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestScreen.kt +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/ui/surequest/SuRequestScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -42,6 +41,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.drawablepainter.rememberDrawablePainter import com.topjohnwu.magisk.core.ktx.toast import com.topjohnwu.magisk.ui.superuser.SharedUidBadge @@ -53,7 +53,7 @@ fun SuRequestScreen( viewModel: SuRequestViewModel, modifier: Modifier = Modifier ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() if (!uiState.showUi) return val context = LocalContext.current diff --git a/app/apk-ng/src/main/java/com/topjohnwu/magisk/utils/FlowUtils.kt b/app/apk-ng/src/main/java/com/topjohnwu/magisk/utils/FlowUtils.kt new file mode 100644 index 0000000000000..9f44fe6f9766a --- /dev/null +++ b/app/apk-ng/src/main/java/com/topjohnwu/magisk/utils/FlowUtils.kt @@ -0,0 +1,19 @@ +package com.topjohnwu.magisk.utils + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +fun LiveData.asFlow(): Flow = callbackFlow { + val observer = Observer { value -> + if (value != null) { + trySend(value) + } + } + observeForever(observer) + awaitClose { + removeObserver(observer) + } +}