Module page
This commit is contained in:
parent
279e95b57b
commit
21c43250f1
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ManageViewModel>()
|
||||
|
||||
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<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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
<string name="uninstall">Uninstall</string>
|
||||
<string name="uninstalling">Uninstalling</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_unavailable">Shizuku service not connected</string>
|
||||
<string name="page_repo">Repo</string>
|
||||
|
|
@ -35,6 +37,7 @@
|
|||
<string name="manage_optimize_successfully">Optimize successfully</string>
|
||||
<string name="manage_optimize_failed">Optimize failed</string>
|
||||
<string name="manage_uninstall_successfully">Uninstall successfully</string>
|
||||
<string name="manage_no_modules">No modules yet</string>
|
||||
|
||||
<!-- New Patch Page -->
|
||||
<string name="page_new_patch">New Patch</string>
|
||||
|
|
|
|||
Loading…
Reference in New Issue