From a17427ddf972d7a3116a7d637bd22e407649bb7d Mon Sep 17 00:00:00 2001 From: xihan123 Date: Sat, 9 Aug 2025 16:11:16 +0800 Subject: [PATCH] Enhance installation and cleanup processes#29 --- manager/src/main/AndroidManifest.xml | 19 +++ .../org/lsposed/lspatch/LSPApplication.kt | 2 +- .../main/java/org/lsposed/lspatch/Patcher.kt | 7 + .../lsposed/lspatch/ui/page/NewPatchScreen.kt | 146 +++++++++++++++-- .../lspatch/ui/page/manage/AppManagePage.kt | 6 +- .../java/org/lsposed/lspatch/ui/util/Utils.kt | 147 ++++++++++++++++++ .../lspatch/ui/viewmodel/NewPatchViewModel.kt | 4 + .../ui/viewmodel/manage/AppManageViewModel.kt | 24 ++- .../lsposed/lspatch/util/LSPPackageManager.kt | 6 + manager/src/main/res/xml/file_paths.xml | 5 + 10 files changed, 347 insertions(+), 19 deletions(-) create mode 100644 manager/src/main/res/xml/file_paths.xml diff --git a/manager/src/main/AndroidManifest.xml b/manager/src/main/AndroidManifest.xml index f3d3f14dd..df006d4b5 100644 --- a/manager/src/main/AndroidManifest.xml +++ b/manager/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" /> + + + + + + + @@ -44,6 +53,16 @@ android:exported="true" android:multiprocess="false" android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" /> + + + + diff --git a/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt b/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt index e35023e29..5924adf9d 100644 --- a/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt +++ b/manager/src/main/java/org/lsposed/lspatch/LSPApplication.kt @@ -18,7 +18,7 @@ class LSPApplication : Application() { lateinit var prefs: SharedPreferences lateinit var tmpApkDir: File - + var targetApkFiles: ArrayList? = null val globalScope = CoroutineScope(Dispatchers.Default) override fun onCreate() { diff --git a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt index 15e6d3544..30757ec24 100644 --- a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt +++ b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt @@ -10,6 +10,7 @@ import org.lsposed.lspatch.share.Constants import org.lsposed.lspatch.share.PatchConfig import org.lsposed.patch.LSPatch import org.lsposed.patch.util.Logger +import java.io.File import java.io.IOException import java.util.Collections.addAll @@ -52,6 +53,8 @@ object Patcher { root.listFiles().forEach { if (it.name?.endsWith(Constants.PATCH_FILE_SUFFIX) == true) it.delete() } + lspApp.targetApkFiles?.clear() + val apkFileList = arrayListOf() lspApp.tmpApkDir.walk() .filter { it.name.endsWith(Constants.PATCH_FILE_SUFFIX) } .forEach { apk -> @@ -59,12 +62,16 @@ object Patcher { ?: throw IOException("Failed to create output file") val output = lspApp.contentResolver.openOutputStream(file.uri) ?: throw IOException("Failed to open output stream") + val apkFile = File(lspApp.externalCacheDir, apk.name) + apk.copyTo(apkFile, overwrite = true) + apkFileList.add(apkFile) output.use { apk.inputStream().use { input -> input.copyTo(output) } } } + lspApp.targetApkFiles = apkFileList logger.i("Patched files are saved to ${root.uri.lastPathSegment}") } } diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchScreen.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchScreen.kt index d298fe16f..ebf2282d2 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchScreen.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/NewPatchScreen.kt @@ -1,10 +1,14 @@ package org.lsposed.lspatch.ui.page +import android.annotation.SuppressLint import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.Context.RECEIVER_NOT_EXPORTED +import android.content.IntentFilter import android.content.pm.PackageInstaller import android.net.Uri +import android.os.Build import android.util.Log import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -28,10 +32,14 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -47,9 +55,14 @@ import org.lsposed.lspatch.ui.component.ShimmerAnimation import org.lsposed.lspatch.ui.component.settings.SettingsCheckBox import org.lsposed.lspatch.ui.component.settings.SettingsItem import org.lsposed.lspatch.ui.page.destinations.SelectAppsScreenDestination +import org.lsposed.lspatch.ui.util.InstallResultReceiver import org.lsposed.lspatch.ui.util.LocalSnackbarHost +import org.lsposed.lspatch.ui.util.checkIsApkFixedByLSP +import org.lsposed.lspatch.ui.util.installApk +import org.lsposed.lspatch.ui.util.installApks import org.lsposed.lspatch.ui.util.isScrolledToEnd import org.lsposed.lspatch.ui.util.lastItemIndex +import org.lsposed.lspatch.ui.util.uninstallApkByPackageName import org.lsposed.lspatch.ui.viewmodel.NewPatchViewModel import org.lsposed.lspatch.ui.viewmodel.NewPatchViewModel.PatchState import org.lsposed.lspatch.ui.viewmodel.NewPatchViewModel.ViewAction @@ -377,6 +390,7 @@ private fun PatchOptionsBody(modifier: Modifier, onAddEmbed: () -> Unit) { } } +@SuppressLint("UnusedBoxWithConstraintsScope") @Composable private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) { val viewModel = viewModel() @@ -435,10 +449,9 @@ private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) { val installSuccessfully = stringResource(R.string.patch_install_successfully) val installFailed = stringResource(R.string.patch_install_failed) val copyError = stringResource(R.string.copy_error) - var installing by remember { mutableStateOf(false) } - if (installing) InstallDialog(viewModel.patchApp) { status, message -> + var installation by remember { mutableStateOf(null) } + val onFinish: (Int, String?) -> Unit = { status, message -> scope.launch { - installing = false if (status == PackageInstaller.STATUS_SUCCESS) { lspApp.globalScope.launch { snackbarHost.showSnackbar(installSuccessfully) } navigator.navigateUp() @@ -451,6 +464,11 @@ private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) { } } } + when (installation) { + NewPatchViewModel.InstallMethod.SYSTEM -> InstallDialog2(viewModel.patchApp, onFinish) + NewPatchViewModel.InstallMethod.SHIZUKU -> InstallDialog(viewModel.patchApp, onFinish) + null -> {} + } Row(Modifier.padding(top = 12.dp)) { Button( modifier = Modifier.weight(1f), @@ -461,13 +479,8 @@ private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) { Button( modifier = Modifier.weight(1f), onClick = { - if (!ShizukuApi.isPermissionGranted) { - scope.launch { - snackbarHost.showSnackbar(shizukuUnavailable) - } - } else { - installing = true - } + installation = if (!ShizukuApi.isPermissionGranted) NewPatchViewModel.InstallMethod.SYSTEM else NewPatchViewModel.InstallMethod.SHIZUKU + Log.d(TAG, "Installation method: $installation") }, content = { Text(stringResource(R.string.install)) } ) @@ -572,3 +585,116 @@ private fun InstallDialog(patchApp: AppInfo, onFinish: (Int, String?) -> Unit) { ) } } + +@Composable +private fun InstallDialog2(patchApp: AppInfo, onFinish: (Int, String?) -> Unit) { + val scope = rememberCoroutineScope() + var uninstallFirst by remember { + mutableStateOf( + checkIsApkFixedByLSP( + lspApp, + patchApp.app.packageName + ) + ) + } + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + val splitInstallReceiver by lazy { InstallResultReceiver() } + fun doInstall() { + Log.i(TAG, "Installing app ${patchApp.app.packageName}") + val apkFiles = lspApp.targetApkFiles + if (apkFiles.isNullOrEmpty()){ + onFinish(PackageInstaller.STATUS_FAILURE, "No target APK files found for installation") + return + } + if (apkFiles.size > 1) { + scope.launch { + val success = installApks(lspApp, apkFiles) + if (success) { + onFinish( + PackageInstaller.STATUS_SUCCESS, + "Split APKs installed successfully" + ) + } else { + onFinish( + PackageInstaller.STATUS_FAILURE, + "Failed to install split APKs" + ) + } + } + } else { + installApk(lspApp, apkFiles.first()) + } + } + + DisposableEffect(lifecycleOwner) { + val observer = object : DefaultLifecycleObserver { + @SuppressLint("UnspecifiedRegisterReceiverFlag") + override fun onCreate(owner: LifecycleOwner) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(splitInstallReceiver, IntentFilter(InstallResultReceiver.ACTION_INSTALL_STATUS), RECEIVER_NOT_EXPORTED) + } else { + context.registerReceiver(splitInstallReceiver, IntentFilter(InstallResultReceiver.ACTION_INSTALL_STATUS)) + } + } + + override fun onDestroy(owner: LifecycleOwner) { + context.unregisterReceiver(splitInstallReceiver) + } + + override fun onResume(owner: LifecycleOwner) { + if (!uninstallFirst) { + Log.d(TAG,"Starting installation without uninstalling first") + onFinish(LSPPackageManager.STATUS_USER_CANCELLED, "User cancelled") + doInstall() + } + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + if (uninstallFirst) { + AlertDialog( + onDismissRequest = { + onFinish( + LSPPackageManager.STATUS_USER_CANCELLED, + "User cancelled" + ) + }, + confirmButton = { + TextButton( + onClick = { + onFinish(LSPPackageManager.STATUS_USER_CANCELLED, "Reset") + scope.launch { + Log.i(TAG, "Uninstalling app ${patchApp.app.packageName}") + uninstallApkByPackageName(lspApp, patchApp.app.packageName) + uninstallFirst = false + } + }, + content = { Text(stringResource(android.R.string.ok)) } + ) + }, + dismissButton = { + TextButton( + onClick = { + onFinish( + LSPPackageManager.STATUS_USER_CANCELLED, + "User cancelled" + ) + }, + content = { Text(stringResource(android.R.string.cancel)) } + ) + }, + title = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.uninstall), + textAlign = TextAlign.Center + ) + }, + text = { Text(stringResource(R.string.patch_uninstall_text)) } + ) + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/AppManagePage.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/AppManagePage.kt index a062a374c..4535147ed 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/AppManagePage.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/manage/AppManagePage.kt @@ -192,11 +192,7 @@ fun AppManageBody( onClick = { expanded = false scope.launch { - if (!ShizukuApi.isPermissionGranted) { - snackbarHost.showSnackbar(shizukuUnavailable) - } else { - viewModel.dispatch(AppManageViewModel.ViewAction.UpdateLoader(it.first, it.second)) - } + viewModel.dispatch(AppManageViewModel.ViewAction.UpdateLoader(it.first, it.second)) } } ) diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/util/Utils.kt b/manager/src/main/java/org/lsposed/lspatch/ui/util/Utils.kt index 0a90329d0..8cb4b3e9b 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/util/Utils.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/util/Utils.kt @@ -1,6 +1,22 @@ package org.lsposed.lspatch.ui.util +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import android.util.Log import androidx.compose.foundation.lazy.LazyListState +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.lsposed.lspatch.BuildConfig +import java.io.File +import java.io.IOException val LazyListState.lastVisibleItemIndex get() = layoutInfo.visibleItemsInfo.lastOrNull()?.index @@ -10,3 +26,134 @@ val LazyListState.lastItemIndex val LazyListState.isScrolledToEnd get() = lastVisibleItemIndex == lastItemIndex + +fun checkIsApkFixedByLSP(context: Context, packageName: String): Boolean { + return try { + val app = + context.packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + (app.metaData?.containsKey("lspatch") != true) + } catch (_: PackageManager.NameNotFoundException) { + Log.e("LSPatch", "Package not found: $packageName") + false + } catch (e: Exception) { + Log.e("LSPatch", "Unexpected error in checkIsApkFixedByLSP", e) + false + } +} + +fun installApk(context: Context, apkFile: File) { + try { + val apkUri = + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile) + + val intent = Intent(Intent.ACTION_VIEW).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + addCategory("android.intent.category.DEFAULT") + setDataAndType(apkUri, "application/vnd.android.package-archive") + } + context.startActivity(intent) + } catch (_: Exception) { + } +} + +fun uninstallApkByPackageName(context: Context, packageName: String) = try { + val intent = Intent(Intent.ACTION_DELETE).apply { + data = "package:$packageName".toUri() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) +} catch (_: Exception) { +} + +class InstallResultReceiver : BroadcastReceiver() { + + companion object { + const val ACTION_INSTALL_STATUS = "${BuildConfig.APPLICATION_ID}.INSTALL_STATUS" + + fun createPendingIntent(context: Context, sessionId: Int): PendingIntent { + val intent = Intent(context, InstallResultReceiver::class.java).apply { + action = ACTION_INSTALL_STATUS + } + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + return PendingIntent.getBroadcast(context, sessionId, intent, flags) + } + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_INSTALL_STATUS) { + return + } + + val status = + intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val confirmIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT) + if (confirmIntent != null) { + context.startActivity(confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + } + + PackageInstaller.STATUS_SUCCESS -> { + } + + else -> { + } + } + } +} + +suspend fun installApks(context: Context, apkFiles: List): Boolean { + if (!context.packageManager.canRequestPackageInstalls()) { + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = "package:${context.packageName}".toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(intent) + return false + } + + apkFiles.forEach { + if (!it.exists()) { + return false + } + } + + return withContext(Dispatchers.IO) { + val packageInstaller = context.packageManager.packageInstaller + var session: PackageInstaller.Session? = null + try { + val params = + PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + val sessionId = packageInstaller.createSession(params) + session = packageInstaller.openSession(sessionId) + + apkFiles.forEach { apkFile -> + session.openWrite(apkFile.name, 0, apkFile.length()).use { outputStream -> + apkFile.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + session.fsync(outputStream) + } + } + } + + val pendingIntent = InstallResultReceiver.createPendingIntent(context, sessionId) + + session.commit(pendingIntent.intentSender) + true + } catch (_: IOException) { + session?.abandon() + false + } catch (_: Exception) { + session?.abandon() + false + } + } +} \ No newline at end of file diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt index be17f4b57..8d939d14f 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/NewPatchViewModel.kt @@ -24,6 +24,10 @@ class NewPatchViewModel : ViewModel() { INIT, SELECTING, CONFIGURING, PATCHING, FINISHED, ERROR } + enum class InstallMethod { + SYSTEM, SHIZUKU + } + sealed class ViewAction { object DoneInit : ViewAction() data class ConfigurePatch(val app: AppInfo) : ViewAction() diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/AppManageViewModel.kt b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/AppManageViewModel.kt index 51da66e3c..34ada43ab 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/AppManageViewModel.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/viewmodel/manage/AppManageViewModel.kt @@ -17,6 +17,8 @@ import org.lsposed.lspatch.Patcher import org.lsposed.lspatch.lspApp import org.lsposed.lspatch.share.Constants import org.lsposed.lspatch.share.PatchConfig +import org.lsposed.lspatch.ui.util.installApk +import org.lsposed.lspatch.ui.util.installApks import org.lsposed.lspatch.ui.viewstate.ProcessingState import org.lsposed.lspatch.util.LSPPackageManager import org.lsposed.lspatch.util.LSPPackageManager.AppInfo @@ -86,7 +88,10 @@ class AppManageViewModel : ViewModel() { updateLoaderState = ProcessingState.Processing val result = runCatching { withContext(Dispatchers.IO) { - LSPPackageManager.cleanTmpApkDir() + LSPPackageManager.apply { + cleanTmpApkDir() + cleanExternalTmpApkDir() + } val apkPaths = listOf(appInfo.app.sourceDir) + (appInfo.app.splitSourceDirs ?: emptyArray()) val patchPaths = mutableListOf() val embeddedModulePaths = mutableListOf() @@ -118,8 +123,21 @@ class AppManageViewModel : ViewModel() { } } Patcher.patch(logger, Patcher.Options(false, config, patchPaths, embeddedModulePaths)) - val (status, message) = LSPPackageManager.install() - if (status != PackageInstaller.STATUS_SUCCESS) throw RuntimeException(message) + if (!ShizukuApi.isPermissionGranted) { + val apkFiles = lspApp.targetApkFiles + if (apkFiles.isNullOrEmpty()){ + Log.e(TAG, "No patched APK files found") + throw RuntimeException("No patched APK files found") + } + if (apkFiles.size > 1) { + val success = installApks(lspApp, apkFiles) + } else { + installApk(lspApp, apkFiles.first()) + } + } else { + val (status, message) = LSPPackageManager.install() + if (status != PackageInstaller.STATUS_SUCCESS) throw RuntimeException(message) + } } } updateLoaderState = ProcessingState.Done(result) diff --git a/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt b/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt index 5830b7920..fd61d8ac9 100644 --- a/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt +++ b/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt @@ -79,6 +79,12 @@ object LSPPackageManager { } } + suspend fun cleanExternalTmpApkDir(){ + withContext(Dispatchers.IO) { + lspApp.externalCacheDir?.listFiles()?.forEach(File::delete) + } + } + suspend fun install(): Pair { Log.i(TAG, "Perform install patched apks") var status = PackageInstaller.STATUS_FAILURE diff --git a/manager/src/main/res/xml/file_paths.xml b/manager/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..b8ff724e0 --- /dev/null +++ b/manager/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file