From 21c43250f1469344eec5b9e02013573f8fe1a37b Mon Sep 17 00:00:00 2001 From: Nullptr <52071314+Dr-TSNG@users.noreply.github.com> Date: Wed, 6 Jul 2022 09:59:47 +0800 Subject: [PATCH] Module page --- manager/build.gradle.kts | 1 + .../org/lsposed/lspatch/LSPApplication.kt | 2 - .../lsposed/lspatch/manager/ModuleProvider.kt | 5 +- .../lspatch/ui/activity/MainActivity.kt | 1 - .../lsposed/lspatch/ui/component/AppItem.kt | 4 +- .../lsposed/lspatch/ui/component/SearchBar.kt | 4 +- .../org/lsposed/lspatch/ui/page/ManagePage.kt | 403 +++--------------- .../lspatch/ui/page/manage/AppManagePage.kt | 349 +++++++++++++++ .../ui/page/manage/ModuleManagePage.kt | 75 ++++ .../lspatch/ui/viewmodel/NewPatchViewModel.kt | 6 +- ...AppViewModel.kt => SelectAppsViewModel.kt} | 6 +- .../AppManageViewModel.kt} | 8 +- .../viewmodel/manage/ModuleManageViewModel.kt | 33 ++ manager/src/main/res/values/strings.xml | 3 + 14 files changed, 533 insertions(+), 367 deletions(-) create mode 100644 manager/src/main/java/org/lsposed/lspatch/ui/page/manage/AppManagePage.kt create mode 100644 manager/src/main/java/org/lsposed/lspatch/ui/page/manage/ModuleManagePage.kt rename manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/{SelectAppViewModel.kt => SelectAppsViewModel.kt} (93%) rename manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/{ManageViewModel.kt => manage/AppManageViewModel.kt} (96%) create mode 100644 manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/ModuleManageViewModel.kt diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index 1b71a54..ce583ac 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -92,6 +92,7 @@ dependencies { implementation("androidx.room:room-runtime:$roomVersion") implementation("com.google.accompanist:accompanist-drawablepainter:0.24.11-rc") implementation("com.google.accompanist:accompanist-navigation-animation:0.24.11-rc") + implementation("com.google.accompanist:accompanist-pager:0.24.11-rc") implementation("com.google.accompanist:accompanist-swiperefresh:0.24.11-rc") implementation("com.google.android.material:material:1.6.1") implementation("com.google.code.gson:gson:2.9.0") diff --git a/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt b/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt index 965cc1c..96a65ec 100644 --- a/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt +++ b/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt @@ -11,8 +11,6 @@ import org.lsposed.lspatch.util.LSPPackageManager import org.lsposed.lspatch.util.ShizukuApi import java.io.File -const val TAG = "LSPatch Manager" - lateinit var lspApp: LSPApplication class LSPApplication : Application() { diff --git a/manager/src/main/java/org/lsposed/lspatch/manager/ModuleProvider.kt b/manager/src/main/java/org/lsposed/lspatch/manager/ModuleProvider.kt index 612daba..afb006f 100644 --- a/manager/src/main/java/org/lsposed/lspatch/manager/ModuleProvider.kt +++ b/manager/src/main/java/org/lsposed/lspatch/manager/ModuleProvider.kt @@ -7,11 +7,14 @@ import android.net.Uri import android.os.Binder import android.os.Bundle import android.util.Log -import org.lsposed.lspatch.TAG import org.lsposed.lspatch.lspApp class ModuleProvider : ContentProvider() { + companion object { + private const val TAG = "ModuleProvider" + } + override fun onCreate(): Boolean { return false } diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/activity/MainActivity.kt b/manager/src/main/java/org/lsposed/lspatch/ui/activity/MainActivity.kt index 64bf982..96728aa 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/activity/MainActivity.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/activity/MainActivity.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/AppItem.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/AppItem.kt index ddd804e..39c2099 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/component/AppItem.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/AppItem.kt @@ -30,7 +30,7 @@ fun AppItem( onLongClick: (() -> Unit)? = null, checked: Boolean? = null, rightIcon: (@Composable () -> Unit)? = null, - additionalContent: (@Composable () -> Unit)? = null, + additionalContent: (@Composable ColumnScope.() -> Unit)? = null, ) { if (checked != null && rightIcon != null) throw IllegalArgumentException("`checked` and `rightIcon` should not be both set") @@ -63,7 +63,7 @@ fun AppItem( fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall ) - additionalContent?.invoke() + additionalContent?.invoke(this) } if (checked != null) { Checkbox( diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt index 04cdea7..20a992e 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/SearchBar.kt @@ -25,7 +25,9 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import org.lsposed.lspatch.TAG + + +private const val TAG = "SearchBar" @OptIn(ExperimentalComposeUiApi::class) @Composable diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/ManagePage.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/ManagePage.kt index 4e5a34e..942d5ed 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/ManagePage.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/ManagePage.kt @@ -1,73 +1,68 @@ package org.lsposed.lspatch.ui.page -import android.app.Activity -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.util.Log -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Alignment +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.rememberPagerState import kotlinx.coroutines.launch -import org.lsposed.lspatch.Constants.PREFS_STORAGE_DIRECTORY import org.lsposed.lspatch.R -import org.lsposed.lspatch.config.ConfigManager -import org.lsposed.lspatch.database.entity.Module -import org.lsposed.lspatch.lspApp -import org.lsposed.lspatch.share.LSPConfig -import org.lsposed.lspatch.ui.component.AppItem -import org.lsposed.lspatch.ui.component.LoadingDialog -import org.lsposed.lspatch.ui.util.LocalNavController -import org.lsposed.lspatch.ui.util.LocalSnackbarHost -import org.lsposed.lspatch.ui.util.observeState -import org.lsposed.lspatch.ui.util.setState -import org.lsposed.lspatch.ui.viewmodel.ManageViewModel -import org.lsposed.lspatch.ui.viewmodel.ManageViewModel.ViewAction -import org.lsposed.lspatch.util.LSPPackageManager -import org.lsposed.lspatch.util.LSPPackageManager.AppInfo -import org.lsposed.lspatch.util.ShizukuApi -import java.io.IOException +import org.lsposed.lspatch.ui.page.manage.AppManageBody +import org.lsposed.lspatch.ui.page.manage.AppManageFab +import org.lsposed.lspatch.ui.page.manage.ModuleManageBody -private const val TAG = "ManagePage" - -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class) @Composable fun ManagePage() { - val viewModel = viewModel() - + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState() Scaffold( topBar = { TopBar() }, - floatingActionButton = { Fab() } + floatingActionButton = { if (pagerState.currentPage == 0) AppManageFab() } ) { innerPadding -> Box(Modifier.padding(innerPadding)) { - Body() + Column { + TabRow( + selectedTabIndex = pagerState.currentPage, + indicator = { tabPositions -> + TabRowDefaults.Indicator(Modifier.tabIndicatorOffset(tabPositions[pagerState.currentPage])) + } + ) { + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } } + ) { + Text( + modifier = Modifier.padding(vertical = 12.dp), + text = stringResource(R.string.apps) + ) + } + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } } + ) { + Text( + modifier = Modifier.padding(vertical = 12.dp), + text = stringResource(R.string.modules) + ) + } + } + + HorizontalPager(count = 2, state = pagerState) { page -> + when (page) { + 0 -> AppManageBody() + 1 -> ModuleManageBody() + } + } + } } } } @@ -78,301 +73,3 @@ private fun TopBar() { title = { Text(PageList.Manage.title) } ) } - -@Composable -private fun Fab() { - val context = LocalContext.current - val snackbarHost = LocalSnackbarHost.current - val navController = LocalNavController.current - val scope = rememberCoroutineScope() - var shouldSelectDirectory by remember { mutableStateOf(false) } - var showNewPatchDialog by remember { mutableStateOf(false) } - - val errorText = stringResource(R.string.patch_select_dir_error) - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - try { - if (it.resultCode == Activity.RESULT_CANCELED) return@rememberLauncherForActivityResult - val uri = it.data?.data ?: throw IOException("No data") - val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - context.contentResolver.takePersistableUriPermission(uri, takeFlags) - lspApp.prefs.edit().putString(PREFS_STORAGE_DIRECTORY, uri.toString()).apply() - Log.i(TAG, "Storage directory: ${uri.path}") - showNewPatchDialog = true - } catch (e: Exception) { - Log.e(TAG, "Error when requesting saving directory", e) - scope.launch { snackbarHost.showSnackbar(errorText) } - } - } - - if (shouldSelectDirectory) { - AlertDialog( - onDismissRequest = { shouldSelectDirectory = false }, - confirmButton = { - TextButton( - content = { Text(stringResource(android.R.string.ok)) }, - onClick = { - launcher.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) - shouldSelectDirectory = false - } - ) - }, - dismissButton = { - TextButton( - content = { Text(stringResource(android.R.string.cancel)) }, - onClick = { shouldSelectDirectory = false } - ) - }, - title = { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.patch_select_dir_title), - textAlign = TextAlign.Center - ) - }, - text = { Text(stringResource(R.string.patch_select_dir_text)) } - ) - } - - if (showNewPatchDialog) { - AlertDialog( - onDismissRequest = { showNewPatchDialog = false }, - confirmButton = {}, - dismissButton = { - TextButton( - content = { Text(stringResource(android.R.string.cancel)) }, - onClick = { showNewPatchDialog = false } - ) - }, - title = { - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.page_new_patch), - textAlign = TextAlign.Center - ) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - TextButton( - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.secondary), - onClick = { - navController.navigate(PageList.NewPatch.name + "?from=storage") - showNewPatchDialog = false - } - ) { - Text( - modifier = Modifier.padding(vertical = 8.dp), - text = stringResource(R.string.patch_from_storage), - style = MaterialTheme.typography.bodyLarge - ) - } - TextButton( - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.secondary), - onClick = { - navController.navigate(PageList.NewPatch.name + "?from=applist") - showNewPatchDialog = false - } - ) { - Text( - modifier = Modifier.padding(vertical = 8.dp), - text = stringResource(R.string.patch_from_applist), - style = MaterialTheme.typography.bodyLarge - ) - } - } - } - ) - } - - FloatingActionButton( - content = { Icon(Icons.Filled.Add, stringResource(R.string.add)) }, - onClick = { - val uri = lspApp.prefs.getString(PREFS_STORAGE_DIRECTORY, null)?.toUri() - if (uri == null) { - shouldSelectDirectory = true - } else { - runCatching { - val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION - context.contentResolver.takePersistableUriPermission(uri, takeFlags) - if (DocumentFile.fromTreeUri(context, uri)?.exists() == false) throw IOException("Storage directory was deleted") - }.onSuccess { - showNewPatchDialog = true - }.onFailure { - Log.w(TAG, "Failed to take persistable permission for saved uri", it) - lspApp.prefs.edit().putString(PREFS_STORAGE_DIRECTORY, null).apply() - shouldSelectDirectory = true - } - } - } - ) -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun Body() { - val viewModel = viewModel() - val snackbarHost = LocalSnackbarHost.current - val navController = LocalNavController.current - val scope = rememberCoroutineScope() - - if (viewModel.appList.isEmpty()) { - Box(Modifier.fillMaxSize()) { - Text( - modifier = Modifier.align(Alignment.Center), - text = run { - if (LSPPackageManager.appList.isEmpty()) stringResource(R.string.manage_loading) - else stringResource(R.string.manage_no_apps) - }, - fontFamily = FontFamily.Serif, - style = MaterialTheme.typography.headlineSmall - ) - } - } else { - var scopeApp by rememberSaveable { mutableStateOf("") } - val isCancelled by navController.currentBackStackEntry!!.observeState("isCancelled") - LaunchedEffect(isCancelled) { - if (isCancelled == false) { - val selected = navController.currentBackStackEntry!! - .savedStateHandle.getLiveData>("selected").value!!.toSet() - Log.d(TAG, "Clear module list for $scopeApp") - ConfigManager.getModulesForApp(scopeApp).forEach { - ConfigManager.deactivateModule(scopeApp, it) - } - selected.forEach { - Log.d(TAG, "Activate ${it.app.packageName} for $scopeApp") - ConfigManager.activateModule(scopeApp, Module(it.app.packageName, it.app.sourceDir)) - } - navController.currentBackStackEntry!!.setState("isCancelled", null) - } - } - - if (viewModel.processingUpdate) LoadingDialog() - viewModel.updateLoaderResult?.let { - val updateSuccessfully = stringResource(R.string.manage_update_loader_successfully) - val updateFailed = stringResource(R.string.manage_update_loader_failed) - val copyError = stringResource(R.string.copy_error) - LaunchedEffect(Unit) { - it.onSuccess { - LSPPackageManager.fetchAppList() - snackbarHost.showSnackbar(updateSuccessfully) - }.onFailure { - val result = snackbarHost.showSnackbar(updateFailed, copyError) - if (result == SnackbarResult.ActionPerformed) { - val cm = lspApp.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - cm.setPrimaryClip(ClipData.newPlainText("LSPatch", it.toString())) - } - } - viewModel.dispatch(ViewAction.ClearUpdateLoaderResult) - } - } - - LazyColumn { - items( - items = viewModel.appList, - key = { it.first.app.packageName } - ) { - var expanded by remember { mutableStateOf(false) } - Box { - AppItem( - modifier = Modifier.animateItemPlacement(spring(stiffness = Spring.StiffnessLow)), - icon = LSPPackageManager.getIcon(it.first), - label = it.first.label, - packageName = it.first.app.packageName, - onClick = { expanded = true }, - onLongClick = { expanded = true }, - additionalContent = { - val text = buildAnnotatedString { - val (text, color) = - if (it.second.useManager) stringResource(R.string.patch_local) to MaterialTheme.colorScheme.secondary - else stringResource(R.string.patch_portable) to MaterialTheme.colorScheme.tertiary - append(AnnotatedString(text, SpanStyle(color = color))) - append(" ") - append(it.second.lspConfig.VERSION_CODE.toString()) - } - Text( - text = text, - fontWeight = FontWeight.SemiBold, - fontFamily = FontFamily.Serif, - style = MaterialTheme.typography.bodySmall - ) - } - ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - val shizukuUnavailable = stringResource(R.string.shizuku_unavailable) - if (it.second.lspConfig.VERSION_CODE >= 319 && it.second.lspConfig.VERSION_CODE < LSPConfig.instance.VERSION_CODE) { - DropdownMenuItem( - text = { Text(stringResource(R.string.manage_update_loader)) }, - onClick = { - expanded = false - scope.launch { - if (!ShizukuApi.isPermissionGranted) { - snackbarHost.showSnackbar(shizukuUnavailable) - } else { - viewModel.dispatch(ViewAction.UpdateLoader(it.first, it.second)) - } - } - } - ) - } - if (it.second.useManager) { - DropdownMenuItem( - text = { Text(stringResource(R.string.manage_module_scope)) }, - onClick = { - expanded = false - scope.launch { - scopeApp = it.first.app.packageName - val activated = ConfigManager.getModulesForApp(scopeApp).map { it.pkgName }.toSet() - navController.currentBackStackEntry!!.setState( - "selected", - SnapshotStateList().apply { - LSPPackageManager.appList.filterTo(this) { activated.contains(it.app.packageName) } - } - ) - navController.navigate(PageList.SelectApps.name + "?multiSelect=true") - } - } - ) - } - val optimizeSucceed = stringResource(R.string.manage_optimize_successfully) - val optimizeFailed = stringResource(R.string.manage_optimize_failed) - DropdownMenuItem( - text = { Text(stringResource(R.string.manage_optimize)) }, - onClick = { - expanded = false - scope.launch { - if (!ShizukuApi.isPermissionGranted) { - snackbarHost.showSnackbar(shizukuUnavailable) - } else { - val result = ShizukuApi.performDexOptMode(it.first.app.packageName) - snackbarHost.showSnackbar(if (result) optimizeSucceed else optimizeFailed) - } - } - } - ) - val uninstallSuccessfully = stringResource(R.string.manage_uninstall_successfully) - val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == Activity.RESULT_OK) { - scope.launch { - LSPPackageManager.fetchAppList() - snackbarHost.showSnackbar(uninstallSuccessfully) - } - } - } - DropdownMenuItem( - text = { Text(stringResource(R.string.uninstall)) }, - onClick = { - expanded = false - val intent = Intent(Intent.ACTION_DELETE).apply { - data = Uri.parse("package:${it.first.app.packageName}") - putExtra(Intent.EXTRA_RETURN_RESULT, true) - } - launcher.launch(intent) - } - ) - } - } - } - } - } -} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/AppManagePage.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/AppManagePage.kt new file mode 100644 index 0000000..1c0255b --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/AppManagePage.kt @@ -0,0 +1,349 @@ +package org.lsposed.lspatch.ui.page.manage + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.launch +import org.lsposed.lspatch.Constants +import org.lsposed.lspatch.R +import org.lsposed.lspatch.config.ConfigManager +import org.lsposed.lspatch.database.entity.Module +import org.lsposed.lspatch.lspApp +import org.lsposed.lspatch.share.LSPConfig +import org.lsposed.lspatch.ui.component.AppItem +import org.lsposed.lspatch.ui.component.LoadingDialog +import org.lsposed.lspatch.ui.page.PageList +import org.lsposed.lspatch.ui.util.LocalNavController +import org.lsposed.lspatch.ui.util.LocalSnackbarHost +import org.lsposed.lspatch.ui.util.observeState +import org.lsposed.lspatch.ui.util.setState +import org.lsposed.lspatch.ui.viewmodel.manage.AppManageViewModel +import org.lsposed.lspatch.util.LSPPackageManager +import org.lsposed.lspatch.util.ShizukuApi +import java.io.IOException + +private const val TAG = "AppManagePage" + +@Composable +fun AppManageBody() { + val viewModel = viewModel() + val snackbarHost = LocalSnackbarHost.current + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + + if (viewModel.appList.isEmpty()) { + Box(Modifier.fillMaxSize()) { + Text( + modifier = Modifier.align(Alignment.Center), + text = run { + if (LSPPackageManager.appList.isEmpty()) stringResource(R.string.manage_loading) + else stringResource(R.string.manage_no_apps) + }, + fontFamily = FontFamily.Serif, + style = MaterialTheme.typography.headlineSmall + ) + } + } else { + var scopeApp by rememberSaveable { mutableStateOf("") } + val isCancelled by navController.currentBackStackEntry!!.observeState("isCancelled") + LaunchedEffect(isCancelled) { + if (isCancelled == false) { + val selected = navController.currentBackStackEntry!! + .savedStateHandle.getLiveData>("selected").value!!.toSet() + Log.d(TAG, "Clear module list for $scopeApp") + ConfigManager.getModulesForApp(scopeApp).forEach { + ConfigManager.deactivateModule(scopeApp, it) + } + selected.forEach { + Log.d(TAG, "Activate ${it.app.packageName} for $scopeApp") + ConfigManager.activateModule(scopeApp, Module(it.app.packageName, it.app.sourceDir)) + } + navController.currentBackStackEntry!!.setState("isCancelled", null) + } + } + + if (viewModel.processingUpdate) LoadingDialog() + viewModel.updateLoaderResult?.let { + val updateSuccessfully = stringResource(R.string.manage_update_loader_successfully) + val updateFailed = stringResource(R.string.manage_update_loader_failed) + val copyError = stringResource(R.string.copy_error) + LaunchedEffect(Unit) { + it.onSuccess { + LSPPackageManager.fetchAppList() + snackbarHost.showSnackbar(updateSuccessfully) + }.onFailure { + val result = snackbarHost.showSnackbar(updateFailed, copyError) + if (result == SnackbarResult.ActionPerformed) { + val cm = lspApp.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("LSPatch", it.toString())) + } + } + viewModel.dispatch(AppManageViewModel.ViewAction.ClearUpdateLoaderResult) + } + } + + LazyColumn(Modifier.fillMaxHeight()) { + items( + items = viewModel.appList, + key = { it.first.app.packageName } + ) { + var expanded by remember { mutableStateOf(false) } + Box { + AppItem( + icon = LSPPackageManager.getIcon(it.first), + label = it.first.label, + packageName = it.first.app.packageName, + onClick = { expanded = true }, + onLongClick = { expanded = true }, + additionalContent = { + Text( + text = buildAnnotatedString { + val (text, color) = + if (it.second.useManager) stringResource(R.string.patch_local) to MaterialTheme.colorScheme.secondary + else stringResource(R.string.patch_portable) to MaterialTheme.colorScheme.tertiary + append(AnnotatedString(text, SpanStyle(color = color))) + append(" ") + append(it.second.lspConfig.VERSION_CODE.toString()) + }, + fontWeight = FontWeight.SemiBold, + fontFamily = FontFamily.Serif, + style = MaterialTheme.typography.bodySmall + ) + } + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + val shizukuUnavailable = stringResource(R.string.shizuku_unavailable) + if (it.second.lspConfig.VERSION_CODE >= 319 && it.second.lspConfig.VERSION_CODE < LSPConfig.instance.VERSION_CODE) { + DropdownMenuItem( + text = { Text(stringResource(R.string.manage_update_loader)) }, + onClick = { + expanded = false + scope.launch { + if (!ShizukuApi.isPermissionGranted) { + snackbarHost.showSnackbar(shizukuUnavailable) + } else { + viewModel.dispatch(AppManageViewModel.ViewAction.UpdateLoader(it.first, it.second)) + } + } + } + ) + } + if (it.second.useManager) { + DropdownMenuItem( + text = { Text(stringResource(R.string.manage_module_scope)) }, + onClick = { + expanded = false + scope.launch { + scopeApp = it.first.app.packageName + val activated = ConfigManager.getModulesForApp(scopeApp).map { it.pkgName }.toSet() + navController.currentBackStackEntry!!.setState( + "selected", + SnapshotStateList().apply { + LSPPackageManager.appList.filterTo(this) { activated.contains(it.app.packageName) } + } + ) + navController.navigate(PageList.SelectApps.name + "?multiSelect=true") + } + } + ) + } + val optimizeSucceed = stringResource(R.string.manage_optimize_successfully) + val optimizeFailed = stringResource(R.string.manage_optimize_failed) + DropdownMenuItem( + text = { Text(stringResource(R.string.manage_optimize)) }, + onClick = { + expanded = false + scope.launch { + if (!ShizukuApi.isPermissionGranted) { + snackbarHost.showSnackbar(shizukuUnavailable) + } else { + val result = ShizukuApi.performDexOptMode(it.first.app.packageName) + snackbarHost.showSnackbar(if (result) optimizeSucceed else optimizeFailed) + } + } + } + ) + val uninstallSuccessfully = stringResource(R.string.manage_uninstall_successfully) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + scope.launch { + LSPPackageManager.fetchAppList() + snackbarHost.showSnackbar(uninstallSuccessfully) + } + } + } + DropdownMenuItem( + text = { Text(stringResource(R.string.uninstall)) }, + onClick = { + expanded = false + val intent = Intent(Intent.ACTION_DELETE).apply { + data = Uri.parse("package:${it.first.app.packageName}") + putExtra(Intent.EXTRA_RETURN_RESULT, true) + } + launcher.launch(intent) + } + ) + } + } + } + } + } +} + +@Composable +fun AppManageFab() { + val context = LocalContext.current + val snackbarHost = LocalSnackbarHost.current + val navController = LocalNavController.current + val scope = rememberCoroutineScope() + var shouldSelectDirectory by remember { mutableStateOf(false) } + var showNewPatchDialog by remember { mutableStateOf(false) } + + val errorText = stringResource(R.string.patch_select_dir_error) + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + try { + if (it.resultCode == Activity.RESULT_CANCELED) return@rememberLauncherForActivityResult + val uri = it.data?.data ?: throw IOException("No data") + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, takeFlags) + lspApp.prefs.edit().putString(Constants.PREFS_STORAGE_DIRECTORY, uri.toString()).apply() + Log.i(TAG, "Storage directory: ${uri.path}") + showNewPatchDialog = true + } catch (e: Exception) { + Log.e(TAG, "Error when requesting saving directory", e) + scope.launch { snackbarHost.showSnackbar(errorText) } + } + } + + if (shouldSelectDirectory) { + AlertDialog( + onDismissRequest = { shouldSelectDirectory = false }, + confirmButton = { + TextButton( + content = { Text(stringResource(android.R.string.ok)) }, + onClick = { + launcher.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)) + shouldSelectDirectory = false + } + ) + }, + dismissButton = { + TextButton( + content = { Text(stringResource(android.R.string.cancel)) }, + onClick = { shouldSelectDirectory = false } + ) + }, + title = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.patch_select_dir_title), + textAlign = TextAlign.Center + ) + }, + text = { Text(stringResource(R.string.patch_select_dir_text)) } + ) + } + + if (showNewPatchDialog) { + AlertDialog( + onDismissRequest = { showNewPatchDialog = false }, + confirmButton = {}, + dismissButton = { + TextButton( + content = { Text(stringResource(android.R.string.cancel)) }, + onClick = { showNewPatchDialog = false } + ) + }, + title = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.page_new_patch), + textAlign = TextAlign.Center + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + TextButton( + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.secondary), + onClick = { + navController.navigate(PageList.NewPatch.name + "?from=storage") + showNewPatchDialog = false + } + ) { + Text( + modifier = Modifier.padding(vertical = 8.dp), + text = stringResource(R.string.patch_from_storage), + style = MaterialTheme.typography.bodyLarge + ) + } + TextButton( + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.secondary), + onClick = { + navController.navigate(PageList.NewPatch.name + "?from=applist") + showNewPatchDialog = false + } + ) { + Text( + modifier = Modifier.padding(vertical = 8.dp), + text = stringResource(R.string.patch_from_applist), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + ) + } + + FloatingActionButton( + content = { Icon(Icons.Filled.Add, stringResource(R.string.add)) }, + onClick = { + val uri = lspApp.prefs.getString(Constants.PREFS_STORAGE_DIRECTORY, null)?.toUri() + if (uri == null) { + shouldSelectDirectory = true + } else { + runCatching { + val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, takeFlags) + if (DocumentFile.fromTreeUri(context, uri)?.exists() == false) throw IOException("Storage directory was deleted") + }.onSuccess { + showNewPatchDialog = true + }.onFailure { + Log.w(TAG, "Failed to take persistable permission for saved uri", it) + lspApp.prefs.edit().putString(Constants.PREFS_STORAGE_DIRECTORY, null).apply() + shouldSelectDirectory = true + } + } + } + ) +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/ModuleManagePage.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/ModuleManagePage.kt new file mode 100644 index 0000000..a0bcc87 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/ModuleManagePage.kt @@ -0,0 +1,75 @@ +package org.lsposed.lspatch.ui.page.manage + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.viewmodel.compose.viewModel +import org.lsposed.lspatch.R +import org.lsposed.lspatch.ui.component.AppItem +import org.lsposed.lspatch.ui.viewmodel.manage.ModuleManageViewModel +import org.lsposed.lspatch.util.LSPPackageManager + +@Composable +fun ModuleManageBody() { + val viewModel = viewModel() + if (viewModel.appList.isEmpty()) { + Box(Modifier.fillMaxSize()) { + Text( + modifier = Modifier.align(Alignment.Center), + text = run { + if (LSPPackageManager.appList.isEmpty()) stringResource(R.string.manage_loading) + else stringResource(R.string.manage_no_modules) + }, + fontFamily = FontFamily.Serif, + style = MaterialTheme.typography.headlineSmall + ) + } + } else { + LazyColumn(Modifier.fillMaxHeight()) { + items( + items = viewModel.appList, + key = { it.first.app.packageName } + ) { + var expanded by remember { mutableStateOf(false) } + Box { + AppItem( + icon = LSPPackageManager.getIcon(it.first), + label = it.first.label, + packageName = it.first.app.packageName, + onClick = { /* TODO: startAndSendModuleBinder */ }, + onLongClick = { expanded = true }, + additionalContent = { + Text( + text = it.second.description, + style = MaterialTheme.typography.bodySmall + ) + Text( + text = buildAnnotatedString { + append(AnnotatedString("API", SpanStyle(color = MaterialTheme.colorScheme.secondary))) + append(" ") + append(it.second.api.toString()) + }, + fontWeight = FontWeight.SemiBold, + fontFamily = FontFamily.Serif, + style = MaterialTheme.typography.bodySmall + ) + } + ) + } + } + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt index 700f29c..f3e944a 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt @@ -15,10 +15,12 @@ import org.lsposed.lspatch.util.LSPPackageManager import org.lsposed.lspatch.util.LSPPackageManager.AppInfo import org.lsposed.patch.util.Logger -private const val TAG = "NewPatchViewModel" - class NewPatchViewModel : ViewModel() { + companion object { + private const val TAG = "NewPatchViewModel" + } + enum class PatchState { SELECTING, CONFIGURING, PATCHING, FINISHED, ERROR } diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppsViewModel.kt similarity index 93% rename from manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt rename to manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppsViewModel.kt index 61bb2e7..c50ca99 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppViewModel.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/SelectAppsViewModel.kt @@ -10,10 +10,12 @@ import kotlinx.coroutines.launch import org.lsposed.lspatch.util.LSPPackageManager import org.lsposed.lspatch.util.LSPPackageManager.AppInfo -private const val TAG = "SelectAppViewModel" - class SelectAppsViewModel : ViewModel() { + companion object { + private const val TAG = "SelectAppViewModel" + } + init { Log.d(TAG, "SelectAppsViewModel ${toString().substringAfterLast('@')} construct") } diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/ManageViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/AppManageViewModel.kt similarity index 96% rename from manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/ManageViewModel.kt rename to manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/AppManageViewModel.kt index 3447553..324f6d2 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/ManageViewModel.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/AppManageViewModel.kt @@ -1,4 +1,4 @@ -package org.lsposed.lspatch.ui.viewmodel +package org.lsposed.lspatch.ui.viewmodel.manage import android.content.pm.PackageInstaller import android.util.Base64 @@ -23,9 +23,11 @@ import org.lsposed.patch.util.Logger import java.io.FileNotFoundException import java.util.zip.ZipFile -private const val TAG = "ManageViewModel" +class AppManageViewModel : ViewModel() { -class ManageViewModel : ViewModel() { + companion object { + private const val TAG = "ManageViewModel" + } sealed class ViewAction { data class UpdateLoader(val appInfo: AppInfo, val config: PatchConfig) : ViewAction() diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/ModuleManageViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/ModuleManageViewModel.kt new file mode 100644 index 0000000..3066c59 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/ModuleManageViewModel.kt @@ -0,0 +1,33 @@ +package org.lsposed.lspatch.ui.viewmodel.manage + +import android.util.Log +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.lifecycle.ViewModel +import org.lsposed.lspatch.util.LSPPackageManager + +class ModuleManageViewModel : ViewModel() { + + companion object { + private const val TAG = "ModuleManageViewModel" + } + + class XposedInfo( + val api: Int, + val description: String, + val scope: List + ) + + val appList: List> by derivedStateOf { + LSPPackageManager.appList.mapNotNull { appInfo -> + val metaData = appInfo.app.metaData ?: return@mapNotNull null + appInfo to XposedInfo( + metaData.getInt("xposedminversion", -1).also { if (it == -1) return@mapNotNull null }, + metaData.getString("xposeddescription") ?: "", + emptyList() // TODO: scope + ) + }.also { + Log.d(TAG, "Loaded ${it.size} Xposed modules") + } + } +} diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 7a5b9ef..0fb13b0 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -5,6 +5,8 @@ Uninstall Uninstalling Copy error + Apps + Modules Shizuku service available Shizuku service not connected Repo @@ -35,6 +37,7 @@ Optimize successfully Optimize failed Uninstall successfully + No modules yet New Patch