Enhance installation and cleanup processes

Co-Authored-By: xihan123 <srxqzxs@vip.qq.com>
This commit is contained in:
NkBe 2025-09-06 22:42:02 +08:00
parent c0aecf9e33
commit 0d55e2e602
No known key found for this signature in database
GPG Key ID: 75EF144ED8F4D7B8
11 changed files with 339 additions and 14 deletions

View File

@ -1,16 +1,15 @@
[versions] [versions]
room = "2.7.2" room = "2.7.2"
accompanist = "0.36.0" accompanist = "0.36.0"
compose-destinations = "1.11.8" compose-destinations = "1.11.7"
shizuku = "13.1.5" shizuku = "13.1.5"
hiddenapi = "4.4.0" hiddenapi = "4.4.0"
compose-bom = "2025.08.01" compose-bom = "2025.08.00"
kotlin = "2.2.10" kotlin = "2.2.10"
ksp = "2.2.10-2.0.2" ksp = "2.2.10-2.0.2"
commons-io = "2.20.0" commons-io = "2.20.0"
beust-jcommander = "1.82" beust-jcommander = "1.82"
google-gson = "2.13.1" google-gson = "2.13.1"
material = "1.12.0"
core-ktx = "1.16.0" core-ktx = "1.16.0"
[plugins] [plugins]

View File

@ -49,6 +49,14 @@
</intent-filter> </intent-filter>
</activity-alias> </activity-alias>
<receiver
android:name=".ui.util.InstallResultReceiver"
android:exported="true">
<intent-filter>
<action android:name="${applicationId}.INSTALL_STATUS" />
</intent-filter>
</receiver>
<service <service
android:name=".manager.ModuleService" android:name=".manager.ModuleService"

View File

@ -19,6 +19,7 @@ class LSPApplication : Application() {
lateinit var prefs: SharedPreferences lateinit var prefs: SharedPreferences
lateinit var tmpApkDir: File lateinit var tmpApkDir: File
var targetApkFiles: ArrayList<File>? = null
val globalScope = CoroutineScope(Dispatchers.Default) val globalScope = CoroutineScope(Dispatchers.Default)
override fun onCreate() { override fun onCreate() {

View File

@ -10,6 +10,7 @@ import org.lsposed.lspatch.share.Constants
import org.lsposed.lspatch.share.PatchConfig import org.lsposed.lspatch.share.PatchConfig
import org.lsposed.patch.LSPatch import org.lsposed.patch.LSPatch
import org.lsposed.patch.util.Logger import org.lsposed.patch.util.Logger
import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Collections.addAll import java.util.Collections.addAll
@ -52,6 +53,8 @@ object Patcher {
root.listFiles().forEach { root.listFiles().forEach {
if (it.name?.endsWith(Constants.PATCH_FILE_SUFFIX) == true) it.delete() if (it.name?.endsWith(Constants.PATCH_FILE_SUFFIX) == true) it.delete()
} }
lspApp.targetApkFiles?.clear()
val apkFileList = arrayListOf<File>()
lspApp.tmpApkDir.walk() lspApp.tmpApkDir.walk()
.filter { it.name.endsWith(Constants.PATCH_FILE_SUFFIX) } .filter { it.name.endsWith(Constants.PATCH_FILE_SUFFIX) }
.forEach { apk -> .forEach { apk ->
@ -59,12 +62,16 @@ object Patcher {
?: throw IOException("Failed to create output file") ?: throw IOException("Failed to create output file")
val output = lspApp.contentResolver.openOutputStream(file.uri) val output = lspApp.contentResolver.openOutputStream(file.uri)
?: throw IOException("Failed to open output stream") ?: throw IOException("Failed to open output stream")
val apkFile = File(lspApp.externalCacheDir, apk.name)
apk.copyTo(apkFile, overwrite = true)
apkFileList.add(apkFile)
output.use { output.use {
apk.inputStream().use { input -> apk.inputStream().use { input ->
input.copyTo(output) input.copyTo(output)
} }
} }
} }
lspApp.targetApkFiles = apkFileList
logger.i("Patched files are saved to ${root.uri.lastPathSegment}") logger.i("Patched files are saved to ${root.uri.lastPathSegment}")
} }
} }

View File

@ -1,10 +1,14 @@
package org.lsposed.lspatch.ui.page package org.lsposed.lspatch.ui.page
import android.annotation.SuppressLint
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.IntentFilter
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.os.Build
import android.util.Log import android.util.Log
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -28,10 +32,14 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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 androidx.lifecycle.viewmodel.compose.viewModel
import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator 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.SettingsCheckBox
import org.lsposed.lspatch.ui.component.settings.SettingsItem import org.lsposed.lspatch.ui.component.settings.SettingsItem
import org.lsposed.lspatch.ui.page.destinations.SelectAppsScreenDestination 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.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.isScrolledToEnd
import org.lsposed.lspatch.ui.util.lastItemIndex 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
import org.lsposed.lspatch.ui.viewmodel.NewPatchViewModel.PatchState import org.lsposed.lspatch.ui.viewmodel.NewPatchViewModel.PatchState
import org.lsposed.lspatch.ui.viewmodel.NewPatchViewModel.ViewAction import org.lsposed.lspatch.ui.viewmodel.NewPatchViewModel.ViewAction
@ -377,6 +390,7 @@ private fun PatchOptionsBody(modifier: Modifier, onAddEmbed: () -> Unit) {
} }
} }
@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable @Composable
private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) { private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) {
val viewModel = viewModel<NewPatchViewModel>() val viewModel = viewModel<NewPatchViewModel>()
@ -435,10 +449,9 @@ private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) {
val installSuccessfully = stringResource(R.string.patch_install_successfully) val installSuccessfully = stringResource(R.string.patch_install_successfully)
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 installing by remember { mutableStateOf(false) } var installation by remember { mutableStateOf<NewPatchViewModel.InstallMethod?>(null) }
if (installing) InstallDialog(viewModel.patchApp) { status, message -> val onFinish: (Int, String?) -> Unit = { status, message ->
scope.launch { scope.launch {
installing = false
if (status == PackageInstaller.STATUS_SUCCESS) { if (status == PackageInstaller.STATUS_SUCCESS) {
lspApp.globalScope.launch { snackbarHost.showSnackbar(installSuccessfully) } lspApp.globalScope.launch { snackbarHost.showSnackbar(installSuccessfully) }
navigator.navigateUp() 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)) { Row(Modifier.padding(top = 12.dp)) {
Button( Button(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -468,6 +486,8 @@ private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) {
} else { } else {
installing = true 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)) } content = { Text(stringResource(R.string.install)) }
) )
@ -572,3 +592,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)) }
)
}
}

View File

@ -195,9 +195,6 @@ fun AppManageBody(
onClick = { onClick = {
expanded = false expanded = false
scope.launch { 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))
} }
} }

View File

@ -1,6 +1,22 @@
package org.lsposed.lspatch.ui.util 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.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 val LazyListState.lastVisibleItemIndex
get() = layoutInfo.visibleItemsInfo.lastOrNull()?.index get() = layoutInfo.visibleItemsInfo.lastOrNull()?.index
@ -10,3 +26,134 @@ val LazyListState.lastItemIndex
val LazyListState.isScrolledToEnd val LazyListState.isScrolledToEnd
get() = lastVisibleItemIndex == lastItemIndex 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>(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<File>): 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
}
}
}

View File

@ -24,6 +24,10 @@ class NewPatchViewModel : ViewModel() {
INIT, SELECTING, CONFIGURING, PATCHING, FINISHED, ERROR INIT, SELECTING, CONFIGURING, PATCHING, FINISHED, ERROR
} }
enum class InstallMethod {
SYSTEM, SHIZUKU
}
sealed class ViewAction { sealed class ViewAction {
object DoneInit : ViewAction() object DoneInit : ViewAction()
data class ConfigurePatch(val app: AppInfo) : ViewAction() data class ConfigurePatch(val app: AppInfo) : ViewAction()

View File

@ -17,6 +17,8 @@ import org.lsposed.lspatch.Patcher
import org.lsposed.lspatch.lspApp import org.lsposed.lspatch.lspApp
import org.lsposed.lspatch.share.Constants import org.lsposed.lspatch.share.Constants
import org.lsposed.lspatch.share.PatchConfig 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.ui.viewstate.ProcessingState
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
@ -89,7 +91,10 @@ class AppManageViewModel : ViewModel() {
updateLoaderState = ProcessingState.Processing updateLoaderState = ProcessingState.Processing
val result = runCatching { val result = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
LSPPackageManager.cleanTmpApkDir() LSPPackageManager.apply {
cleanTmpApkDir()
cleanExternalTmpApkDir()
}
val apkPaths = listOf(appInfo.app.sourceDir) + (appInfo.app.splitSourceDirs ?: emptyArray()) val apkPaths = listOf(appInfo.app.sourceDir) + (appInfo.app.splitSourceDirs ?: emptyArray())
val patchPaths = mutableListOf<String>() val patchPaths = mutableListOf<String>()
val embeddedModulePaths = mutableListOf<String>() val embeddedModulePaths = mutableListOf<String>()
@ -121,10 +126,23 @@ class AppManageViewModel : ViewModel() {
} }
} }
Patcher.patch(logger, Patcher.Options(false, config, patchPaths, embeddedModulePaths)) Patcher.patch(logger, Patcher.Options(false, config, patchPaths, embeddedModulePaths))
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() val (status, message) = LSPPackageManager.install()
if (status != PackageInstaller.STATUS_SUCCESS) throw RuntimeException(message) if (status != PackageInstaller.STATUS_SUCCESS) throw RuntimeException(message)
} }
} }
}
updateLoaderState = ProcessingState.Done(result) updateLoaderState = ProcessingState.Done(result)
} }

View File

@ -79,6 +79,12 @@ object LSPPackageManager {
} }
} }
suspend fun cleanExternalTmpApkDir(){
withContext(Dispatchers.IO) {
lspApp.externalCacheDir?.listFiles()?.forEach(File::delete)
}
}
suspend fun install(): Pair<Int, String?> { suspend fun install(): Pair<Int, String?> {
Log.i(TAG, "Perform install patched apks") Log.i(TAG, "Perform install patched apks")
var status = PackageInstaller.STATUS_FAILURE var status = PackageInstaller.STATUS_FAILURE

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_files" path="." />
<files-path name="files" path="." />
</paths>