diff --git a/gradle.properties b/gradle.properties index 387da3961..e238f4e45 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,5 @@ android.experimental.enableNewResourceShrinker.preciseShrinking=true android.enableAppCompileTimeRClass=true android.useAndroidX=true +org.gradle.parallel=true +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 diff --git a/manager/build.gradle.kts b/manager/build.gradle.kts index 6a0f87876..3035c2025 100644 --- a/manager/build.gradle.kts +++ b/manager/build.gradle.kts @@ -84,6 +84,8 @@ afterEvaluate { dependencies { implementation(projects.patch) + implementation(projects.apkzlib) + implementation("vector:axml") implementation("vector:daemon-service") implementation(projects.share.android) implementation(projects.share.java) diff --git a/manager/src/main/AndroidManifest.xml b/manager/src/main/AndroidManifest.xml index f3d3f14dd..27f92b4bc 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" /> + - if (uri == null) return@rememberLauncherForActivityResult - context.contentResolver.openInputStream(uri).use { input -> - MyKeyStore.tmpFile.outputStream().use { output -> - input?.copyTo(output) + path = uri?.toString()?.substringAfterLast('/') ?: "" + uri?.let { + context.contentResolver.openInputStream(it)?.use { input -> + MyKeyStore.tmpFile.outputStream().use { output -> input.copyTo(output) } } } - path = uri.path ?: "" } AlertDialog( - onDismissRequest = { expanded = false; showDialog = false }, + onDismissRequest = { showDialog = false; expanded = false }, confirmButton = { TextButton( content = { Text(stringResource(android.R.string.ok)) }, onClick = { - wrongKeystore = false wrongPassword = false wrongAliasName = false wrongAliasPassword = false - - if (path.isEmpty()) { - wrongKeystore = true - return@TextButton - } - val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + wrongKeystore = false try { - MyKeyStore.tmpFile.inputStream().use { input -> - keyStore.load(input, password.toCharArray()) + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + MyKeyStore.tmpFile.inputStream().use { + keyStore.load(it, password.toCharArray()) } + val entry = keyStore.getEntry(alias, KeyStore.PasswordProtection(aliasPassword.toCharArray())) + if (entry == null) wrongAliasName = true } catch (e: IOException) { - wrongKeystore = true - if (e.message == "KeyStore integrity check failed.") { - wrongPassword = true - } - return@TextButton - } - if (!keyStore.containsAlias(alias)) { - wrongAliasName = true - return@TextButton - } - try { - keyStore.getKey(alias, aliasPassword.toCharArray()) + wrongPassword = true } catch (e: GeneralSecurityException) { + wrongKeystore = true + } catch (e: Exception) { wrongAliasPassword = true - return@TextButton } + if (wrongPassword || wrongAliasName || wrongAliasPassword || wrongKeystore) return@TextButton scope.launch { MyKeyStore.setCustom(password, alias, aliasPassword) } expanded = false @@ -240,3 +238,252 @@ private fun DetailPatchLogs() { title = stringResource(R.string.settings_detail_patch_logs) ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HidePackageName() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var showDialog by remember { mutableStateOf(false) } + var packageName by rememberSaveable { mutableStateOf(PackageNameValidator.randomPackageName()) } + var running by remember { mutableStateOf(false) } + var statusText by remember { mutableStateOf(null) } + var statusError by remember { mutableStateOf(false) } + + SettingsItem( + modifier = Modifier.clickable(enabled = !running) { showDialog = true }, + icon = Icons.Outlined.VisibilityOff, + title = stringResource(R.string.settings_cloak), + desc = stringResource(R.string.settings_cloak_desc, context.packageName) + ) + + if (showDialog) { + AlertDialog( + onDismissRequest = { if (!running) showDialog = false }, + title = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.settings_cloak_dialog_title), + textAlign = TextAlign.Center + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(bottom = 8.dp), + text = stringResource(R.string.settings_cloak_warning) + ) + OutlinedTextField( + value = packageName, + onValueChange = { packageName = it.trim() }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.settings_cloak_package)) }, + singleLine = true, + enabled = !running, + isError = packageName.isNotEmpty() && !PackageNameValidator.isValid(packageName) + ) + TextButton( + onClick = { packageName = PackageNameValidator.randomPackageName() }, + enabled = !running + ) { + Text(stringResource(R.string.settings_cloak_randomize)) + } + statusText?.let { + Text( + modifier = Modifier.padding(top = 8.dp), + text = it, + color = if (statusError) MaterialTheme.colorScheme.error else Color.Unspecified + ) + } + if (running) { + Row( + modifier = Modifier.padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.padding(end = 12.dp)) + Text(stringResource(R.string.settings_cloak_running)) + } + } + } + }, + confirmButton = { + TextButton( + enabled = !running, + onClick = { + when { + !PackageNameValidator.isValid(packageName) -> { + statusError = true + statusText = context.getString(R.string.settings_cloak_invalid) + } + packageName == context.packageName -> { + statusError = true + statusText = context.getString(R.string.settings_cloak_same) + } + !ShizukuApi.isPermissionGranted -> { + statusError = true + statusText = context.getString(R.string.settings_cloak_need_shizuku) + } + Configs.storageDirectory.isNullOrEmpty() -> { + statusError = true + statusText = context.getString(R.string.settings_cloak_need_storage) + } + else -> { + running = true + statusError = false + statusText = null + scope.launch { + ManagerCloakFlow.run(packageName) { progress -> + when (progress) { + is ManagerCloakFlow.Progress.Message -> { + statusText = progress.text + statusError = false + } + is ManagerCloakFlow.Progress.Success -> { + running = false + statusError = false + statusText = context.getString( + R.string.settings_cloak_success, + progress.newPackageName + ) + showDialog = false + } + is ManagerCloakFlow.Progress.Error -> { + running = false + statusError = true + statusText = context.getString( + R.string.settings_cloak_failed, + progress.message + ) + } + } + } + } + } + } + } + ) { + Text(stringResource(R.string.settings_cloak_confirm)) + } + }, + dismissButton = { + TextButton( + enabled = !running, + onClick = { showDialog = false } + ) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RevertToOriginal() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var showDialog by remember { mutableStateOf(false) } + var running by remember { mutableStateOf(false) } + var statusText by remember { mutableStateOf(null) } + var statusError by remember { mutableStateOf(false) } + + SettingsItem( + modifier = Modifier.clickable(enabled = !running) { showDialog = true }, + icon = Icons.Outlined.Restore, + title = stringResource(R.string.settings_revert), + desc = stringResource(R.string.settings_revert_desc, context.packageName) + ) + + if (showDialog) { + AlertDialog( + onDismissRequest = { if (!running) showDialog = false }, + title = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.settings_revert_dialog_title), + textAlign = TextAlign.Center + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(bottom = 8.dp), + text = stringResource(R.string.settings_revert_warning) + ) + statusText?.let { + Text( + modifier = Modifier.padding(top = 8.dp), + text = it, + color = if (statusError) MaterialTheme.colorScheme.error else Color.Unspecified + ) + } + if (running) { + Row( + modifier = Modifier.padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.padding(end = 12.dp)) + Text(stringResource(R.string.settings_cloak_running)) + } + } + } + }, + confirmButton = { + TextButton( + enabled = !running, + onClick = { + when { + !ShizukuApi.isPermissionGranted -> { + statusError = true + statusText = context.getString(R.string.settings_cloak_need_shizuku) + } + Configs.storageDirectory.isNullOrEmpty() -> { + statusError = true + statusText = context.getString(R.string.settings_cloak_need_storage) + } + else -> { + running = true + statusError = false + statusText = null + scope.launch { + ManagerCloakFlow.revertToOriginal { progress -> + when (progress) { + is ManagerCloakFlow.Progress.Message -> { + statusText = progress.text + statusError = false + } + is ManagerCloakFlow.Progress.Success -> { + running = false + statusError = false + statusText = context.getString(R.string.settings_revert_success) + showDialog = false + } + is ManagerCloakFlow.Progress.Error -> { + running = false + statusError = true + statusText = context.getString( + R.string.settings_cloak_failed, + progress.message + ) + } + } + } + } + } + } + } + ) { + Text(stringResource(R.string.settings_revert_confirm)) + } + }, + dismissButton = { + TextButton( + enabled = !running, + onClick = { showDialog = false } + ) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } +} 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..e6e8585cc 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 @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch import org.lsposed.lspatch.Patcher +import org.lsposed.lspatch.lspApp import org.lsposed.lspatch.share.PatchConfig import org.lsposed.lspatch.util.LSPPackageManager import org.lsposed.lspatch.util.LSPPackageManager.AppInfo @@ -92,7 +93,15 @@ class NewPatchViewModel : ViewModel() { if (useManager) embeddedModules = emptyList() patchOptions = Patcher.Options( injectDex = injectDex, - config = PatchConfig(useManager, debuggable, overrideVersionCode, sigBypassLevel, null, null), + config = PatchConfig( + useManager, + debuggable, + overrideVersionCode, + sigBypassLevel, + null, + null, + if (useManager) lspApp.packageName else null + ), apkPaths = listOf(patchApp.app.sourceDir) + (patchApp.app.splitSourceDirs ?: emptyArray()), embeddedModules = embeddedModules.flatMap { listOf(it.app.sourceDir) + (it.app.splitSourceDirs ?: emptyArray()) } ) 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..9ca18e4da 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 @@ -117,7 +117,16 @@ class AppManageViewModel : ViewModel() { } } } - Patcher.patch(logger, Patcher.Options(false, config, patchPaths, embeddedModulePaths)) + val updatedConfig = PatchConfig( + config.useManager, + config.debuggable, + config.overrideVersionCode, + config.sigBypassLevel, + config.originalSignature, + config.appComponentFactory, + if (config.useManager) lspApp.packageName else null + ) + Patcher.patch(logger, Patcher.Options(false, updatedConfig, patchPaths, embeddedModulePaths)) val (status, message) = LSPPackageManager.install() if (status != PackageInstaller.STATUS_SUCCESS) throw RuntimeException(message) } diff --git a/manager/src/main/java/org/lsposed/lspatch/util/GithubReleaseDownloader.kt b/manager/src/main/java/org/lsposed/lspatch/util/GithubReleaseDownloader.kt new file mode 100644 index 000000000..0565776d2 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/util/GithubReleaseDownloader.kt @@ -0,0 +1,126 @@ +package org.lsposed.lspatch.util + +import android.util.Log +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +object GithubReleaseDownloader { + + private const val TAG = "GithubReleaseDownloader" + private const val RELEASES_API = + "https://api.github.com/repos/JingMatrix/LSPatch/releases/latest" + private const val USER_AGENT = "LSPatch-Manager" + + data class Result(val tagName: String, val assetName: String, val file: File) + + /** + * Downloads the latest manager APK from GitHub Releases. + * Prefers `manager.apk`, falls back to `manager-debug.apk`. + */ + fun downloadLatestManager(dest: File): Result { + dest.parentFile?.mkdirs() + if (dest.exists()) dest.delete() + + val releaseJson = httpGetString(RELEASES_API) + val root = JSONObject(releaseJson) + val tag = root.optString("tag_name", "unknown") + val assets = root.getJSONArray("assets") + + var preferredUrl: String? = null + var preferredName: String? = null + var fallbackUrl: String? = null + var fallbackName: String? = null + + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val name = asset.getString("name") + val url = asset.getString("browser_download_url") + when { + name.equals("manager.apk", ignoreCase = true) -> { + preferredUrl = url + preferredName = name + } + name.equals("manager-debug.apk", ignoreCase = true) -> { + fallbackUrl = url + fallbackName = name + } + } + } + + val downloadUrl = preferredUrl ?: fallbackUrl + ?: throw IllegalStateException("No manager APK found in latest GitHub release") + val assetName = preferredName ?: fallbackName!! + + Log.i(TAG, "Downloading $assetName ($tag) from $downloadUrl") + httpDownload(downloadUrl, dest) + if (!dest.isFile || dest.length() == 0L) { + throw IllegalStateException("Downloaded APK is empty") + } + return Result(tag, assetName, dest) + } + + private fun httpGetString(url: String): String { + val conn = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Accept", "application/vnd.github+json") + setRequestProperty("User-Agent", USER_AGENT) + connectTimeout = 30_000 + readTimeout = 60_000 + instanceFollowRedirects = true + } + try { + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + val body = stream?.bufferedReader()?.use { it.readText() }.orEmpty() + if (code !in 200..299) { + throw IllegalStateException("GitHub API HTTP $code: $body") + } + return body + } finally { + conn.disconnect() + } + } + + private fun httpDownload(url: String, dest: File) { + var current = url + // Follow a few redirects manually for CDN links if needed + repeat(5) { + val conn = (URL(current).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("User-Agent", USER_AGENT) + setRequestProperty("Accept", "application/octet-stream") + connectTimeout = 30_000 + readTimeout = 300_000 + instanceFollowRedirects = false + } + try { + val code = conn.responseCode + when (code) { + in 200..299 -> { + conn.inputStream.use { input -> + FileOutputStream(dest).use { output -> input.copyTo(output) } + } + return + } + HttpURLConnection.HTTP_MOVED_PERM, + HttpURLConnection.HTTP_MOVED_TEMP, + HttpURLConnection.HTTP_SEE_OTHER, + 307, 308 -> { + current = conn.getHeaderField("Location") + ?: throw IllegalStateException("Redirect without Location") + } + else -> { + val err = conn.errorStream?.bufferedReader()?.use { it.readText() } + throw IllegalStateException("Download HTTP $code: $err") + } + } + } finally { + conn.disconnect() + } + } + throw IllegalStateException("Too many redirects while downloading release APK") + } +} 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..e00e3360e 100644 --- a/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt +++ b/manager/src/main/java/org/lsposed/lspatch/util/LSPPackageManager.kt @@ -81,6 +81,28 @@ object LSPPackageManager { suspend fun install(): Pair { Log.i(TAG, "Perform install patched apks") + return withContext(Dispatchers.IO) { + runCatching { + val uri = Configs.storageDirectory?.toUri() ?: throw IOException("Uri is null") + val root = DocumentFile.fromTreeUri(lspApp, uri) ?: throw IOException("DocumentFile is null") + val files = root.listFiles().mapNotNull { file -> + if (file.name?.endsWith(Constants.PATCH_FILE_SUFFIX) != true) return@mapNotNull null + val input = lspApp.contentResolver.openInputStream(file.uri) + ?: throw IOException("Cannot open input stream") + val tmp = lspApp.tmpApkDir.resolve(file.name!!) + input.use { inp -> tmp.outputStream().use { out -> inp.copyTo(out) } } + tmp + } + if (files.isEmpty()) throw IOException("No patched apks to install") + installApks(files) + }.getOrElse { + PackageInstaller.STATUS_FAILURE to (it.message + "\n" + it.stackTraceToString()) + } + } + } + + suspend fun installApks(apks: List): Pair { + Log.i(TAG, "Perform install ${apks.size} apk(s)") var status = PackageInstaller.STATUS_FAILURE var message: String? = null withContext(Dispatchers.IO) { @@ -90,15 +112,10 @@ object LSPPackageManager { flags = flags or PackageManagerHidden.INSTALL_ALLOW_TEST or PackageManagerHidden.INSTALL_REPLACE_EXISTING Refine.unsafeCast(params).installFlags = flags ShizukuApi.createPackageInstallerSession(params).use { session -> - val uri = Configs.storageDirectory?.toUri() ?: throw IOException("Uri is null") - val root = DocumentFile.fromTreeUri(lspApp, uri) ?: throw IOException("DocumentFile is null") - root.listFiles().forEach { file -> - if (file.name?.endsWith(Constants.PATCH_FILE_SUFFIX) != true) return@forEach + apks.forEach { file -> Log.d(TAG, "Add ${file.name}") - val input = lspApp.contentResolver.openInputStream(file.uri) - ?: throw IOException("Cannot open input stream") - input.use { - session.openWrite(file.name!!, 0, input.available().toLong()).use { output -> + file.inputStream().use { input -> + session.openWrite(file.name, 0, file.length()).use { output -> input.copyTo(output) session.fsync(output) } diff --git a/manager/src/main/java/org/lsposed/lspatch/util/LocalAppsUpdater.kt b/manager/src/main/java/org/lsposed/lspatch/util/LocalAppsUpdater.kt new file mode 100644 index 000000000..06d25c965 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/util/LocalAppsUpdater.kt @@ -0,0 +1,101 @@ +package org.lsposed.lspatch.util + +import android.content.pm.PackageInstaller +import android.util.Base64 +import android.util.Log +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +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.util.LSPPackageManager.AppInfo +import org.lsposed.patch.util.Logger +import java.io.FileNotFoundException +import java.util.zip.ZipFile + +object LocalAppsUpdater { + + private const val TAG = "LocalAppsUpdater" + + private val logger = object : Logger() { + override fun d(msg: String) { + if (verbose) Log.d(TAG, msg) + } + + override fun i(msg: String) { + Log.i(TAG, msg) + } + + override fun e(msg: String) { + Log.e(TAG, msg) + } + } + + fun listLocalApps(): List> { + return LSPPackageManager.appList.mapNotNull { appInfo -> + appInfo.app.metaData?.getString("lspatch")?.let { + val json = Base64.decode(it, Base64.DEFAULT).toString(Charsets.UTF_8) + val config = Gson().fromJson(json, PatchConfig::class.java) + if (config.useManager) appInfo to config else null + } + } + } + + /** + * Re-patches every Local-mode app so their config points at [managerPackageName]. + */ + suspend fun updateAllForManager(managerPackageName: String) = withContext(Dispatchers.IO) { + val apps = listLocalApps() + Log.i(TAG, "Updating ${apps.size} Local apps for manager $managerPackageName") + for ((appInfo, config) in apps) { + updateOne(appInfo, config, managerPackageName) + } + } + + private suspend fun updateOne(appInfo: AppInfo, config: PatchConfig, managerPackageName: String) { + Log.i(TAG, "Update loader for ${appInfo.app.packageName}") + LSPPackageManager.cleanTmpApkDir() + val apkPaths = listOf(appInfo.app.sourceDir) + (appInfo.app.splitSourceDirs ?: emptyArray()) + val patchPaths = mutableListOf() + val embeddedModulePaths = mutableListOf() + for (apk in apkPaths) { + ZipFile(apk).use { zip -> + var entry = zip.getEntry(Constants.ORIGINAL_APK_ASSET_PATH) + if (entry == null) entry = zip.getEntry("assets/lspatch/origin_apk.bin") + if (entry == null) throw FileNotFoundException("Original apk entry not found for $apk") + zip.getInputStream(entry).use { input -> + val dst = lspApp.tmpApkDir.resolve(apk.substringAfterLast('/')) + patchPaths.add(dst.absolutePath) + dst.outputStream().use { output -> input.copyTo(output) } + } + } + } + ZipFile(appInfo.app.sourceDir).use { zip -> + zip.entries().iterator().forEach { entry -> + if (entry.name.startsWith(Constants.EMBEDDED_MODULES_ASSET_PATH)) { + val dst = lspApp.tmpApkDir.resolve(entry.name.substringAfterLast('/')) + embeddedModulePaths.add(dst.absolutePath) + zip.getInputStream(entry).use { input -> + dst.outputStream().use { output -> input.copyTo(output) } + } + } + } + } + val updated = PatchConfig( + config.useManager, + config.debuggable, + config.overrideVersionCode, + config.sigBypassLevel, + config.originalSignature, + config.appComponentFactory, + managerPackageName + ) + Patcher.patch(logger, Patcher.Options(false, updated, patchPaths, embeddedModulePaths)) + val (status, message) = LSPPackageManager.install() + if (status != PackageInstaller.STATUS_SUCCESS) { + throw RuntimeException("Failed to install updated ${appInfo.app.packageName}: $message") + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/util/ManagerCloak.kt b/manager/src/main/java/org/lsposed/lspatch/util/ManagerCloak.kt new file mode 100644 index 000000000..10ff61c88 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/util/ManagerCloak.kt @@ -0,0 +1,202 @@ +package org.lsposed.lspatch.util + +import android.util.Log +import com.android.tools.build.apkzlib.sign.SigningExtension +import com.android.tools.build.apkzlib.sign.SigningOptions +import com.android.tools.build.apkzlib.zip.AlignmentRules +import com.android.tools.build.apkzlib.zip.ZFile +import com.android.tools.build.apkzlib.zip.ZFileOptions +import com.wind.meditor.core.ManifestEditor +import com.wind.meditor.property.AttributeItem +import com.wind.meditor.property.ModificationProperty +import com.wind.meditor.utils.NodeValue +import org.lsposed.lspatch.config.Configs +import org.lsposed.lspatch.config.MyKeyStore +import org.lsposed.lspatch.lspApp +import org.lsposed.lspatch.share.Constants +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.InputStream +import java.security.KeyStore +import java.security.cert.X509Certificate +import java.util.zip.ZipFile + +object ManagerCloak { + + private const val TAG = "ManagerCloak" + private const val MANIFEST = "AndroidManifest.xml" + private const val RESOURCES_ARSC = "resources.arsc" + + // Android R+ requires resources.arsc STORED and 4-byte aligned. + private val zFileOptions = ZFileOptions().setAlignmentRule( + AlignmentRules.compose( + AlignmentRules.constantForSuffix(".so", 4096), + AlignmentRules.constantForSuffix(RESOURCES_ARSC, 4) + ) + ) + + /** + * Rewrites the current manager APK to [newPackageName], embeds optional migrate zip, + * and signs the result. Returns the signed APK file. + */ + fun cloak(newPackageName: String, migrateZip: File?, output: File): File { + val splits = lspApp.applicationInfo.splitSourceDirs + if (!splits.isNullOrEmpty()) { + throw IllegalStateException("Split APK managers cannot be cloaked; install a single-APK build") + } + + val src = File(lspApp.applicationInfo.sourceDir) + if (!src.isFile) throw IllegalStateException("Manager source APK not found") + + return rebuild( + src = src, + output = output, + migrateZip = migrateZip, + rewritePackageTo = newPackageName + ) + } + + /** + * Takes a stock manager APK (e.g. from GitHub), embeds migrate data, and re-signs it. + * Package id is forced to [Constants.MANAGER_PACKAGE_NAME]. + */ + fun prepareStockWithMigrate(src: File, migrateZip: File?, output: File): File { + if (!src.isFile) throw IllegalStateException("Source APK not found") + return rebuild( + src = src, + output = output, + migrateZip = migrateZip, + rewritePackageTo = Constants.MANAGER_PACKAGE_NAME + ) + } + + private fun rebuild( + src: File, + output: File, + migrateZip: File?, + rewritePackageTo: String + ): File { + output.parentFile?.mkdirs() + if (output.exists()) output.delete() + + Log.i(TAG, "Rebuilding manager ${lspApp.packageName} -> $rewritePackageTo") + + ZFile.openReadWrite(output, zFileOptions).use { dst -> + registerSigner(dst) + + ZipFile(src).use { zip -> + for (entry in zip.entries()) { + if (entry.isDirectory) continue + val name = entry.name + if (isSignatureFile(name)) continue + // Drop any previous migrate payload; we add a fresh one below. + if (name == Constants.MIGRATE_ASSET_PATH) continue + + zip.getInputStream(entry).use { input -> + when { + name == MANIFEST -> { + val modified = modifyManifest(input, rewritePackageTo) + ByteArrayInputStream(modified).use { + dst.add(name, it, true) + } + } + name == RESOURCES_ARSC || name.endsWith(".so") -> { + dst.add(name, input, false) + } + else -> { + dst.add(name, input, entry.method != java.util.zip.ZipEntry.STORED) + } + } + } + } + } + + if (migrateZip != null && migrateZip.isFile) { + FileInputStream(migrateZip).use { + dst.add(Constants.MIGRATE_ASSET_PATH, it, true) + } + } + dst.realign() + } + + return output + } + + private fun isSignatureFile(name: String): Boolean { + if (!name.startsWith("META-INF/")) return false + return name.endsWith(".SF") || + name.endsWith(".MF") || + name.endsWith(".RSA") || + name.endsWith(".DSA") || + name.endsWith(".EC") || + name.endsWith(".IMG") + } + + private fun modifyManifest(input: InputStream, newPackageName: String): ByteArray { + val oldPackages = listOf( + lspApp.packageName, + Constants.MANAGER_PACKAGE_NAME + ).distinct() + + fun remapPrefixed(value: String): String { + for (old in oldPackages) { + if (value == old || value.startsWith("$old.")) { + return newPackageName + value.removePrefix(old) + } + } + return value + } + + val property = ModificationProperty() + property.addManifestAttribute( + AttributeItem(NodeValue.Manifest.PACKAGE, newPackageName).setNamespace(null) + ) + // Rewrite app-scoped custom permissions (e.g. DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION) + // so install does not clash with the still-installed original package. + property.setPermissionMapper { _, permission -> remapPrefixed(permission) } + property.setAuthorityMapper { authority -> remapPrefixed(authority) } + + val os = ByteArrayOutputStream() + ManifestEditor(input, os, property).processManifest() + return os.toByteArray() + } + + private fun registerSigner(dst: ZFile) { + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + if (MyKeyStore.useDefault) { + val stream = lspApp.classLoader.getResourceAsStream("assets/keystore") + ?: throw IllegalStateException("Default keystore missing") + stream.use { keyStore.load(it, "123456".toCharArray()) } + val entry = keyStore.getEntry("key0", KeyStore.PasswordProtection("123456".toCharArray())) + as KeyStore.PrivateKeyEntry + @Suppress("UNCHECKED_CAST") + val certs = entry.certificateChain as Array + SigningExtension( + SigningOptions.builder() + .setMinSdkVersion(28) + .setV2SigningEnabled(true) + .setCertificates(*certs) + .setKey(entry.privateKey) + .build() + ).register(dst) + } else { + FileInputStream(MyKeyStore.file).use { keyStore.load(it, Configs.keyStorePassword.toCharArray()) } + val entry = keyStore.getEntry( + Configs.keyStoreAlias, + KeyStore.PasswordProtection(Configs.keyStoreAliasPassword.toCharArray()) + ) as KeyStore.PrivateKeyEntry + @Suppress("UNCHECKED_CAST") + val certs = entry.certificateChain as Array + SigningExtension( + SigningOptions.builder() + .setMinSdkVersion(28) + .setV2SigningEnabled(true) + .setCertificates(*certs) + .setKey(entry.privateKey) + .build() + ).register(dst) + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/util/ManagerCloakFlow.kt b/manager/src/main/java/org/lsposed/lspatch/util/ManagerCloakFlow.kt new file mode 100644 index 000000000..ef005364b --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/util/ManagerCloakFlow.kt @@ -0,0 +1,173 @@ +package org.lsposed.lspatch.util + +import android.content.Intent +import android.content.pm.PackageInstaller +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.lsposed.lspatch.lspApp +import org.lsposed.lspatch.share.Constants + +object ManagerCloakFlow { + + private const val TAG = "ManagerCloakFlow" + + sealed class Progress { + data class Message(val text: String) : Progress() + data class Success(val newPackageName: String) : Progress() + data class Error(val message: String) : Progress() + } + + /** + * Full cloak flow: export data → rebuild APK → install → update Local apps → launch new → uninstall old. + * [onProgress] is always invoked on the Main thread. + */ + suspend fun run( + newPackageName: String, + onProgress: (Progress) -> Unit + ) { + suspend fun emit(progress: Progress) = withContext(Dispatchers.Main.immediate) { + onProgress(progress) + } + + val oldPackage = lspApp.packageName + try { + if (!PackageNameValidator.isValid(newPackageName)) { + emit(Progress.Error("Invalid package name")) + return + } + if (newPackageName == oldPackage) { + emit(Progress.Error("Package name is unchanged")) + return + } + if (!ShizukuApi.isPermissionGranted) { + emit(Progress.Error("Shizuku is required")) + return + } + + withContext(Dispatchers.IO) { + emit(Progress.Message("Preparing migrate data…")) + val workDir = lspApp.cacheDir.resolve("cloak").also { + it.deleteRecursively() + it.mkdirs() + } + val migrateZip = ManagerMigrate.createMigrateZip(lspApp, workDir.resolve("migrate.zip")) + + emit(Progress.Message("Rebuilding manager APK…")) + val cloakedApk = ManagerCloak.cloak( + newPackageName, + migrateZip, + workDir.resolve("manager-$newPackageName.apk") + ) + + emit(Progress.Message("Installing cloaked manager…")) + val (installStatus, installMessage) = LSPPackageManager.installApks(listOf(cloakedApk)) + if (installStatus != PackageInstaller.STATUS_SUCCESS) { + emit(Progress.Error("Install failed: $installMessage")) + return@withContext + } + + emit(Progress.Message("Updating Local patched apps…")) + LocalAppsUpdater.updateAllForManager(newPackageName) + + emit(Progress.Message("Launching new manager…")) + val launch = LSPPackageManager.getLaunchIntentForPackage(newPackageName) + if (launch != null) { + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + withContext(Dispatchers.Main) { + lspApp.startActivity(launch) + } + } + + emit(Progress.Message("Removing old package…")) + val (uninstallStatus, uninstallMessage) = LSPPackageManager.uninstall(oldPackage) + if (uninstallStatus != PackageInstaller.STATUS_SUCCESS) { + Log.w(TAG, "Uninstall old package failed: $uninstallMessage") + } + + workDir.deleteRecursively() + emit(Progress.Success(newPackageName)) + } + } catch (t: Throwable) { + Log.e(TAG, "Cloak failed", t) + emit(Progress.Error(t.message ?: t.toString())) + } + } + + /** + * Revert a cloaked manager back to [Constants.MANAGER_PACKAGE_NAME] using the latest + * GitHub release APK, then uninstall this (random-package) app. + */ + suspend fun revertToOriginal(onProgress: (Progress) -> Unit) { + suspend fun emit(progress: Progress) = withContext(Dispatchers.Main.immediate) { + onProgress(progress) + } + + val currentPackage = lspApp.packageName + val original = Constants.MANAGER_PACKAGE_NAME + try { + if (currentPackage == original) { + emit(Progress.Error("Already using the original package name")) + return + } + if (!ShizukuApi.isPermissionGranted) { + emit(Progress.Error("Shizuku is required")) + return + } + + withContext(Dispatchers.IO) { + val workDir = lspApp.cacheDir.resolve("cloak-revert").also { + it.deleteRecursively() + it.mkdirs() + } + + emit(Progress.Message("Downloading latest manager from GitHub…")) + val downloaded = GithubReleaseDownloader.downloadLatestManager( + workDir.resolve("manager-github.apk") + ) + emit(Progress.Message("Downloaded ${downloaded.assetName} (${downloaded.tagName})")) + + emit(Progress.Message("Preparing migrate data…")) + val migrateZip = ManagerMigrate.createMigrateZip(lspApp, workDir.resolve("migrate.zip")) + + emit(Progress.Message("Preparing original package APK…")) + val stockApk = ManagerCloak.prepareStockWithMigrate( + downloaded.file, + migrateZip, + workDir.resolve("manager-original.apk") + ) + + emit(Progress.Message("Installing original manager…")) + val (installStatus, installMessage) = LSPPackageManager.installApks(listOf(stockApk)) + if (installStatus != PackageInstaller.STATUS_SUCCESS) { + emit(Progress.Error("Install failed: $installMessage")) + return@withContext + } + + emit(Progress.Message("Updating Local patched apps…")) + LocalAppsUpdater.updateAllForManager(original) + + emit(Progress.Message("Launching original manager…")) + val launch = LSPPackageManager.getLaunchIntentForPackage(original) + if (launch != null) { + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + withContext(Dispatchers.Main) { + lspApp.startActivity(launch) + } + } + + emit(Progress.Message("Removing cloaked package…")) + val (uninstallStatus, uninstallMessage) = LSPPackageManager.uninstall(currentPackage) + if (uninstallStatus != PackageInstaller.STATUS_SUCCESS) { + Log.w(TAG, "Uninstall cloaked package failed: $uninstallMessage") + } + + workDir.deleteRecursively() + emit(Progress.Success(original)) + } + } catch (t: Throwable) { + Log.e(TAG, "Revert failed", t) + emit(Progress.Error(t.message ?: t.toString())) + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/util/ManagerMigrate.kt b/manager/src/main/java/org/lsposed/lspatch/util/ManagerMigrate.kt new file mode 100644 index 000000000..d800c93de --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/util/ManagerMigrate.kt @@ -0,0 +1,146 @@ +package org.lsposed.lspatch.util + +import android.content.Context +import android.util.Log +import org.lsposed.lspatch.config.Configs +import org.lsposed.lspatch.config.MyKeyStore +import org.lsposed.lspatch.share.Constants +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +/** + * Embeds / restores manager settings across a package-name change. + * Migrate payload is written into the cloaked APK as [Constants.MIGRATE_ASSET_PATH] + * so the new package can import without Shizuku on first launch. + */ +object ManagerMigrate { + + private const val TAG = "ManagerMigrate" + private const val MARKER = ".cloak_migrate_done" + private const val ENTRY_PREFS = "settings.xml" + private const val ENTRY_KEYSTORE = "keystore.bks" + private const val ENTRY_DB = "modules_config.db" + private const val ENTRY_DB_WAL = "modules_config.db-wal" + private const val ENTRY_DB_SHM = "modules_config.db-shm" + private const val ENTRY_STORAGE = "storage_directory.txt" + + fun createMigrateZip(context: Context, dest: File): File { + dest.parentFile?.mkdirs() + if (dest.exists()) dest.delete() + + runCatching { + val dbFile = context.getDatabasePath("modules_config.db") + if (dbFile.exists()) { + android.database.sqlite.SQLiteDatabase.openDatabase( + dbFile.path, + null, + android.database.sqlite.SQLiteDatabase.OPEN_READONLY + ).use { it.rawQuery("PRAGMA wal_checkpoint(FULL)", null).close() } + } + } + + ZipOutputStream(BufferedOutputStream(FileOutputStream(dest))).use { zos -> + fun putFile(entryName: String, file: File) { + if (!file.exists()) return + zos.putNextEntry(ZipEntry(entryName)) + FileInputStream(file).use { it.copyTo(zos) } + zos.closeEntry() + } + + putFile(ENTRY_PREFS, File(context.applicationInfo.dataDir, "shared_prefs/settings.xml")) + putFile(ENTRY_KEYSTORE, MyKeyStore.file) + putFile(ENTRY_DB, context.getDatabasePath("modules_config.db")) + putFile(ENTRY_DB_WAL, context.getDatabasePath("modules_config.db-wal")) + putFile(ENTRY_DB_SHM, context.getDatabasePath("modules_config.db-shm")) + + val storage = Configs.storageDirectory + if (!storage.isNullOrEmpty()) { + zos.putNextEntry(ZipEntry(ENTRY_STORAGE)) + zos.write(storage.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + } + return dest + } + + fun importIfNeeded(context: Context) { + val marker = File(context.filesDir, MARKER) + if (marker.exists()) return + + val apk = File(context.applicationInfo.sourceDir) + if (!apk.exists()) return + + try { + ZipFile(apk).use { zip -> + val entry = zip.getEntry(Constants.MIGRATE_ASSET_PATH) ?: run { + // Stock / non-cloaked install: mark done so we don't keep checking. + context.filesDir.mkdirs() + marker.createNewFile() + return + } + Log.i(TAG, "Importing migrate payload from manager APK") + var storageDir: String? = null + ZipInputStream(zip.getInputStream(entry)).use { zis -> + var zipEntry = zis.nextEntry + while (zipEntry != null) { + when (zipEntry.name) { + ENTRY_PREFS -> { + val out = File(context.applicationInfo.dataDir, "shared_prefs/settings.xml") + out.parentFile?.mkdirs() + FileOutputStream(out).use { zis.copyTo(it) } + } + ENTRY_KEYSTORE -> { + context.filesDir.mkdirs() + FileOutputStream(File(context.filesDir, "keystore.bks")).use { zis.copyTo(it) } + } + ENTRY_DB -> writeDb(context, "modules_config.db", zis) + ENTRY_DB_WAL -> writeDb(context, "modules_config.db-wal", zis) + ENTRY_DB_SHM -> writeDb(context, "modules_config.db-shm", zis) + ENTRY_STORAGE -> { + storageDir = zis.readBytes().toString(Charsets.UTF_8) + } + } + zis.closeEntry() + zipEntry = zis.nextEntry + } + } + // Ensure storage_directory is present even if prefs XML did not include it yet. + if (!storageDir.isNullOrEmpty()) { + val prefsFile = File(context.applicationInfo.dataDir, "shared_prefs/settings.xml") + if (!prefsFile.exists() || !prefsFile.readText().contains("storage_directory")) { + prefsFile.parentFile?.mkdirs() + val escaped = storageDir + .replace("&", "&") + .replace("\"", """) + .replace("<", "<") + .replace(">", ">") + prefsFile.writeText( + """ + + $escaped + +""" + ) + } + } + } + context.filesDir.mkdirs() + marker.createNewFile() + Log.i(TAG, "Migrate import completed") + } catch (t: Throwable) { + Log.e(TAG, "Failed to import migrate payload", t) + } + } + + private fun writeDb(context: Context, name: String, zis: ZipInputStream) { + val out = context.getDatabasePath(name) + out.parentFile?.mkdirs() + FileOutputStream(out).use { zis.copyTo(it) } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/util/PackageNameValidator.kt b/manager/src/main/java/org/lsposed/lspatch/util/PackageNameValidator.kt new file mode 100644 index 000000000..a6e4e6455 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/util/PackageNameValidator.kt @@ -0,0 +1,19 @@ +package org.lsposed.lspatch.util + +object PackageNameValidator { + + private val SEGMENT = Regex("^[a-zA-Z][a-zA-Z0-9_]*$") + + fun isValid(packageName: String): Boolean { + if (packageName.isBlank() || packageName.length > 255) return false + val parts = packageName.split('.') + if (parts.size < 2) return false + return parts.all { SEGMENT.matches(it) } + } + + fun randomPackageName(): String { + val alphabet = ('a'..'z') + fun word(len: Int) = (1..len).map { alphabet.random() }.joinToString("") + return "org.${word(8)}.${word(6)}" + } +} diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 27393557a..0a8d4adff 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -90,6 +90,26 @@ Wrong alias name Wrong alias password Detail patch logs + Hide package name + Current: %1$s\nChange the installed package id so detectors looking for org.lsposed.lspatch fail. Requires Shizuku. Local patched apps will be updated. + Change package name + New package name + Randomize + This installs a new copy of LSPatch Manager under a different package name, updates Local-mode apps, then uninstalls the current one. Integrated patches are unaffected. Shizuku must stay available. Grant Shizuku to the new package after install. + Enter a valid package name (e.g. org.example.app) + Package name is the same as the current one + Shizuku is required to change the package name + Configure storage directory first (used when re-patching Local apps) + Working… + Installed as %1$s. Open the new manager and grant Shizuku if prompted. + Failed: %1$s + Continue + Revert to original + Current: %1$s\nDownload the latest official manager from GitHub (org.lsposed.lspatch) and remove this cloaked install. + Revert to original package + Downloads the latest manager.apk from JingMatrix/LSPatch releases, installs it as org.lsposed.lspatch (settings and Local app links are restored), then uninstalls this app. Requires Shizuku and network. + Revert + Restored org.lsposed.lspatch. Grant Shizuku to it if prompted. Inject loader dex For applications with isolated services, such as the render engines of browsers, please turn on this option to ensure that they work properly. diff --git a/meta-loader/src/main/java/org/lsposed/lspatch/metaloader/LSPAppComponentFactoryStub.java b/meta-loader/src/main/java/org/lsposed/lspatch/metaloader/LSPAppComponentFactoryStub.java index 869437295..323fb0d84 100644 --- a/meta-loader/src/main/java/org/lsposed/lspatch/metaloader/LSPAppComponentFactoryStub.java +++ b/meta-loader/src/main/java/org/lsposed/lspatch/metaloader/LSPAppComponentFactoryStub.java @@ -60,6 +60,7 @@ private static void bootstrap() { String libName = archToLib.get(arch); boolean useManager = false; + String managerPackageName = Constants.MANAGER_PACKAGE_NAME; String soPath; try (var is = cl.getResourceAsStream(Constants.CONFIG_ASSET_PATH); @@ -69,7 +70,11 @@ private static void bootstrap() { var name = reader.nextName(); if (name.equals("useManager")) { useManager = reader.nextBoolean(); - break; + } else if (name.equals("managerPackageName")) { + var value = reader.nextString(); + if (value != null && !value.isEmpty()) { + managerPackageName = value; + } } else { reader.skipValue(); } @@ -77,13 +82,13 @@ private static void bootstrap() { } if (useManager) { - Log.i(TAG, "Bootstrap loader from manager"); + Log.i(TAG, "Bootstrap loader from manager: " + managerPackageName); var ipm = IPackageManager.Stub.asInterface(ServiceManager.getService("package")); ApplicationInfo manager; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - manager = (ApplicationInfo) HiddenApiBypass.invoke(IPackageManager.class, ipm, "getApplicationInfo", Constants.MANAGER_PACKAGE_NAME, 0L, Process.myUid() / 100000); + manager = (ApplicationInfo) HiddenApiBypass.invoke(IPackageManager.class, ipm, "getApplicationInfo", managerPackageName, 0L, Process.myUid() / 100000); } else { - manager = ipm.getApplicationInfo(Constants.MANAGER_PACKAGE_NAME, 0, Process.myUid() / 100000); + manager = ipm.getApplicationInfo(managerPackageName, 0, Process.myUid() / 100000); } try (var zip = new ZipFile(new File(manager.sourceDir)); var is = zip.getInputStream(zip.getEntry(Constants.LOADER_DEX_ASSET_PATH)); diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPApplication.java b/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPApplication.java index ced217ff9..c073ed26f 100644 --- a/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPApplication.java +++ b/patch-loader/src/main/java/org/lsposed/lspatch/loader/LSPApplication.java @@ -17,6 +17,7 @@ import org.lsposed.lspatch.loader.util.XLog; import org.lsposed.lspatch.service.LocalApplicationService; import org.lsposed.lspatch.service.RemoteApplicationService; +import org.lsposed.lspatch.share.Constants; import org.lsposed.lspd.core.Startup; import org.lsposed.lspd.service.ILSPApplicationService; import org.json.JSONObject; @@ -78,7 +79,10 @@ public static void onLoad() throws RemoteException, IOException { Log.d(TAG, "Initialize service client"); ILSPApplicationService service; if (config.optBoolean("useManager")) { - service = new RemoteApplicationService(context); + var managerPackage = config.optString("managerPackageName", Constants.MANAGER_PACKAGE_NAME); + if (managerPackage.isEmpty()) managerPackage = Constants.MANAGER_PACKAGE_NAME; + Log.i(TAG, "Manager package: " + managerPackage); + service = new RemoteApplicationService(context, managerPackage); } else { service = new LocalApplicationService(context); } diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/service/RemoteApplicationService.java b/patch-loader/src/main/java/org/lsposed/lspatch/service/RemoteApplicationService.java index ab3d027e9..f1a41e9b6 100644 --- a/patch-loader/src/main/java/org/lsposed/lspatch/service/RemoteApplicationService.java +++ b/patch-loader/src/main/java/org/lsposed/lspatch/service/RemoteApplicationService.java @@ -32,15 +32,17 @@ public class RemoteApplicationService implements ILSPApplicationService { private static final String TAG = "LSPatch"; - private static final String MODULE_SERVICE = Constants.MANAGER_PACKAGE_NAME + ".manager.ModuleService"; private volatile ILSPApplicationService service; @SuppressLint("DiscouragedPrivateApi") - public RemoteApplicationService(Context context) throws RemoteException { + public RemoteApplicationService(Context context, String managerPackageName) throws RemoteException { + var packageName = (managerPackageName == null || managerPackageName.isEmpty()) + ? Constants.MANAGER_PACKAGE_NAME + : managerPackageName; try { var intent = new Intent() - .setComponent(new ComponentName(Constants.MANAGER_PACKAGE_NAME, MODULE_SERVICE)) + .setComponent(new ComponentName(packageName, Constants.MANAGER_SERVICE_NAME)) .putExtra("packageName", context.getPackageName()); // TODO: Authentication var latch = new CountDownLatch(1); diff --git a/patch/src/main/java/org/lsposed/patch/LSPatch.java b/patch/src/main/java/org/lsposed/patch/LSPatch.java index c7907a84d..82d386da5 100644 --- a/patch/src/main/java/org/lsposed/patch/LSPatch.java +++ b/patch/src/main/java/org/lsposed/patch/LSPatch.java @@ -87,6 +87,9 @@ public PatchError(String message, Throwable cause) { @Parameter(names = {"--manager"}, description = "Use manager (Cannot work with embedding modules)") private boolean useManager = false; + @Parameter(names = {"--manager-package"}, description = "Manager package name when using --manager") + private String managerPackageName = Constants.MANAGER_PACKAGE_NAME; + @Parameter(names = {"-r", "--allowdown"}, description = "Allow downgrade installation by overriding versionCode to 1 (In most cases, the app can still get the correct versionCode)") private boolean overrideVersionCode = false; @@ -250,7 +253,7 @@ public void patch(File srcApkFile, File outputFile) throws PatchError, IOExcepti logger.i("Patching apk..."); // modify manifest - final var config = new PatchConfig(useManager, debuggableFlag, overrideVersionCode, sigbypassLevel, originalSignature, appComponentFactory); + final var config = new PatchConfig(useManager, debuggableFlag, overrideVersionCode, sigbypassLevel, originalSignature, appComponentFactory, useManager ? managerPackageName : null); final var configBytes = new Gson().toJson(config).getBytes(StandardCharsets.UTF_8); final var metadata = Base64.getEncoder().encodeToString(configBytes); try (var is = new ByteArrayInputStream(modifyManifestFile(manifestEntry.open(), metadata, minSdkVersion))) { diff --git a/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java b/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java index f8a8f7f09..d9a7c279e 100644 --- a/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java +++ b/share/java/src/main/java/org/lsposed/lspatch/share/Constants.java @@ -11,6 +11,8 @@ public class Constants { final static public String PATCH_FILE_SUFFIX = "-lspatched.apk"; final static public String PROXY_APP_COMPONENT_FACTORY = "org.lsposed.lspatch.metaloader.LSPAppComponentFactoryStub"; final static public String MANAGER_PACKAGE_NAME = "org.lsposed.lspatch"; + final static public String MANAGER_SERVICE_NAME = "org.lsposed.lspatch.manager.ModuleService"; + final static public String MIGRATE_ASSET_PATH = "assets/lspatch/migrate.zip"; final static public int MIN_ROLLING_VERSION_CODE = 348; final static public int SIGBYPASS_LV_DISABLE = 0; diff --git a/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java b/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java index 2cf9dc0df..05e6e39ce 100644 --- a/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java +++ b/share/java/src/main/java/org/lsposed/lspatch/share/PatchConfig.java @@ -8,6 +8,7 @@ public class PatchConfig { public final int sigBypassLevel; public final String originalSignature; public final String appComponentFactory; + public final String managerPackageName; public final LSPConfig lspConfig; public PatchConfig( @@ -17,6 +18,18 @@ public PatchConfig( int sigBypassLevel, String originalSignature, String appComponentFactory + ) { + this(useManager, debuggable, overrideVersionCode, sigBypassLevel, originalSignature, appComponentFactory, null); + } + + public PatchConfig( + boolean useManager, + boolean debuggable, + boolean overrideVersionCode, + int sigBypassLevel, + String originalSignature, + String appComponentFactory, + String managerPackageName ) { this.useManager = useManager; this.debuggable = debuggable; @@ -24,6 +37,21 @@ public PatchConfig( this.sigBypassLevel = sigBypassLevel; this.originalSignature = originalSignature; this.appComponentFactory = appComponentFactory; + if (useManager) { + this.managerPackageName = (managerPackageName != null && !managerPackageName.isEmpty()) + ? managerPackageName + : Constants.MANAGER_PACKAGE_NAME; + } else { + this.managerPackageName = null; + } this.lspConfig = LSPConfig.instance; } + + /** Resolved manager package for Local mode; falls back for configs created before this field existed. */ + public String resolveManagerPackageName() { + if (managerPackageName != null && !managerPackageName.isEmpty()) { + return managerPackageName; + } + return Constants.MANAGER_PACKAGE_NAME; + } }