feat: 支持手動重載列表

- 當卸載後重新整理程式清單
- 可手動下拉刷新應用列表與模組列表
This commit is contained in:
NkBe 2025-12-07 23:35:59 +08:00
parent 5a90ed436e
commit 5c8509e4f5
No known key found for this signature in database
GPG Key ID: 525137026FF031DF
4 changed files with 274 additions and 226 deletions

View File

@ -30,6 +30,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.NavResult
import com.ramcosta.composedestinations.result.ResultRecipient import com.ramcosta.composedestinations.result.ResultRecipient
@ -68,191 +70,199 @@ fun AppManageBody(
val snackbarHost = LocalSnackbarHost.current val snackbarHost = LocalSnackbarHost.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
if (viewModel.appList.isEmpty()) { var scopeApp by rememberSaveable { mutableStateOf("") }
Box(Modifier.fillMaxSize()) { var afterCheckManager by remember { mutableStateOf<(() -> Unit)?>(null) }
Text(
modifier = Modifier.align(Alignment.Center), resultRecipient.onNavResult {
text = run { if (it is NavResult.Value) {
if (NPackageManager.appList.isEmpty()) stringResource(R.string.manage_loading) scope.launch {
else stringResource(R.string.manage_no_apps) val result = it.value as SelectAppsResult.MultipleApps
}, ConfigManager.getModulesForApp(scopeApp).forEach {
fontFamily = FontFamily.Serif, ConfigManager.deactivateModule(scopeApp, it)
style = MaterialTheme.typography.headlineSmall }
) result.selected.forEach {
} Log.d(TAG, "Activate ${it.app.packageName} for $scopeApp")
} else { ConfigManager.activateModule(scopeApp, Module(it.app.packageName, it.app.sourceDir))
var scopeApp by rememberSaveable { mutableStateOf("") }
var afterCheckManager by remember { mutableStateOf<(() -> Unit)?>(null) }
resultRecipient.onNavResult {
if (it is NavResult.Value) {
scope.launch {
val result = it.value as SelectAppsResult.MultipleApps
ConfigManager.getModulesForApp(scopeApp).forEach {
ConfigManager.deactivateModule(scopeApp, it)
}
result.selected.forEach {
Log.d(TAG, "Activate ${it.app.packageName} for $scopeApp")
ConfigManager.activateModule(scopeApp, Module(it.app.packageName, it.app.sourceDir))
}
} }
} }
} }
}
when (viewModel.updateLoaderState) { when (viewModel.updateLoaderState) {
is ProcessingState.Idle -> Unit is ProcessingState.Idle -> Unit
is ProcessingState.Processing -> LoadingDialog() is ProcessingState.Processing -> LoadingDialog()
is ProcessingState.Done -> { is ProcessingState.Done -> {
val it = viewModel.updateLoaderState as ProcessingState.Done val it = viewModel.updateLoaderState as ProcessingState.Done
val updateSuccessfully = stringResource(R.string.manage_update_loader_successfully) val updateSuccessfully = stringResource(R.string.manage_update_loader_successfully)
val updateFailed = stringResource(R.string.manage_update_loader_failed) val updateFailed = stringResource(R.string.manage_update_loader_failed)
val copyError = stringResource(R.string.copy_error) val copyError = stringResource(R.string.copy_error)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
it.result.onSuccess { it.result.onSuccess {
snackbarHost.showSnackbar(updateSuccessfully) snackbarHost.showSnackbar(updateSuccessfully)
}.onFailure { }.onFailure {
val result = snackbarHost.showSnackbar(updateFailed, copyError) val result = snackbarHost.showSnackbar(updateFailed, copyError)
if (result == SnackbarResult.ActionPerformed) { if (result == SnackbarResult.ActionPerformed) {
val cm = lspApp.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val cm = lspApp.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("NPatch", it.toString())) cm.setPrimaryClip(ClipData.newPlainText("NPatch", it.toString()))
}
} }
viewModel.dispatch(AppManageViewModel.ViewAction.ClearUpdateLoaderResult)
} }
viewModel.dispatch(AppManageViewModel.ViewAction.ClearUpdateLoaderResult)
} }
} }
when (viewModel.optimizeState) { }
is ProcessingState.Idle -> Unit when (viewModel.optimizeState) {
is ProcessingState.Processing -> LoadingDialog() is ProcessingState.Idle -> Unit
is ProcessingState.Done -> { is ProcessingState.Processing -> LoadingDialog()
val it = viewModel.optimizeState as ProcessingState.Done is ProcessingState.Done -> {
val optimizeSucceed = stringResource(R.string.manage_optimize_successfully) val it = viewModel.optimizeState as ProcessingState.Done
val optimizeFailed = stringResource(R.string.manage_optimize_failed) val optimizeSucceed = stringResource(R.string.manage_optimize_successfully)
LaunchedEffect(Unit) { val optimizeFailed = stringResource(R.string.manage_optimize_failed)
snackbarHost.showSnackbar(if (it.result) optimizeSucceed else optimizeFailed) LaunchedEffect(Unit) {
viewModel.dispatch(AppManageViewModel.ViewAction.ClearOptimizeResult) snackbarHost.showSnackbar(if (it.result) optimizeSucceed else optimizeFailed)
} viewModel.dispatch(AppManageViewModel.ViewAction.ClearOptimizeResult)
} }
} }
}
// 下拉刷新
SwipeRefresh(
state = rememberSwipeRefreshState(viewModel.isRefreshing),
onRefresh = { viewModel.dispatch(AppManageViewModel.ViewAction.Refresh) },
modifier = Modifier.fillMaxSize()
) {
if (viewModel.appList.isEmpty()) {
Box(Modifier.fillMaxSize()) {
Text(
modifier = Modifier.align(Alignment.Center),
text = run {
if (NPackageManager.appList.isEmpty()) stringResource(R.string.manage_loading)
else stringResource(R.string.manage_no_apps)
},
fontFamily = FontFamily.Serif,
style = MaterialTheme.typography.headlineSmall
)
}
} else {
LazyColumn(Modifier.fillMaxSize()) {
items(
items = viewModel.appList,
key = { it.first.app.packageName }
) { (appInfo, patchConfig) ->
val isRolling = patchConfig.useManager && patchConfig.lspConfig.VERSION_CODE >= Constants.MIN_ROLLING_VERSION_CODE
val canUpdateLoader = !isRolling && (patchConfig.lspConfig.VERSION_CODE < LSPConfig.instance.VERSION_CODE || patchConfig.managerPackageName != BuildConfig.APPLICATION_ID)
var expanded by remember { mutableStateOf(false) }
LazyColumn(Modifier.fillMaxHeight()) { AnywhereDropdown(
items( expanded = expanded,
items = viewModel.appList, onDismissRequest = { expanded = false },
key = { it.first.app.packageName } onClick = { expanded = true },
) { (appInfo, patchConfig) -> onLongClick = { expanded = true },
val isRolling = patchConfig.useManager && patchConfig.lspConfig.VERSION_CODE >= Constants.MIN_ROLLING_VERSION_CODE surface = {
val canUpdateLoader = !isRolling && (patchConfig.lspConfig.VERSION_CODE < LSPConfig.instance.VERSION_CODE || patchConfig.managerPackageName != BuildConfig.APPLICATION_ID) AppItem(
var expanded by remember { mutableStateOf(false) } icon = NPackageManager.getIcon(appInfo),
label = appInfo.label,
packageName = appInfo.app.packageName,
additionalContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
val patchText = if (patchConfig.useManager) {
stringResource(R.string.patch_local)
} else {
stringResource(R.string.patch_integrated)
}
val patchColor = if (patchConfig.useManager) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.tertiary
}
val versionText = if (isRolling) {
stringResource(R.string.manage_rolling)
} else {
patchConfig.lspConfig.VERSION_CODE.toString()
}
AnywhereDropdown( Text(
expanded = expanded, text = "$patchText $versionText",
onDismissRequest = { expanded = false }, color = patchColor,
onClick = { expanded = true }, fontWeight = FontWeight.SemiBold,
onLongClick = { expanded = true }, fontFamily = FontFamily.Serif,
surface = { style = MaterialTheme.typography.bodySmall
AppItem( )
icon = NPackageManager.getIcon(appInfo), if (canUpdateLoader) {
label = appInfo.label, with(LocalDensity.current) {
packageName = appInfo.app.packageName, val size = MaterialTheme.typography.bodySmall.fontSize * 1.2
additionalContent = { Icon(Icons.Filled.KeyboardCapslock, null, Modifier.size(size.toDp()))
Row(verticalAlignment = Alignment.CenterVertically) { }
val patchText = if (patchConfig.useManager) {
stringResource(R.string.patch_local)
} else {
stringResource(R.string.patch_integrated)
}
val patchColor = if (patchConfig.useManager) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.tertiary
}
val versionText = if (isRolling) {
stringResource(R.string.manage_rolling)
} else {
patchConfig.lspConfig.VERSION_CODE.toString()
}
Text(
text = "$patchText $versionText",
color = patchColor,
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Serif,
style = MaterialTheme.typography.bodySmall
)
if (canUpdateLoader) {
with(LocalDensity.current) {
val size = MaterialTheme.typography.bodySmall.fontSize * 1.2
Icon(Icons.Filled.KeyboardCapslock, null, Modifier.size(size.toDp()))
} }
} }
} }
} )
) }
} ) {
) {
DropdownMenuItem(
text = { Text(text = appInfo.label, color = MaterialTheme.colorScheme.primary) },
onClick = {}, enabled = false
)
val shizukuUnavailable = stringResource(R.string.shizuku_unavailable)
if (canUpdateLoader || BuildConfig.DEBUG) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.manage_update_loader)) }, text = { Text(text = appInfo.label, color = MaterialTheme.colorScheme.primary) },
onClick = { onClick = {}, enabled = false
expanded = false
scope.launch {
viewModel.dispatch(AppManageViewModel.ViewAction.UpdateLoader(appInfo, patchConfig))
}
}
) )
} val shizukuUnavailable = stringResource(R.string.shizuku_unavailable)
if (patchConfig.useManager) { if (canUpdateLoader || BuildConfig.DEBUG) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.manage_module_scope)) }, text = { Text(stringResource(R.string.manage_update_loader)) },
onClick = { onClick = {
expanded = false expanded = false
scope.launch { scope.launch {
scopeApp = appInfo.app.packageName viewModel.dispatch(AppManageViewModel.ViewAction.UpdateLoader(appInfo, patchConfig))
val activated = ConfigManager.getModulesForApp(scopeApp).map { it.pkgName }.toSet() }
val initialSelected = NPackageManager.appList.mapNotNullTo(ArrayList()) { }
if (activated.contains(it.app.packageName)) it.app.packageName else null )
}
if (patchConfig.useManager) {
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_module_scope)) },
onClick = {
expanded = false
scope.launch {
scopeApp = appInfo.app.packageName
val activated = ConfigManager.getModulesForApp(scopeApp).map { it.pkgName }.toSet()
val initialSelected = NPackageManager.appList.mapNotNullTo(ArrayList()) {
if (activated.contains(it.app.packageName)) it.app.packageName else null
}
navigator.navigate(SelectAppsScreenDestination(true, initialSelected))
}
}
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_optimize)) },
onClick = {
expanded = false
scope.launch {
if (!ShizukuApi.isPermissionGranted) {
snackbarHost.showSnackbar(shizukuUnavailable)
} else {
viewModel.dispatch(AppManageViewModel.ViewAction.PerformOptimize(appInfo))
} }
navigator.navigate(SelectAppsScreenDestination(true, initialSelected))
} }
} }
) )
} val uninstallSuccessfully = stringResource(R.string.manage_uninstall_successfully)
DropdownMenuItem( val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
text = { Text(stringResource(R.string.manage_optimize)) }, if (result.resultCode == Activity.RESULT_OK) {
onClick = { scope.launch {
expanded = false snackbarHost.showSnackbar(uninstallSuccessfully)
scope.launch { viewModel.dispatch(AppManageViewModel.ViewAction.Refresh)
if (!ShizukuApi.isPermissionGranted) {
snackbarHost.showSnackbar(shizukuUnavailable)
} else {
viewModel.dispatch(AppManageViewModel.ViewAction.PerformOptimize(appInfo))
} }
} }
} }
) DropdownMenuItem(
val uninstallSuccessfully = stringResource(R.string.manage_uninstall_successfully) text = { Text(stringResource(R.string.uninstall)) },
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> onClick = {
if (result.resultCode == Activity.RESULT_OK) { expanded = false
scope.launch { val intent = Intent(Intent.ACTION_DELETE).apply {
snackbarHost.showSnackbar(uninstallSuccessfully) data = Uri.parse("package:${appInfo.app.packageName}")
putExtra(Intent.EXTRA_RETURN_RESULT, true)
}
launcher.launch(intent)
} }
} )
} }
DropdownMenuItem(
text = { Text(stringResource(R.string.uninstall)) },
onClick = {
expanded = false
val intent = Intent(Intent.ACTION_DELETE).apply {
data = Uri.parse("package:${appInfo.app.packageName}")
putExtra(Intent.EXTRA_RETURN_RESULT, true)
}
launcher.launch(intent)
}
)
} }
} }
} }

View File

@ -22,6 +22,8 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import org.lsposed.npatch.ui.component.AnywhereDropdown import org.lsposed.npatch.ui.component.AnywhereDropdown
import org.lsposed.npatch.ui.component.AppItem import org.lsposed.npatch.ui.component.AppItem
import org.lsposed.npatch.R import org.lsposed.npatch.R
@ -32,75 +34,82 @@ import nkbe.util.NPackageManager
fun ModuleManageBody() { fun ModuleManageBody() {
val context = LocalContext.current val context = LocalContext.current
val viewModel = viewModel<ModuleManageViewModel>() val viewModel = viewModel<ModuleManageViewModel>()
if (viewModel.appList.isEmpty()) { // 下拉刷新
Box(Modifier.fillMaxSize()) { SwipeRefresh(
Text( state = rememberSwipeRefreshState(viewModel.isRefreshing),
modifier = Modifier.align(Alignment.Center), onRefresh = { viewModel.refresh() },
text = run { modifier = Modifier.fillMaxSize()
if (NPackageManager.appList.isEmpty()) stringResource(R.string.manage_loading) ) {
else stringResource(R.string.manage_no_modules) if (viewModel.appList.isEmpty()) {
}, Box(Modifier.fillMaxSize()) {
fontFamily = FontFamily.Serif, Text(
style = MaterialTheme.typography.headlineSmall modifier = Modifier.align(Alignment.Center),
) text = run {
} if (NPackageManager.appList.isEmpty()) stringResource(R.string.manage_loading)
} else { else stringResource(R.string.manage_no_modules)
LazyColumn(Modifier.fillMaxHeight()) { },
items( fontFamily = FontFamily.Serif,
items = viewModel.appList, style = MaterialTheme.typography.headlineSmall
key = { it.first.app.packageName } )
) { }
var expanded by remember { mutableStateOf(false) } } else {
val settingsIntent = remember { NPackageManager.getSettingsIntent(it.first.app.packageName) } LazyColumn(Modifier.fillMaxSize()) {
AnywhereDropdown( items(
expanded = expanded, items = viewModel.appList,
onDismissRequest = { expanded = false }, key = { it.first.app.packageName }
onClick = { settingsIntent?.let { context.startActivity(it) } }, ) {
onLongClick = { expanded = true }, var expanded by remember { mutableStateOf(false) }
surface = { val settingsIntent = remember { NPackageManager.getSettingsIntent(it.first.app.packageName) }
AppItem( AnywhereDropdown(
icon = NPackageManager.getIcon(it.first), expanded = expanded,
label = it.first.label, onDismissRequest = { expanded = false },
packageName = it.first.app.packageName, onClick = { settingsIntent?.let { context.startActivity(it) } },
additionalContent = { onLongClick = { expanded = true },
Text( surface = {
text = it.second.description, AppItem(
style = MaterialTheme.typography.bodySmall icon = NPackageManager.getIcon(it.first),
) label = it.first.label,
Text( packageName = it.first.app.packageName,
text = buildAnnotatedString { additionalContent = {
append(AnnotatedString("API", SpanStyle(color = MaterialTheme.colorScheme.secondary))) Text(
append(" ") text = it.second.description,
append(it.second.api.toString()) style = MaterialTheme.typography.bodySmall
}, )
fontWeight = FontWeight.SemiBold, Text(
fontFamily = FontFamily.Serif, text = buildAnnotatedString {
style = MaterialTheme.typography.bodySmall append(AnnotatedString("API", SpanStyle(color = MaterialTheme.colorScheme.secondary)))
append(" ")
append(it.second.api.toString())
},
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Serif,
style = MaterialTheme.typography.bodySmall
)
}
)
}
) {
DropdownMenuItem(
text = { Text(text = it.first.label, color = MaterialTheme.colorScheme.primary) },
onClick = {}, enabled = false
)
if (settingsIntent != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_module_settings)) },
onClick = { context.startActivity(settingsIntent) }
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_app_info)) },
onClick = {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", it.first.app.packageName, null)
) )
context.startActivity(intent)
} }
) )
} }
) {
DropdownMenuItem(
text = { Text(text = it.first.label, color = MaterialTheme.colorScheme.primary) },
onClick = {}, enabled = false
)
if (settingsIntent != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_module_settings)) },
onClick = { context.startActivity(settingsIntent) }
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.manage_app_info)) },
onClick = {
val intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", it.first.app.packageName, null)
)
context.startActivity(intent)
}
)
} }
} }
} }

View File

@ -42,7 +42,7 @@ class AppManageViewModel : ViewModel() {
object ClearUpdateLoaderResult : ViewAction() object ClearUpdateLoaderResult : ViewAction()
data class PerformOptimize(val appInfo: AppInfo) : ViewAction() data class PerformOptimize(val appInfo: AppInfo) : ViewAction()
object ClearOptimizeResult : ViewAction() object ClearOptimizeResult : ViewAction()
object RefreshList : ViewAction() object Refresh : ViewAction()
} }
// 手動管理狀態,避免實時響應系統廣播導致列表跳動 // 手動管理狀態,避免實時響應系統廣播導致列表跳動
@ -98,7 +98,16 @@ class AppManageViewModel : ViewModel() {
is ViewAction.ClearUpdateLoaderResult -> updateLoaderState = ProcessingState.Idle is ViewAction.ClearUpdateLoaderResult -> updateLoaderState = ProcessingState.Idle
is ViewAction.PerformOptimize -> performOptimize(action.appInfo) is ViewAction.PerformOptimize -> performOptimize(action.appInfo)
is ViewAction.ClearOptimizeResult -> optimizeState = ProcessingState.Idle is ViewAction.ClearOptimizeResult -> optimizeState = ProcessingState.Idle
is ViewAction.RefreshList -> loadData(silent = false) is ViewAction.Refresh -> {
if (!isRefreshing) {
isRefreshing = true
withContext(Dispatchers.IO) {
NPackageManager.fetchAppList()
}
loadData(silent = true)
isRefreshing = false
}
}
} }
} }
} }

View File

@ -3,7 +3,13 @@ package org.lsposed.npatch.ui.viewmodel.manage
import android.util.Log import android.util.Log
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nkbe.util.NPackageManager import nkbe.util.NPackageManager
class ModuleManageViewModel : ViewModel() { class ModuleManageViewModel : ViewModel() {
@ -12,6 +18,9 @@ class ModuleManageViewModel : ViewModel() {
private const val TAG = "ModuleManageViewModel" private const val TAG = "ModuleManageViewModel"
} }
var isRefreshing by mutableStateOf(false)
private set
class XposedInfo( class XposedInfo(
val api: Int, val api: Int,
val description: String, val description: String,
@ -30,4 +39,15 @@ class ModuleManageViewModel : ViewModel() {
Log.d(TAG, "Loaded ${it.size} Xposed modules") Log.d(TAG, "Loaded ${it.size} Xposed modules")
} }
} }
fun refresh() {
if (isRefreshing) return
viewModelScope.launch {
isRefreshing = true
withContext(Dispatchers.IO) {
NPackageManager.fetchAppList()
}
isRefreshing = false
}
}
} }