Module page

This commit is contained in:
Nullptr 2022-07-06 09:59:47 +08:00
parent 279e95b57b
commit 21c43250f1
No known key found for this signature in database
GPG Key ID: 0B9D02052FF536BD
14 changed files with 533 additions and 367 deletions

View File

@ -92,6 +92,7 @@ dependencies {
implementation("androidx.room:room-runtime:$roomVersion") implementation("androidx.room:room-runtime:$roomVersion")
implementation("com.google.accompanist:accompanist-drawablepainter:0.24.11-rc") 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-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.accompanist:accompanist-swiperefresh:0.24.11-rc")
implementation("com.google.android.material:material:1.6.1") implementation("com.google.android.material:material:1.6.1")
implementation("com.google.code.gson:gson:2.9.0") implementation("com.google.code.gson:gson:2.9.0")

View File

@ -11,8 +11,6 @@ import org.lsposed.lspatch.util.LSPPackageManager
import org.lsposed.lspatch.util.ShizukuApi import org.lsposed.lspatch.util.ShizukuApi
import java.io.File import java.io.File
const val TAG = "LSPatch Manager"
lateinit var lspApp: LSPApplication lateinit var lspApp: LSPApplication
class LSPApplication : Application() { class LSPApplication : Application() {

View File

@ -7,11 +7,14 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import org.lsposed.lspatch.TAG
import org.lsposed.lspatch.lspApp import org.lsposed.lspatch.lspApp
class ModuleProvider : ContentProvider() { class ModuleProvider : ContentProvider() {
companion object {
private const val TAG = "ModuleProvider"
}
override fun onCreate(): Boolean { override fun onCreate(): Boolean {
return false return false
} }

View File

@ -10,7 +10,6 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable

View File

@ -30,7 +30,7 @@ fun AppItem(
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
checked: Boolean? = null, checked: Boolean? = null,
rightIcon: (@Composable () -> Unit)? = null, rightIcon: (@Composable () -> Unit)? = null,
additionalContent: (@Composable () -> Unit)? = null, additionalContent: (@Composable ColumnScope.() -> Unit)? = null,
) { ) {
if (checked != null && rightIcon != null) if (checked != null && rightIcon != null)
throw IllegalArgumentException("`checked` and `rightIcon` should not be both set") throw IllegalArgumentException("`checked` and `rightIcon` should not be both set")
@ -63,7 +63,7 @@ fun AppItem(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
additionalContent?.invoke() additionalContent?.invoke(this)
} }
if (checked != null) { if (checked != null) {
Checkbox( Checkbox(

View File

@ -25,7 +25,9 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.lsposed.lspatch.TAG
private const val TAG = "SearchBar"
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable

View File

@ -1,73 +1,68 @@
package org.lsposed.lspatch.ui.page package org.lsposed.lspatch.ui.page
import android.app.Activity import androidx.compose.foundation.layout.Box
import android.content.ClipData import androidx.compose.foundation.layout.Column
import android.content.ClipboardManager import androidx.compose.foundation.layout.padding
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.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.compose.ui.unit.dp
import androidx.core.net.toUri import com.google.accompanist.pager.ExperimentalPagerApi
import androidx.documentfile.provider.DocumentFile import com.google.accompanist.pager.HorizontalPager
import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.pager.rememberPagerState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.lsposed.lspatch.Constants.PREFS_STORAGE_DIRECTORY
import org.lsposed.lspatch.R import org.lsposed.lspatch.R
import org.lsposed.lspatch.config.ConfigManager import org.lsposed.lspatch.ui.page.manage.AppManageBody
import org.lsposed.lspatch.database.entity.Module import org.lsposed.lspatch.ui.page.manage.AppManageFab
import org.lsposed.lspatch.lspApp import org.lsposed.lspatch.ui.page.manage.ModuleManageBody
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
private const val TAG = "ManagePage" @OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ManagePage() { fun ManagePage() {
val viewModel = viewModel<ManageViewModel>() val scope = rememberCoroutineScope()
val pagerState = rememberPagerState()
Scaffold( Scaffold(
topBar = { TopBar() }, topBar = { TopBar() },
floatingActionButton = { Fab() } floatingActionButton = { if (pagerState.currentPage == 0) AppManageFab() }
) { innerPadding -> ) { innerPadding ->
Box(Modifier.padding(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) } 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<ManageViewModel>()
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<Boolean>("isCancelled")
LaunchedEffect(isCancelled) {
if (isCancelled == false) {
val selected = navController.currentBackStackEntry!!
.savedStateHandle.getLiveData<SnapshotStateList<AppInfo>>("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<AppInfo>().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)
}
)
}
}
}
}
}
}

View File

@ -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<AppManageViewModel>()
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<Boolean>("isCancelled")
LaunchedEffect(isCancelled) {
if (isCancelled == false) {
val selected = navController.currentBackStackEntry!!
.savedStateHandle.getLiveData<SnapshotStateList<LSPPackageManager.AppInfo>>("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<LSPPackageManager.AppInfo>().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
}
}
}
)
}

View File

@ -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<ModuleManageViewModel>()
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
)
}
)
}
}
}
}
}

View File

@ -15,10 +15,12 @@ import org.lsposed.lspatch.util.LSPPackageManager
import org.lsposed.lspatch.util.LSPPackageManager.AppInfo import org.lsposed.lspatch.util.LSPPackageManager.AppInfo
import org.lsposed.patch.util.Logger import org.lsposed.patch.util.Logger
private const val TAG = "NewPatchViewModel"
class NewPatchViewModel : ViewModel() { class NewPatchViewModel : ViewModel() {
companion object {
private const val TAG = "NewPatchViewModel"
}
enum class PatchState { enum class PatchState {
SELECTING, CONFIGURING, PATCHING, FINISHED, ERROR SELECTING, CONFIGURING, PATCHING, FINISHED, ERROR
} }

View File

@ -10,10 +10,12 @@ import kotlinx.coroutines.launch
import org.lsposed.lspatch.util.LSPPackageManager import org.lsposed.lspatch.util.LSPPackageManager
import org.lsposed.lspatch.util.LSPPackageManager.AppInfo import org.lsposed.lspatch.util.LSPPackageManager.AppInfo
private const val TAG = "SelectAppViewModel"
class SelectAppsViewModel : ViewModel() { class SelectAppsViewModel : ViewModel() {
companion object {
private const val TAG = "SelectAppViewModel"
}
init { init {
Log.d(TAG, "SelectAppsViewModel ${toString().substringAfterLast('@')} construct") Log.d(TAG, "SelectAppsViewModel ${toString().substringAfterLast('@')} construct")
} }

View File

@ -1,4 +1,4 @@
package org.lsposed.lspatch.ui.viewmodel package org.lsposed.lspatch.ui.viewmodel.manage
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.util.Base64 import android.util.Base64
@ -23,9 +23,11 @@ import org.lsposed.patch.util.Logger
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.util.zip.ZipFile 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 { sealed class ViewAction {
data class UpdateLoader(val appInfo: AppInfo, val config: PatchConfig) : ViewAction() data class UpdateLoader(val appInfo: AppInfo, val config: PatchConfig) : ViewAction()

View File

@ -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<String>
)
val appList: List<Pair<LSPPackageManager.AppInfo, XposedInfo>> 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")
}
}
}

View File

@ -5,6 +5,8 @@
<string name="uninstall">Uninstall</string> <string name="uninstall">Uninstall</string>
<string name="uninstalling">Uninstalling</string> <string name="uninstalling">Uninstalling</string>
<string name="copy_error">Copy error</string> <string name="copy_error">Copy error</string>
<string name="apps">Apps</string>
<string name="modules">Modules</string>
<string name="shizuku_available">Shizuku service available</string> <string name="shizuku_available">Shizuku service available</string>
<string name="shizuku_unavailable">Shizuku service not connected</string> <string name="shizuku_unavailable">Shizuku service not connected</string>
<string name="page_repo">Repo</string> <string name="page_repo">Repo</string>
@ -35,6 +37,7 @@
<string name="manage_optimize_successfully">Optimize successfully</string> <string name="manage_optimize_successfully">Optimize successfully</string>
<string name="manage_optimize_failed">Optimize failed</string> <string name="manage_optimize_failed">Optimize failed</string>
<string name="manage_uninstall_successfully">Uninstall successfully</string> <string name="manage_uninstall_successfully">Uninstall successfully</string>
<string name="manage_no_modules">No modules yet</string>
<!-- New Patch Page --> <!-- New Patch Page -->
<string name="page_new_patch">New Patch</string> <string name="page_new_patch">New Patch</string>