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]
room = "2.7.2"
accompanist = "0.36.0"
compose-destinations = "1.11.8"
compose-destinations = "1.11.7"
shizuku = "13.1.5"
hiddenapi = "4.4.0"
compose-bom = "2025.08.01"
compose-bom = "2025.08.00"
kotlin = "2.2.10"
ksp = "2.2.10-2.0.2"
commons-io = "2.20.0"
beust-jcommander = "1.82"
google-gson = "2.13.1"
material = "1.12.0"
core-ktx = "1.16.0"
[plugins]

View File

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

View File

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

View File

@ -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<File>()
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}")
}
}

View File

@ -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<NewPatchViewModel>()
@ -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<NewPatchViewModel.InstallMethod?>(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),
@ -468,6 +486,8 @@ private fun DoPatchBody(modifier: Modifier, navigator: DestinationsNavigator) {
} 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 +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,11 +195,8 @@ 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))
}
}
}
)

View File

@ -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>(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
}
enum class InstallMethod {
SYSTEM, SHIZUKU
}
sealed class ViewAction {
object DoneInit : 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.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
@ -89,7 +91,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<String>()
val embeddedModulePaths = mutableListOf<String>()
@ -121,8 +126,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)

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?> {
Log.i(TAG, "Perform install patched apks")
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>