feat: 增加已修補列表自動刷新 & 優化安裝完成後的跳轉邏輯

1. AppManageViewModel:
- 實現已修補應用列表的自動刷新機制(每 90 秒輪詢一次)。
- 引入 `silent` 參數區分自動刷新與手動刷新,避免自動刷新時 UI 出現加載轉圈,提升體驗。

2. NewPatchPage:
- 重構安裝成功的判定邏輯:不再僅依賴 PackageInstaller 的回調。
- 新增 `BroadcastReceiver` 監聽系統 `PACKAGE_ADDED` 和 `PACKAGE_REPLACED` 廣播。
- 確保系統真正註冊完新包後再執行導航返回 (navigateUp),解決安裝後立即返回導致列表尚未更新或狀態不同步的問題。
This commit is contained in:
NkBe 2025-12-03 00:33:21 +08:00
parent 5aa809dc5a
commit af548bb25d
No known key found for this signature in database
GPG Key ID: 525137026FF031DF
3 changed files with 91 additions and 20 deletions

View File

@ -55,7 +55,7 @@ val coreVerName by extra(coreLatestTag)
val androidMinSdkVersion by extra(27) val androidMinSdkVersion by extra(27)
val androidTargetSdkVersion by extra(36) val androidTargetSdkVersion by extra(36)
val androidCompileSdkVersion by extra(36) val androidCompileSdkVersion by extra(36)
val androidCompileNdkVersion by extra("29.0.13113456") val androidCompileNdkVersion by extra("29.0.13599879")
val androidBuildToolsVersion by extra("36.0.0") val androidBuildToolsVersion by extra("36.0.0")
val androidSourceCompatibility by extra(JavaVersion.VERSION_21) val androidSourceCompatibility by extra(JavaVersion.VERSION_21)
val androidTargetCompatibility by extra(JavaVersion.VERSION_21) val androidTargetCompatibility by extra(JavaVersion.VERSION_21)

View File

@ -1,10 +1,12 @@
package org.lsposed.npatch.ui.page package org.lsposed.npatch.ui.page
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Context.RECEIVER_NOT_EXPORTED import android.content.Context.RECEIVER_NOT_EXPORTED
import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
@ -406,6 +408,35 @@ private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) {
val viewModel = viewModel<NewPatchViewModel>() val viewModel = viewModel<NewPatchViewModel>()
val snackbarHost = LocalSnackbarHost.current val snackbarHost = LocalSnackbarHost.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current
// 监听应用安装广播
DisposableEffect(viewModel.patchApp.app.packageName) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
val data = intent.data
val pkgName = data?.schemeSpecificPart
// 只有当包名匹配,且动作是 添加 或 替换 时才认为是安装成功
if (pkgName == viewModel.patchApp.app.packageName) {
if (action == Intent.ACTION_PACKAGE_ADDED || action == Intent.ACTION_PACKAGE_REPLACED) {
scope.launch {
snackbarHost.showSnackbar(context.getString(R.string.patch_install_successfully))
navigator.navigateUp()
}
}
}
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_PACKAGE_ADDED)
addAction(Intent.ACTION_PACKAGE_REPLACED)
addDataScheme("package")
}
context.registerReceiver(receiver, filter)
onDispose { context.unregisterReceiver(receiver) }
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (viewModel.logs.isEmpty()) { if (viewModel.logs.isEmpty()) {
@ -454,16 +485,20 @@ private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) {
when (viewModel.patchState) { when (viewModel.patchState) {
PatchState.PATCHING -> BackHandler {} PatchState.PATCHING -> BackHandler {}
PatchState.FINISHED -> { PatchState.FINISHED -> {
val installSuccessfully = stringResource(R.string.patch_install_successfully) // val installSuccessfully = stringResource(R.string.patch_install_successfully) // 移交给 BroadcastReceiver 处理
val installFailed = stringResource(R.string.patch_install_failed) val installFailed = stringResource(R.string.patch_install_failed)
val copyError = stringResource(R.string.copy_error) val copyError = stringResource(R.string.copy_error)
var installation by remember { mutableStateOf<NewPatchViewModel.InstallMethod?>(null) } var installation by remember { mutableStateOf<NewPatchViewModel.InstallMethod?>(null) }
val onFinish: (Int, String?) -> Unit = { status, message -> val onFinish: (Int, String?) -> Unit = { status, message ->
scope.launch { scope.launch {
if (status == PackageInstaller.STATUS_SUCCESS) { if (status == PackageInstaller.STATUS_SUCCESS) {
snackbarHost.showSnackbar(installSuccessfully) // Shizuku 安装成功的情况
navigator.navigateUp() // 此处不执行 navigateUp等待 BroadcastReceiver 收到系统广播后再统一跳转
// 这样可以保证应用确实已被系统注册
Log.i(TAG, "Install reported success, waiting for broadcast to navigate.")
} else if (status != NPackageManager.STATUS_USER_CANCELLED) { } else if (status != NPackageManager.STATUS_USER_CANCELLED) {
// 安装失败处理
val result = snackbarHost.showSnackbar(installFailed, copyError) val result = snackbarHost.showSnackbar(installFailed, 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

View File

@ -3,14 +3,17 @@ package org.lsposed.npatch.ui.viewmodel.manage
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.util.Base64 import android.util.Base64
import android.util.Log import android.util.Log
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.google.gson.Gson import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.lsposed.npatch.Patcher import org.lsposed.npatch.Patcher
@ -31,6 +34,7 @@ class AppManageViewModel : ViewModel() {
companion object { companion object {
private const val TAG = "ManageViewModel" private const val TAG = "ManageViewModel"
private const val AUTO_REFRESH_INTERVAL = 90_114L
} }
sealed class ViewAction { sealed class ViewAction {
@ -38,22 +42,15 @@ 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()
} }
val appList: List<Pair<AppInfo, PatchConfig>> by derivedStateOf { // 手動管理狀態,避免實時響應系統廣播導致列表跳動
NPackageManager.appList.mapNotNull { appInfo -> var appList: List<Pair<AppInfo, PatchConfig>> by mutableStateOf(emptyList())
runCatching { private set
appInfo.app.metaData?.getString("npatch")?.let {
val json = Base64.decode(it, Base64.DEFAULT).toString(Charsets.UTF_8) var isRefreshing by mutableStateOf(false)
Log.d(TAG, "Read patched config: $json") private set
val config = Gson().fromJson(json, PatchConfig::class.java)
if (config?.lspConfig == null) null else appInfo to config
}
}.getOrNull()
}.also {
Log.d(TAG, "Loaded ${it.size} patched apps")
}
}
var updateLoaderState: ProcessingState<Result<Unit>> by mutableStateOf(ProcessingState.Idle) var updateLoaderState: ProcessingState<Result<Unit>> by mutableStateOf(ProcessingState.Idle)
private set private set
@ -75,6 +72,25 @@ class AppManageViewModel : ViewModel() {
} }
} }
init {
viewModelScope.launch {
snapshotFlow { NPackageManager.appList }
.filter { it.isNotEmpty() }
.first()
Log.d(TAG, "Initial data ready, starting auto-refresh loop")
// 啓動立即加载
loadData()
while (true) {
delay(AUTO_REFRESH_INTERVAL)
Log.d(TAG, "Auto refreshing app list (90s timer)")
if (!isRefreshing) {
loadData(silent = true)
}
}
}
}
fun dispatch(action: ViewAction) { fun dispatch(action: ViewAction) {
viewModelScope.launch { viewModelScope.launch {
when (action) { when (action) {
@ -82,10 +98,30 @@ 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)
} }
} }
} }
// silent 参数用于区分是否显示 loading 状态
private fun loadData(silent: Boolean = false) {
if (!silent) isRefreshing = true
val currentList = NPackageManager.appList.mapNotNull { appInfo ->
runCatching {
appInfo.app.metaData?.getString("npatch")?.let {
val json = Base64.decode(it, Base64.DEFAULT).toString(Charsets.UTF_8)
val config = Gson().fromJson(json, PatchConfig::class.java)
if (config?.lspConfig == null) null else appInfo to config
}
}.getOrNull()
}
Log.d(TAG, "Loaded ${currentList.size} patched apps")
appList = currentList
if (!silent) isRefreshing = false
}
private suspend fun updateLoader(appInfo: AppInfo, config: PatchConfig) { private suspend fun updateLoader(appInfo: AppInfo, config: PatchConfig) {
Log.i(TAG, "Update loader for ${appInfo.app.packageName}") Log.i(TAG, "Update loader for ${appInfo.app.packageName}")
updateLoaderState = ProcessingState.Processing updateLoaderState = ProcessingState.Processing