From 951ccd3921bfaa0fd296cf9b02ca242fe40ff8d0 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 23 Oct 2026 15:25:59 +0200 Subject: [PATCH 1/2] Keep a sheet's content clear of the keyboard A text field on a modal bottom sheet sat under the keyboard the moment it was focused, taking its supporting text with it -- the validation message and the availability hint, which are exactly what a reader needs while typing -- and a sheet shortened by the keyboard had no way to scroll to what it had lost. Three things were missing and all three are needed: the activity never declared adjustResize, so no window inset was reported at all; the sheet consumes the window insets, so imePadding in its content had nothing left to apply and the inset has to be passed through; and the body needs to scroll once the keyboard has taken half the screen. They live in SheetBody, along with the language override every sheet was already re-applying by hand, so the next sheet gets all of them without knowing about them. Not for a sheet whose content scrolls itself: nesting a scrollable in another of the same direction is an error, and such a sheet handles the inset alone. ChoiceCard moves out of the keystore sheet unchanged, so the sheets that offer a small set of alternatives keep reading alike. Note that adjustResize reaches every screen, not only these sheets. --- manager/src/main/AndroidManifest.xml | 3 +- .../lsposed/lspatch/ui/component/SheetBody.kt | 38 +++++++++ .../ui/component/settings/ChoiceCard.kt | 83 +++++++++++++++++++ .../ui/component/settings/KeystoreSetting.kt | 68 +++------------ 4 files changed, 133 insertions(+), 59 deletions(-) create mode 100644 manager/src/main/java/org/lsposed/lspatch/ui/component/SheetBody.kt create mode 100644 manager/src/main/java/org/lsposed/lspatch/ui/component/settings/ChoiceCard.kt diff --git a/manager/src/main/AndroidManifest.xml b/manager/src/main/AndroidManifest.xml index 938c784ac..ae66410f6 100644 --- a/manager/src/main/AndroidManifest.xml +++ b/manager/src/main/AndroidManifest.xml @@ -36,7 +36,8 @@ + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/SheetBody.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/SheetBody.kt new file mode 100644 index 000000000..cc0bc330c --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/SheetBody.kt @@ -0,0 +1,38 @@ +package org.lsposed.lspatch.ui.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.matrix.vector.ui.LocalDialogLocalizer + +/** + * The body of a modal bottom sheet. + * + * Three things every sheet needs and none of them got by default, kept in one place so the next sheet does not + * have to remember them: + * + * - **The reader's language.** A sheet is its own window, and a new window takes a fresh set of Android + * composition locals from that window's context, dropping the in-app language override on the way in. + * - **Room for the keyboard.** A sheet is laid out against the window, not against the inset, so a text field in + * one sits under the keyboard the moment it is focused -- along with whatever the field was going to say about + * what was typed. + * - **Somewhere to scroll.** A sheet tall enough to need it, or shortened by the keyboard to need it, otherwise + * just loses its lower half with no way to reach it. + * + * Not for a sheet whose content scrolls itself: nesting a scrollable in another of the same direction is an + * error, so such a sheet applies [androidx.compose.foundation.layout.imePadding] on its own. + */ +@Composable +fun SheetBody(content: @Composable ColumnScope.() -> Unit) { + LocalDialogLocalizer.current { + Column( + Modifier.imePadding().verticalScroll(rememberScrollState()).navigationBarsPadding(), + content = content, + ) + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/ChoiceCard.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/ChoiceCard.kt new file mode 100644 index 000000000..127cd6358 --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/ChoiceCard.kt @@ -0,0 +1,83 @@ +package org.lsposed.lspatch.ui.component.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * One choice on a settings sheet, as a selectable card that carries its own explanation. + * + * Shared by every sheet that offers a small set of alternatives, so they read alike and a reader who has + * learned one has learned them all. + */ +@Composable +fun ChoiceCard( + selected: Boolean, + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + role: Role = Role.RadioButton, +) { + val colors = MaterialTheme.colorScheme + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(if (selected) colors.primaryContainer else colors.surfaceContainerHigh) + .selectable(selected = selected, role = role, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + icon, + contentDescription = null, + tint = if (selected) colors.primary else colors.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = if (selected) colors.onPrimaryContainer else colors.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = if (selected) colors.onPrimaryContainer.copy(alpha = 0.8f) + else colors.onSurfaceVariant, + ) + } + if (selected) { + Spacer(Modifier.width(10.dp)) + Icon( + Icons.Rounded.CheckCircle, + contentDescription = null, + tint = colors.primary, + modifier = Modifier.size(20.dp), + ) + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/KeystoreSetting.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/KeystoreSetting.kt index 0367c7ce8..acb3e319a 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/KeystoreSetting.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/KeystoreSetting.kt @@ -8,10 +8,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -63,7 +63,7 @@ import kotlinx.coroutines.withContext import org.lsposed.lspatch.R import org.lsposed.lspatch.config.Configs import org.lsposed.lspatch.config.MyKeyStore -import org.matrix.vector.ui.LocalDialogLocalizer +import org.lsposed.lspatch.ui.component.SheetBody import org.matrix.vector.ui.sheetRowColors import java.io.IOException import java.security.GeneralSecurityException @@ -122,10 +122,14 @@ private fun KeystoreSheet(onDismiss: () -> Unit) { // changes nothing. var custom by rememberSaveable { mutableStateOf(!MyKeyStore.useDefault) } - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - // A sheet is its own window and drops the in-app language override; re-applied so it speaks - // the reader's language, the way every shared sheet does. - LocalDialogLocalizer.current { + // The sheet consumes the window insets by default, so the body's imePadding would see nothing left to + // apply; the inset is passed through for SheetBody to act on instead. + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + contentWindowInsets = { WindowInsets(0) }, + ) { + SheetBody { Column(Modifier.padding(bottom = 12.dp)) { Row( modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 4.dp, bottom = 12.dp), @@ -184,63 +188,11 @@ private fun KeystoreSheet(onDismiss: () -> Unit) { ) } - Spacer(Modifier.navigationBarsPadding()) } } } } -/** One of the two keystore choices, as a selectable card that carries its own explanation. */ -@Composable -private fun ChoiceCard( - selected: Boolean, - icon: ImageVector, - title: String, - subtitle: String, - onClick: () -> Unit, -) { - val colors = MaterialTheme.colorScheme - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .background(if (selected) colors.primaryContainer else colors.surfaceContainerHigh) - .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - icon, - contentDescription = null, - tint = if (selected) colors.primary else colors.onSurfaceVariant, - modifier = Modifier.size(24.dp), - ) - Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = if (selected) colors.onPrimaryContainer else colors.onSurface, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = if (selected) colors.onPrimaryContainer.copy(alpha = 0.8f) - else colors.onSurfaceVariant, - ) - } - if (selected) { - Spacer(Modifier.width(10.dp)) - Icon( - Icons.Rounded.CheckCircle, - contentDescription = null, - tint = colors.primary, - modifier = Modifier.size(20.dp), - ) - } - } -} /** * The custom-keystore form, inline in the sheet. From 615ca67084176492dbc8cc3fe5dbf06e263abaf3 Mon Sep 17 00:00:00 2001 From: JingMatrix Date: Fri, 23 Oct 2026 15:26:25 +0200 Subject: [PATCH 2/2] Send a patched app's Google Play services calls to a replacement A patched app is re-signed, so Google Play services refuses it wherever it checks the caller's certificate -- most visibly at sign-in, which fails with UNREGISTERED_ON_API_CONSOLE. A replacement answering the same APIs under its own package can stand in, but only if the app's calls reach it and it knows which certificate the app was originally signed with. Nothing here names a vendor. A replacement is any package ending in .gms other than Google's own, and the single question asked of that name is whether it also ends in .android.gms: such a build renamed Google's namespaces into its own so it can be installed beside Google Play services, while one that keeps Google's names is meant to replace it outright and serves them under those names. Only the first needs its provider authorities and push names rewritten. The offers in the manager are discovered by asking the device which packages answer the sign-in service, and a package that is not among them can still be typed. Choosing one is three changes, not a flag, which is why the sheet says so before the patch rather than after. GmsRedirector rewrites the package, component and provider authority of GMS-bound intents and provider calls at run time. Actions are deliberately left alone: a replacement declares Google's own action names under its own package, so rewriting the package is what routes a call, and rewriting the action too would address a name nothing declares. Push is the one exception, because the app's push receiver only accepts a sender holding a signature permission of Google Play services that no other build can hold, so its C2DM names are renamed in the manifest for good and the same prefix rule is applied to the actions the app sets at run time. The original certificate is then declared in the metadata the replacement reads. The client library also asks the package manager -- not an intent -- whether Google Play services is present, so the redirect never sees that question, and on a device where it was replaced rather than supplemented the answer is no and the library stops before sending anything. Once the chosen replacement is found installed, isGooglePlayServicesAvailable is answered on its behalf, which is what makes these APIs reachable at all on a device with no Google Play services. It is logged the first time it changes an answer. Only the library's public entry points are answered; an API that repeats the check inside its own internals, as the advertising-id client does, still refuses. Declaring that certificate for a ROM with signature spoofing to report is the alternative, for a replacement that installs as Google Play services itself and so needs no redirect. The two are alternatives rather than additions -- both declare the same certificate and differ only in who reads it -- so at most one is chosen, and choosing neither changes nothing. The redirect only arms itself if the app can see the chosen package, which its own queries do not cover; a local-mode app already holds QUERY_ALL_PACKAGES, and the manager offers to add it to an integrated one. Supersedes #115 and #125. The run-time redirect is ported from NPatch, where NkBe introduced it as 7723mod/NPatch e640da4; its authors are credited below. Co-Authored-By: Martin Co-Authored-By: MrZhongzq <108169409+mrzhongzq@users.noreply.github.com> Co-Authored-By: NkBe --- .gitignore | 2 + .../main/java/org/lsposed/lspatch/Patcher.kt | 2 + .../lspatch/data/model/PatchRequest.kt | 5 + .../lspatch/data/repository/PatchReport.kt | 2 + .../ui/component/settings/GmsSetting.kt | 317 +++++++++++ .../lspatch/ui/page/AppDetailScreen.kt | 9 + .../lsposed/lspatch/ui/page/NewPatchScreen.kt | 33 ++ .../org/lsposed/lspatch/ui/page/PatchEntry.kt | 2 + .../lspatch/ui/viewmodel/NewPatchViewModel.kt | 4 + .../lsposed/lspatch/util/GmsReplacements.kt | 80 +++ .../lsposed/lspatch/util/LocalAppsUpdater.kt | 2 + manager/src/main/res/values-ar/strings.xml | 15 + manager/src/main/res/values-de/strings.xml | 15 + manager/src/main/res/values-es/strings.xml | 15 + manager/src/main/res/values-fa/strings.xml | 15 + manager/src/main/res/values-fr/strings.xml | 15 + manager/src/main/res/values-in/strings.xml | 15 + manager/src/main/res/values-it/strings.xml | 15 + manager/src/main/res/values-iw/strings.xml | 15 + manager/src/main/res/values-ja/strings.xml | 15 + manager/src/main/res/values-ko/strings.xml | 15 + manager/src/main/res/values-pl/strings.xml | 15 + .../src/main/res/values-pt-rBR/strings.xml | 15 + manager/src/main/res/values-ru/strings.xml | 15 + manager/src/main/res/values-tr/strings.xml | 15 + manager/src/main/res/values-uk/strings.xml | 15 + manager/src/main/res/values-vi/strings.xml | 15 + .../src/main/res/values-zh-rCN/strings.xml | 15 + .../src/main/res/values-zh-rTW/strings.xml | 15 + manager/src/main/res/values/strings.xml | 15 + .../lsposed/lspatch/loader/GmsRedirector.java | 496 ++++++++++++++++++ .../lspatch/loader/LSPApplication.java | 12 + .../java/org/lsposed/patch/ApkPatcher.java | 127 ++++- .../main/java/org/lsposed/patch/LSPatch.java | 8 + .../org/lsposed/patch/ManifestOverrides.java | 42 +- .../org/lsposed/lspatch/share/Constants.java | 65 +++ .../lsposed/lspatch/share/PatchConfig.java | 18 + 37 files changed, 1500 insertions(+), 11 deletions(-) create mode 100644 manager/src/main/java/org/lsposed/lspatch/ui/component/settings/GmsSetting.kt create mode 100644 manager/src/main/java/org/lsposed/lspatch/util/GmsReplacements.kt create mode 100644 patch-loader/src/main/java/org/lsposed/lspatch/loader/GmsRedirector.java diff --git a/.gitignore b/.gitignore index 438776576..8ce66f25f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ libxposed .cxx /out /.idea +# Output of the Eclipse/JDT language server, which several modules trigger +bin/ diff --git a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt index 91eaaab68..82798efa6 100644 --- a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt +++ b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt @@ -47,6 +47,8 @@ object Patcher { .usesCleartextTraffic(if (usesCleartextTraffic) true else null) .permissions(addedPermissions) .injectDocumentsProvider(injectDocumentsProvider) + .gmsReplacementPackage(gmsReplacementPackage) + .declareFakeSignature(declareFakeSignature) .build() ) .keystore( diff --git a/manager/src/main/java/org/lsposed/lspatch/data/model/PatchRequest.kt b/manager/src/main/java/org/lsposed/lspatch/data/model/PatchRequest.kt index aed721519..b130fa450 100644 --- a/manager/src/main/java/org/lsposed/lspatch/data/model/PatchRequest.kt +++ b/manager/src/main/java/org/lsposed/lspatch/data/model/PatchRequest.kt @@ -91,6 +91,11 @@ data class PatchRequest( // Inject a DocumentsProvider exposing the app's private data. Recorded in the config for the same // reason as the permissions above. val injectDocumentsProvider: Boolean = false, + // The replacement to redirect Google Play services to, or null to leave it alone. A package name + // rather than a flag because the patch bakes that name into the manifest. Recorded for the same reason. + val gmsReplacementPackage: String? = null, + // Declare the original signature as fake-signature for ROM signature spoofing. Recorded for the same reason. + val declareFakeSignature: Boolean = false, val origin: PatchOrigin = PatchOrigin.New, ) { val packageName: String get() = target.packageName diff --git a/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt b/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt index b07730d9a..e742179be 100644 --- a/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt +++ b/manager/src/main/java/org/lsposed/lspatch/data/repository/PatchReport.kt @@ -60,6 +60,8 @@ object PatchReport { "Added perms ${if (request.addedPermissions.isEmpty()) "none" else request.addedPermissions.joinToString(", ")}" ) add("Docs provider ${request.injectDocumentsProvider}") + add("GMS target ${request.gmsReplacementPackage ?: "none"}") + add("Fake sig ${request.declareFakeSignature}") add("Keystore ${if (MyKeyStore.useDefault) "built-in" else "custom (${MyKeyStore.file.name})"}") add("Verbose ${Configs.detailPatchLogs}") add("") diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/GmsSetting.kt b/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/GmsSetting.kt new file mode 100644 index 000000000..0a18d365a --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/ui/component/settings/GmsSetting.kt @@ -0,0 +1,317 @@ +package org.lsposed.lspatch.ui.component.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.ArrowDropUp +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material.icons.rounded.VerifiedUser +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.lsposed.lspatch.R +import org.lsposed.lspatch.share.Constants +import org.lsposed.lspatch.util.GmsReplacements +import org.matrix.vector.ui.AppIcon +import org.matrix.vector.ui.PackageRow +import org.lsposed.lspatch.ui.component.SheetBody +import org.matrix.vector.ui.sheetRowColors + +/** + * How the patched app reaches Google Play services. + * + * Three states, and they are what the patch actually does rather than a property it sets. Leaving it alone keeps + * Google's own package, which refuses a re-signed caller wherever it checks the certificate. Redirecting sends the + * app's calls to a replacement installed under its own name, which is not one change but three: intents and + * provider calls are rewritten at runtime, the push names in the manifest are rewritten to that vendor for good, + * and the original certificate is declared for it to report. Declaring the certificate alone is for a replacement + * that installs as Google Play services itself, where nothing needs redirecting and a ROM does the reporting. + * + * Each choice therefore states its consequences instead of naming a mechanism: the cost of redirecting is a + * manifest the app keeps, so the reader is told that before choosing rather than after patching. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GmsSetting( + replacement: String?, + declareFakeSignature: Boolean, + needsPackageVisibility: Boolean, + onChange: (replacement: String?, fake: Boolean) -> Unit, + onAddPackageVisibility: () -> Unit, +) { + var showSheet by remember { mutableStateOf(false) } + + ListItem( + modifier = Modifier.clickable { showSheet = true }, + leadingContent = { Icon(Icons.Rounded.CloudSync, contentDescription = null) }, + supportingContent = { + Text( + replacement?.let { stringResource(R.string.patch_gms_redirect_to, GmsReplacements.shortName(it)) } + ?: stringResource( + if (declareFakeSignature) R.string.patch_gms_declare_only + else R.string.patch_gms_unchanged + ) + ) + }, + trailingContent = { + Icon( + Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = sheetRowColors, + ) { Text(stringResource(R.string.patch_gms)) } + + if (showSheet) { + GmsSheet( + replacement = replacement, + declareFakeSignature = declareFakeSignature, + needsPackageVisibility = needsPackageVisibility, + onChange = onChange, + onAddPackageVisibility = onAddPackageVisibility, + onDismiss = { showSheet = false }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GmsSheet( + replacement: String?, + declareFakeSignature: Boolean, + needsPackageVisibility: Boolean, + onChange: (replacement: String?, fake: Boolean) -> Unit, + onAddPackageVisibility: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + // Which card is open, not what is stored: picking "redirect" has to show the list before there is anything to + // store, and a reader who opens it and changes nothing must leave the patch as it was. + var redirecting by remember { mutableStateOf(replacement != null) } + + // The sheet consumes the window insets by default, so the body's imePadding would see nothing left to + // apply; the inset is passed through for SheetBody to act on instead. + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + contentWindowInsets = { WindowInsets(0) }, + ) { + SheetBody { + Column(Modifier.padding(bottom = 12.dp)) { + Row( + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Rounded.CloudSync, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(14.dp)) + Text( + text = stringResource(R.string.patch_gms), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Text( + text = stringResource(R.string.patch_gms_why), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 12.dp), + ) + + Column( + Modifier.padding(horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Two cards, both of which can be turned back off, and at most one on. Doing nothing is the + // state with neither selected rather than a card of its own: it is the absence of a change, and + // giving it a row made the reader choose something in order to choose nothing. + ChoiceCard( + selected = declareFakeSignature, + icon = Icons.Rounded.VerifiedUser, + title = stringResource(R.string.patch_gms_declare_only), + subtitle = stringResource(R.string.patch_gms_declare_only_desc), + role = Role.Checkbox, + onClick = { + redirecting = false + onChange(null, !declareFakeSignature) + }, + ) + ChoiceCard( + selected = redirecting, + icon = Icons.Rounded.SwapHoriz, + title = stringResource(R.string.patch_gms_redirect), + subtitle = stringResource(R.string.patch_gms_redirect_desc), + role = Role.Checkbox, + onClick = { + if (redirecting) { + redirecting = false + onChange(null, false) + } else { + // Opening the field is not yet a destination: this only drops the other card's + // choice, and the package stays as it is until one is typed or picked. + redirecting = true + onChange(replacement, false) + } + }, + ) + } + + AnimatedVisibility(visible = redirecting) { + Column(Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) { + ReplacementField(replacement = replacement, onChange = { onChange(it, false) }) + if (replacement != null && needsPackageVisibility) { + Text( + text = stringResource(R.string.patch_gms_needs_visibility), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { onAddPackageVisibility() } + .padding(vertical = 8.dp), + ) + } + } + } + + } + } + } +} + +/** + * Where to send the app's Google calls. + * + * A package name typed in, with what this device has offered as a shortcut. Nothing is pre-chosen: the builds that + * exist are not a list this release can hold, and a name shipped here would outlive the project it points at. The + * shortcut only appears when the device actually has something to offer, so a reader with none sees a plain field + * rather than an empty menu. + * + * Only the shape is required. Whether the build is present is a question for the device that runs the apk, not the + * one that patches it, so an absent package is reported and still accepted. + */ +@Composable +private fun ReplacementField(replacement: String?, onChange: (String?) -> Unit) { + val onDevice = remember { GmsReplacements.installed() } + var text by rememberSaveable { mutableStateOf(replacement.orEmpty()) } + var listOpen by remember { mutableStateOf(false) } + + val typed = text.trim() + val wellFormed = typed.isNotEmpty() && Constants.isGmsReplacement(typed) + val malformed = text.isNotBlank() && !wellFormed + val installed = remember(typed) { wellFormed && GmsReplacements.isInstalled(typed) } + + fun accept(value: String) { + text = value + val candidate = value.trim() + // Anything that is not yet a usable package clears the choice instead of leaving the last valid one + // stored: otherwise editing a name that was already accepted leaves the field showing one package while + // the patch still carries the other, and the reader is given no sign of it. + onChange(if (Constants.isGmsReplacement(candidate)) candidate else null) + } + + OutlinedTextField( + value = text, + onValueChange = { accept(it) }, + label = { Text(stringResource(R.string.patch_gms_pick)) }, + placeholder = { Text(stringResource(R.string.patch_gms_custom_hint)) }, + isError = malformed, + singleLine = true, + trailingIcon = + if (onDevice.isEmpty()) null + else + ({ + IconButton(onClick = { listOpen = !listOpen }) { + Icon( + if (listOpen) Icons.Rounded.ArrowDropUp else Icons.Rounded.ArrowDropDown, + contentDescription = null, + ) + } + }), + supportingText = + when { + malformed -> ({ Text(stringResource(R.string.patch_gms_custom_invalid)) }) + wellFormed -> + ({ + Text( + text = + stringResource( + if (installed) R.string.patch_gms_installed + else R.string.patch_gms_not_installed + ), + color = + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (installed) 1f else 0.45f + ), + ) + }) + else -> null + }, + modifier = Modifier.fillMaxWidth(), + ) + + // Opened in place rather than as a popup: a popup anchored from inside a sheet is positioned against the + // sheet's own window, not the field. + AnimatedVisibility(visible = listOpen) { + Column { + onDevice.forEach { candidate -> + PackageRow( + icon = { AppIcon(applicationInfo = candidate.applicationInfo, contentDescription = candidate.label) }, + label = candidate.label, + packageName = candidate.packageName, + checked = typed == candidate.packageName, + modifier = + Modifier.clip(RoundedCornerShape(16.dp)).clickable { + accept(candidate.packageName) + listOpen = false + }, + ) { + // These builds are updated often, and two of them side by side are otherwise + // indistinguishable; the version is what tells the reader which one this is. + Text( + text = + (candidate.versionName ?: "?") + " \u00b7 " + + stringResource(R.string.patch_gms_version_code, candidate.versionCode), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/AppDetailScreen.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/AppDetailScreen.kt index 5148a09f3..4b2a8bb01 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/AppDetailScreen.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/AppDetailScreen.kt @@ -460,6 +460,15 @@ fun AppDetailScreen(navigator: Navigator, packageName: String) { stringResource(R.string.appdetail_info_inject_dex), yesNo(config?.injectDex == true), ) + InfoRow( + stringResource(R.string.patch_gms), + config?.gmsReplacementPackage + ?: if (config?.declareFakeSignature == true) { + stringResource(R.string.patch_gms_declare_only) + } else { + stringResource(R.string.patch_gms_unchanged) + }, + ) // Manifest attributes, read from the installed manifest rather than the patch // config: they are baked into the manifest at patch time and are not carried in the // runtime config, so this reflects the app's actual manifest. Grouped and ordered to 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 480306558..b278afd92 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 @@ -61,8 +61,10 @@ import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.RemoveModerator import androidx.compose.material.icons.rounded.Share import androidx.compose.material.icons.rounded.Smartphone +import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.material.icons.rounded.Terminal import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.VerifiedUser import androidx.compose.material.icons.rounded.WorkOutline import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox @@ -132,10 +134,12 @@ import org.lsposed.lspatch.ui.component.DetailTopBar import org.lsposed.lspatch.ui.component.PatchLog import org.lsposed.lspatch.ui.component.PatchStepList import org.lsposed.lspatch.ui.component.rememberExportApk +import org.lsposed.lspatch.ui.component.settings.GmsSetting import org.lsposed.lspatch.ui.component.settings.KeystoreSetting import org.lsposed.lspatch.ui.navigation.ModuleSelection import org.lsposed.lspatch.ui.navigation.SelectModules import org.lsposed.lspatch.ui.util.LocalSnackbarHost +import org.lsposed.lspatch.util.GmsReplacements import org.lsposed.lspatch.util.LSPPackageManager import org.lsposed.lspatch.util.ShizukuApi import org.matrix.vector.ui.PackageRow @@ -340,6 +344,8 @@ fun NewPatchScreen(navigator: Navigator, token: String) { onAddPermission = viewModel::addPermission, onRemovePermission = viewModel::removePermission, onInjectDocumentsProvider = viewModel::setInjectDocumentsProvider, + onSelectGmsReplacement = viewModel::setGmsReplacementPackage, + onDeclareFakeSignature = viewModel::setDeclareFakeSignature, ) else -> Column( @@ -387,6 +393,8 @@ private fun ConfigureBody( onAddPermission: (String) -> Unit, onRemovePermission: (String) -> Unit, onInjectDocumentsProvider: (Boolean) -> Unit, + onSelectGmsReplacement: (String?) -> Unit, + onDeclareFakeSignature: (Boolean) -> Unit, ) { val scope = rememberCoroutineScope() val snackbarHost = LocalSnackbarHost.current @@ -520,6 +528,17 @@ private fun ConfigureBody( // Neither writes the app's manifest, so they are kept apart from the overrides below. GroupDivider() SigBypassRow(level = request.sigBypassLevel, onSelect = onSigBypass) + GmsSetting( + replacement = request.gmsReplacementPackage, + declareFakeSignature = request.declareFakeSignature, + needsPackageVisibility = + !request.mode.useManager && QUERY_ALL_PACKAGES !in request.addedPermissions, + onChange = { replacement, fake -> + onSelectGmsReplacement(replacement) + onDeclareFakeSignature(fake) + }, + onAddPackageVisibility = { onAddPermission(QUERY_ALL_PACKAGES) }, + ) ToggleRow( title = stringResource(R.string.patch_inject_dex), icon = Icons.Rounded.Code, @@ -779,6 +798,18 @@ private fun advancedChips(request: PatchRequest): List = buildLi if (request.injectDocumentsProvider) { add(OptionChipData(stringResource(R.string.patch_manifest_documents_provider), true, Icons.Rounded.FolderOpen)) } + request.gmsReplacementPackage?.let { + add( + OptionChipData( + stringResource(R.string.patch_gms_redirect_to, GmsReplacements.shortName(it)), + true, + Icons.Rounded.SwapHoriz, + ) + ) + } + if (request.declareFakeSignature) { + add(OptionChipData(stringResource(R.string.patch_gms_declare_only), true, Icons.Rounded.VerifiedUser)) + } if (!MyKeyStore.useDefault) { add(OptionChipData(stringResource(R.string.settings_keystore_custom), true, Icons.Outlined.Ballot)) } @@ -1543,3 +1574,5 @@ private fun UninstallConfirm(label: String, onConfirm: () -> Unit, onCancel: () }, ) } + +private const val QUERY_ALL_PACKAGES = "android.permission.QUERY_ALL_PACKAGES" diff --git a/manager/src/main/java/org/lsposed/lspatch/ui/page/PatchEntry.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/PatchEntry.kt index 030d0c678..e1c3f635b 100644 --- a/manager/src/main/java/org/lsposed/lspatch/ui/page/PatchEntry.kt +++ b/manager/src/main/java/org/lsposed/lspatch/ui/page/PatchEntry.kt @@ -79,6 +79,8 @@ suspend fun rePatchRequestFor( // Recovered from the recorded config, not the original apks -- those never carried them. addedPermissions = config?.addedPermissions?.toList() ?: emptyList(), injectDocumentsProvider = config?.injectDocumentsProvider ?: false, + gmsReplacementPackage = config?.gmsReplacementPackage, + declareFakeSignature = config?.declareFakeSignature ?: false, sigBypassLevel = config?.sigBypassLevel ?: 2, injectDex = config?.injectDex ?: false, modules = modules ?: embedded, 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 1f383b140..4d2fb8966 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 @@ -109,6 +109,10 @@ class NewPatchViewModel(private val token: String) : ViewModel() { fun setInjectDocumentsProvider(value: Boolean) = mutate { it.copy(injectDocumentsProvider = value) } + fun setGmsReplacementPackage(value: String?) = mutate { it.copy(gmsReplacementPackage = value) } + + fun setDeclareFakeSignature(value: Boolean) = mutate { it.copy(declareFakeSignature = value) } + /** * Adds modules to the set, keeping what is already there. * diff --git a/manager/src/main/java/org/lsposed/lspatch/util/GmsReplacements.kt b/manager/src/main/java/org/lsposed/lspatch/util/GmsReplacements.kt new file mode 100644 index 000000000..85b30268c --- /dev/null +++ b/manager/src/main/java/org/lsposed/lspatch/util/GmsReplacements.kt @@ -0,0 +1,80 @@ +package org.lsposed.lspatch.util + +import android.content.Intent +import android.content.pm.ApplicationInfo +import androidx.core.content.pm.PackageInfoCompat +import org.lsposed.lspatch.lspApp +import org.lsposed.lspatch.share.Constants + +/** + * A Google Play services replacement installed on this device. + * + * Carries what the chooser shows: the build names itself through [label] and [applicationInfo], and [versionName] + * is worth showing because these builds are updated often and two of them side by side are otherwise + * indistinguishable. + */ +data class GmsReplacement( + val packageName: String, + val label: String, + val versionName: String?, + val versionCode: Long, + val applicationInfo: ApplicationInfo, +) + +/** + * The Google Play services replacements this device actually has. + * + * Found by asking, not by reading names. A replacement is whatever answers the services a Play services client + * binds, and builds differ in what they call themselves: one renames itself and its permissions so it can sit + * beside Google's own, another keeps Google's names and replaces it outright. Querying the sign-in service is + * how a client finds Google Play services in the first place, so anything that answers it is by definition a + * candidate, whatever it is called. + * + * Needs package visibility, which the manager holds; Google's own package is dropped, since it is what is being + * replaced. + */ +object GmsReplacements { + + /** The action a Play services client binds to sign in, and so the one a replacement has to answer. */ + private const val SIGN_IN_SERVICE = "com.google.android.gms.auth.api.signin.service.START" + + /** + * The vendor token of a replacement's package -- what distinguishes one build from another. + * + * The trailing segments every such build shares are dropped first, so what is left is the part that names + * the vendor rather than the service: `app.revanced.android.gms` and `org.microg.gms` give `revanced` and + * `microg`, not `android` and `gms`. + */ + fun shortName(packageName: String): String = + packageName + .removeSuffix(Constants.GMS_REPLACEMENT_SUFFIX) + .removeSuffix(".android") + .substringAfterLast('.') + + fun installed(): List = + runCatching { + lspApp.packageManager + .queryIntentServices(Intent(SIGN_IN_SERVICE), 0) + .mapNotNull { it.serviceInfo?.packageName } + .distinct() + .filter { Constants.isGmsReplacement(it) } + .sorted() + .mapNotNull { describe(it) } + }.getOrDefault(emptyList()) + + fun isInstalled(packageName: String): Boolean = + runCatching { lspApp.packageManager.getPackageInfo(packageName, 0) }.isSuccess + + private fun describe(packageName: String): GmsReplacement? = + runCatching { + val info = lspApp.packageManager.getPackageInfo(packageName, 0) + val app = info.applicationInfo ?: return null + GmsReplacement( + packageName = packageName, + label = lspApp.packageManager.getApplicationLabel(app).toString(), + versionName = info.versionName, + versionCode = PackageInfoCompat.getLongVersionCode(info), + applicationInfo = app, + ) + }.getOrNull() +} diff --git a/manager/src/main/java/org/lsposed/lspatch/util/LocalAppsUpdater.kt b/manager/src/main/java/org/lsposed/lspatch/util/LocalAppsUpdater.kt index 34dabfc62..2722bee07 100644 --- a/manager/src/main/java/org/lsposed/lspatch/util/LocalAppsUpdater.kt +++ b/manager/src/main/java/org/lsposed/lspatch/util/LocalAppsUpdater.kt @@ -127,6 +127,8 @@ object LocalAppsUpdater { .versionCode(config.versionCode) .permissions(config.addedPermissions?.toList() ?: emptyList()) .injectDocumentsProvider(config.injectDocumentsProvider) + .gmsReplacementPackage(config.gmsReplacementPackage) + .declareFakeSignature(config.declareFakeSignature) .build() ) .keystore( diff --git a/manager/src/main/res/values-ar/strings.xml b/manager/src/main/res/values-ar/strings.xml index 6117d854c..27344685d 100644 --- a/manager/src/main/res/values-ar/strings.xml +++ b/manager/src/main/res/values-ar/strings.xml @@ -300,4 +300,19 @@ أي عمل في الخلفية مجموعة انتظار التطبيقات التشغيل التلقائي من المصنّع + خدمات Google Play + يُعاد توقيع التطبيق المُرقَّع، لذا ترفضه خدمات Google Play في كل موضع تتحقق فيه من شهادة المتصل، وأوضح ذلك عند تسجيل الدخول. كلا الخيارين يُعلن شهادة التطبيق الأصلية، ويختلفان فقط في من يقرؤها. عدم اختيار أي منهما لا يغيّر شيئًا. + اتركه دون تغيير + التوجيه إلى بديل + يعيد كتابة نداءات التطبيق إلى Google أثناء التشغيل، ويغيّر أسماء الإشعارات في البيان بشكل دائم، ويُعلن الشهادة الأصلية للبديل. + موجَّه إلى %1$s + إعلان الشهادة فقط + بلا توجيه وبلا تغيير أسماء الإشعارات — لبديل مثبَّت باسم خدمات Google Play نفسها، تُبلِّغ عن شهادته نسخة نظام تدعم انتحال التوقيع. + البديل الوجهة + غير مثبَّت + لا يرى الوضع المدمج الحزم الأخرى، لذا لن يعثر عليه التطبيق. اضغط لإضافة QUERY_ALL_PACKAGES. + vendor.android.gms + يجب أن تكون حزمة البديل نفسه، وتنتهي بـ ‎.gms. + مثبَّت على هذا الجهاز + إصدار %1$d diff --git a/manager/src/main/res/values-de/strings.xml b/manager/src/main/res/values-de/strings.xml index edf21b529..dfc1a2907 100644 --- a/manager/src/main/res/values-de/strings.xml +++ b/manager/src/main/res/values-de/strings.xml @@ -300,4 +300,19 @@ Jede Hintergrundausführung App-Standby-Bucket Hersteller-Autostart + Google Play-Dienste + Eine gepatchte App wird neu signiert, daher weisen die Google Play-Dienste sie überall dort ab, wo das Zertifikat des Aufrufers geprüft wird – am sichtbarsten bei der Anmeldung. Beide Antworten geben das ursprüngliche Zertifikat der App an; sie unterscheiden sich darin, wer es liest. Keine von beiden zu wählen ändert nichts. + Unverändert lassen + An einen Ersatz senden + Schreibt die Google-Aufrufe der App zur Laufzeit um, benennt ihre Push-Namen im Manifest dauerhaft um und gibt dem Ersatz das ursprüngliche Zertifikat an. + An %1$s gesendet + Nur Zertifikat angeben + Keine Umleitung und keine Push-Umbenennung – für einen Ersatz, der selbst als Google Play-Dienste installiert ist und dessen Zertifikat eine ROM mit Signaturfälschung meldet. + Ziel-Ersatz + nicht installiert + Der integrierte Modus sieht keine anderen Pakete, die App findet ihn also nicht. Tippen, um QUERY_ALL_PACKAGES hinzuzufügen. + vendor.android.gms + Muss das eigene Paket eines Ersatzes sein und auf .gms enden. + auf diesem Gerät installiert + Build %1$d diff --git a/manager/src/main/res/values-es/strings.xml b/manager/src/main/res/values-es/strings.xml index 0d1d0817a..2e2fc4dfa 100644 --- a/manager/src/main/res/values-es/strings.xml +++ b/manager/src/main/res/values-es/strings.xml @@ -300,4 +300,19 @@ Cualquier trabajo en segundo plano Grupo de espera de aplicaciones Inicio automático del fabricante + Servicios de Google Play + Una app parcheada se vuelve a firmar, así que los servicios de Google Play la rechazan allí donde comprueban el certificado de quien llama, sobre todo al iniciar sesión. Ambas respuestas declaran el certificado original de la app; se diferencian en quién lo lee. No elegir ninguna no cambia nada. + Dejar como está + Enviar a un sustituto + Reescribe las llamadas a Google en tiempo de ejecución, renombra de forma permanente los nombres de push en el manifiesto y declara el certificado original al sustituto. + Enviado a %1$s + Solo declarar el certificado + Sin redirección ni renombrado de push: para un sustituto instalado como los propios servicios de Google Play, cuyo certificado informa una ROM con suplantación de firma. + Sustituto de destino + no instalado + El modo integrado no ve otros paquetes, así que la app no lo encontrará. Toca para añadir QUERY_ALL_PACKAGES. + vendor.android.gms + Debe ser el paquete propio de un sustituto, terminado en .gms. + instalado en este dispositivo + compilación %1$d diff --git a/manager/src/main/res/values-fa/strings.xml b/manager/src/main/res/values-fa/strings.xml index 8d29eaf4f..3c1ef052c 100644 --- a/manager/src/main/res/values-fa/strings.xml +++ b/manager/src/main/res/values-fa/strings.xml @@ -300,4 +300,19 @@ هر کاری در پس‌زمینه گروه آماده‌باش برنامه‌ها اجرای خودکار سازنده + سرویس‌های Google Play + برنامهٔ وصله‌شده دوباره امضا می‌شود، بنابراین سرویس‌های Google Play آن را هرجا گواهی فراخوان را بررسی کنند رد می‌کنند؛ آشکارتر از همه هنگام ورود. هر دو گزینه گواهی اصلی برنامه را اعلام می‌کنند و تنها در خوانندهٔ آن تفاوت دارند. انتخاب نکردن هیچ‌کدام چیزی را تغییر نمی‌دهد. + بدون تغییر + هدایت به جایگزین + فراخوانی‌های برنامه به Google را هنگام اجرا بازنویسی می‌کند، نام‌های اعلان را در مانیفست برای همیشه تغییر می‌دهد و گواهی اصلی را به جایگزین اعلام می‌کند. + هدایت‌شده به %1$s + فقط اعلام گواهی + بدون هدایت و بدون تغییر نام اعلان‌ها — برای جایگزینی که با نام خود سرویس‌های Google Play نصب شده و رامی با جعل امضا گواهی‌اش را گزارش می‌کند. + جایگزین مقصد + نصب‌نشده + حالت یکپارچه بسته‌های دیگر را نمی‌بیند، پس برنامه آن را پیدا نمی‌کند. برای افزودن QUERY_ALL_PACKAGES بزنید. + vendor.android.gms + باید بستهٔ خودِ جایگزین باشد و به ‎.gms ختم شود. + روی این دستگاه نصب است + ساخت %1$d diff --git a/manager/src/main/res/values-fr/strings.xml b/manager/src/main/res/values-fr/strings.xml index a6b1654c3..554cf3d28 100644 --- a/manager/src/main/res/values-fr/strings.xml +++ b/manager/src/main/res/values-fr/strings.xml @@ -300,4 +300,19 @@ Tout travail en arrière-plan Palier de veille des applications Démarrage auto du constructeur + Services Google Play + Une app patchée est resignée : les services Google Play la refusent partout où le certificat de l’appelant est vérifié — la connexion en premier lieu. Les deux réponses déclarent le certificat d’origine ; elles diffèrent sur qui le lit. N’en choisir aucune ne change rien. + Ne rien changer + Rediriger vers un remplaçant + Réécrit les appels Google à l’exécution, renomme définitivement les noms de push dans le manifeste et déclare le certificat d’origine au remplaçant. + Redirigé vers %1$s + Déclarer le certificat seulement + Aucune redirection ni renommage des push — pour un remplaçant installé sous le nom des services Google Play, dont une ROM avec usurpation de signature rapporte le certificat. + Remplaçant de destination + non installé + Le mode intégré ne voit pas les autres paquets : l’app ne le trouvera pas. Touchez pour ajouter QUERY_ALL_PACKAGES. + vendor.android.gms + Doit être le paquet d’un remplaçant, se terminant par .gms. + installé sur cet appareil + build %1$d diff --git a/manager/src/main/res/values-in/strings.xml b/manager/src/main/res/values-in/strings.xml index 1772b0594..a7d572a0c 100644 --- a/manager/src/main/res/values-in/strings.xml +++ b/manager/src/main/res/values-in/strings.xml @@ -300,4 +300,19 @@ Segala kerja di latar belakang Bucket siaga aplikasi Mulai otomatis dari vendor + Layanan Google Play + Aplikasi yang dipatch ditandatangani ulang, sehingga Layanan Google Play menolaknya di mana pun sertifikat pemanggil diperiksa, paling terlihat saat masuk. Kedua pilihan sama-sama mendeklarasikan sertifikat asli aplikasi; bedanya hanya siapa yang membacanya. Tidak memilih keduanya tidak mengubah apa pun. + Biarkan apa adanya + Arahkan ke pengganti + Menulis ulang panggilan Google saat berjalan, mengganti nama push di manifes secara permanen, dan mendeklarasikan sertifikat asli kepada pengganti. + Diarahkan ke %1$s + Hanya deklarasikan sertifikat + Tanpa pengalihan dan tanpa penggantian nama push — untuk pengganti yang dipasang sebagai Layanan Google Play itu sendiri, yang sertifikatnya dilaporkan ROM dengan pemalsuan tanda tangan. + Pengganti tujuan + belum terpasang + Mode terintegrasi tidak dapat melihat paket lain, jadi aplikasi tidak akan menemukannya. Ketuk untuk menambahkan QUERY_ALL_PACKAGES. + vendor.android.gms + Harus paket milik pengganti sendiri, berakhiran .gms. + terpasang di perangkat ini + build %1$d diff --git a/manager/src/main/res/values-it/strings.xml b/manager/src/main/res/values-it/strings.xml index efe2a3459..ae42c3c6f 100644 --- a/manager/src/main/res/values-it/strings.xml +++ b/manager/src/main/res/values-it/strings.xml @@ -300,4 +300,19 @@ Qualsiasi lavoro in background Bucket di standby delle app Avvio automatico del produttore + Google Play Services + Un\'app patchata viene firmata di nuovo, quindi Google Play Services la rifiuta ovunque controlli il certificato del chiamante, soprattutto all\'accesso. Entrambe le risposte dichiarano il certificato originale dell\'app; cambiano solo per chi lo legge. Non sceglierne nessuna non cambia nulla. + Lascia invariato + Inoltra a un sostituto + Riscrive le chiamate a Google in esecuzione, rinomina in modo permanente i nomi push nel manifest e dichiara il certificato originale al sostituto. + Inoltrato a %1$s + Dichiara solo il certificato + Nessuna redirezione né rinomina dei push: per un sostituto installato come Google Play Services stesso, il cui certificato viene riportato da una ROM con spoofing della firma. + Sostituto di destinazione + non installato + La modalità integrata non vede altri pacchetti, quindi l\'app non lo troverà. Tocca per aggiungere QUERY_ALL_PACKAGES. + vendor.android.gms + Deve essere il pacchetto di un sostituto e terminare con .gms. + installato su questo dispositivo + build %1$d diff --git a/manager/src/main/res/values-iw/strings.xml b/manager/src/main/res/values-iw/strings.xml index 718116ccd..3fa95e7d5 100644 --- a/manager/src/main/res/values-iw/strings.xml +++ b/manager/src/main/res/values-iw/strings.xml @@ -300,4 +300,19 @@ כל עבודה ברקע קבוצת המתנה של אפליקציות הפעלה אוטומטית של היצרן + שירותי Google Play + אפליקציה מתוקנת נחתמת מחדש, ולכן שירותי Google Play דוחים אותה בכל מקום שבו הם בודקים את האישור של הקורא, בעיקר בהתחברות. שתי האפשרויות מצהירות על האישור המקורי של האפליקציה, והן נבדלות רק במי שקורא אותו. אם לא תבחר באף אחת, דבר לא ישתנה. + להשאיר ללא שינוי + להפנות לתחליף + משכתב את הקריאות ל‑Google בזמן ריצה, משנה לצמיתות את שמות ההתראות במניפסט, ומצהיר על האישור המקורי בפני התחליף. + מופנה אל %1$s + להצהיר על האישור בלבד + ללא הפניה וללא שינוי שמות התראות — עבור תחליף שמותקן בשם שירותי Google Play עצמם, שעל האישור שלו מדווחת גרסת מערכת עם זיוף חתימה. + תחליף היעד + לא מותקן + מצב משולב אינו רואה חבילות אחרות, ולכן האפליקציה לא תמצא אותו. הקש כדי להוסיף QUERY_ALL_PACKAGES. + vendor.android.gms + חייבת להיות החבילה של התחליף עצמו, המסתיימת ב‑.gms. + מותקן במכשיר הזה + בנייה %1$d diff --git a/manager/src/main/res/values-ja/strings.xml b/manager/src/main/res/values-ja/strings.xml index 3fe27423d..d103b8722 100644 --- a/manager/src/main/res/values-ja/strings.xml +++ b/manager/src/main/res/values-ja/strings.xml @@ -300,4 +300,19 @@ あらゆるバックグラウンド動作 アプリスタンバイバケット メーカーの自動起動 + Google Play 開発者サービス + パッチ適用後のアプリは署名し直されるため、呼び出し元の証明書を確認する箇所では Google Play 開発者サービスに拒否されます。もっとも目に見えるのはログインです。どちらの選択肢もアプリ本来の証明書を宣言し、違うのはそれを読む相手だけです。どちらも選ばなければ何も変わりません。 + 変更しない + 代替実装へ転送 + 実行時に Google への呼び出しを書き換え、マニフェストの push 名を恒久的に変更し、本来の証明書を代替実装へ宣言します。 + %1$s へ転送 + 証明書の宣言のみ + 転送も push 名の変更も行いません。Google Play 開発者サービス自身として導入され、署名偽装に対応した ROM が証明書を報告する代替実装向けです。 + 転送先の代替実装 + 未インストール + 統合モードでは他のパッケージが見えないため、アプリが見つけられません。タップして QUERY_ALL_PACKAGES を追加します。 + vendor.android.gms + 代替実装自身のパッケージ名で、.gms で終わる必要があります。 + この端末にインストール済み + ビルド %1$d diff --git a/manager/src/main/res/values-ko/strings.xml b/manager/src/main/res/values-ko/strings.xml index 65c7e5378..6a695c670 100644 --- a/manager/src/main/res/values-ko/strings.xml +++ b/manager/src/main/res/values-ko/strings.xml @@ -300,4 +300,19 @@ 모든 백그라운드 작업 앱 대기 버킷 제조사 자동 실행 + Google Play 서비스 + 패치된 앱은 다시 서명되므로 호출자의 인증서를 확인하는 곳마다 Google Play 서비스가 거부합니다. 로그인에서 가장 뚜렷하게 나타납니다. 두 선택지 모두 앱의 원래 인증서를 선언하며, 그것을 읽는 쪽만 다릅니다. 둘 다 고르지 않으면 아무것도 바뀌지 않습니다. + 그대로 두기 + 대체 구현으로 보내기 + 실행 중 Google 호출을 다시 쓰고, 매니페스트의 push 이름을 영구히 바꾸며, 원래 인증서를 대체 구현에 선언합니다. + %1$s(으)로 전달 + 인증서만 선언 + 전달도 push 이름 변경도 하지 않습니다. Google Play 서비스 자체로 설치되어, 서명 위장을 지원하는 ROM이 인증서를 알려주는 대체 구현용입니다. + 보낼 대체 구현 + 설치되지 않음 + 통합 모드에서는 다른 패키지를 볼 수 없어 앱이 찾지 못합니다. 눌러서 QUERY_ALL_PACKAGES를 추가하세요. + vendor.android.gms + 대체 구현 자신의 패키지여야 하며 .gms로 끝나야 합니다. + 이 기기에 설치됨 + 빌드 %1$d diff --git a/manager/src/main/res/values-pl/strings.xml b/manager/src/main/res/values-pl/strings.xml index 8326f26e7..5ff7d0c53 100644 --- a/manager/src/main/res/values-pl/strings.xml +++ b/manager/src/main/res/values-pl/strings.xml @@ -300,4 +300,19 @@ Dowolna praca w tle Grupa uśpienia aplikacji Autostart producenta + Usługi Google Play + Spatchowana aplikacja jest podpisana na nowo, więc Usługi Google Play odrzucają ją wszędzie tam, gdzie sprawdzają certyfikat wywołującego – najbardziej widocznie przy logowaniu. Obie odpowiedzi deklarują pierwotny certyfikat aplikacji; różnią się tym, kto go odczytuje. Niewybranie żadnej nic nie zmienia. + Pozostaw bez zmian + Kieruj do zamiennika + Przepisuje wywołania do Google w czasie działania, trwale zmienia nazwy push w manifeście i deklaruje zamiennikowi pierwotny certyfikat. + Kierowane do %1$s + Tylko zadeklaruj certyfikat + Bez przekierowania i bez zmiany nazw push – dla zamiennika zainstalowanego jako same Usługi Google Play, którego certyfikat zgłasza ROM z podszywaniem się pod podpis. + Zamiennik docelowy + niezainstalowany + Tryb zintegrowany nie widzi innych pakietów, więc aplikacja go nie znajdzie. Dotknij, aby dodać QUERY_ALL_PACKAGES. + vendor.android.gms + Musi być własnym pakietem zamiennika i kończyć się na .gms. + zainstalowany na tym urządzeniu + kompilacja %1$d diff --git a/manager/src/main/res/values-pt-rBR/strings.xml b/manager/src/main/res/values-pt-rBR/strings.xml index e4f40093d..1590e3833 100644 --- a/manager/src/main/res/values-pt-rBR/strings.xml +++ b/manager/src/main/res/values-pt-rBR/strings.xml @@ -300,4 +300,19 @@ Qualquer trabalho em segundo plano Grupo de espera de aplicativos Início automático do fabricante + Google Play Services + Um app corrigido é assinado de novo, então o Google Play Services o recusa onde quer que verifique o certificado de quem chama — mais visivelmente no login. As duas respostas declaram o certificado original do app; diferem em quem o lê. Não escolher nenhuma não muda nada. + Deixar como está + Enviar a um substituto + Reescreve as chamadas ao Google em tempo de execução, renomeia permanentemente os nomes de push no manifesto e declara o certificado original ao substituto. + Enviado a %1$s + Apenas declarar o certificado + Sem redirecionamento nem renomeação de push — para um substituto instalado como o próprio Google Play Services, cujo certificado é informado por uma ROM com falsificação de assinatura. + Substituto de destino + não instalado + O modo integrado não enxerga outros pacotes, então o app não o encontrará. Toque para adicionar QUERY_ALL_PACKAGES. + vendor.android.gms + Deve ser o pacote do próprio substituto, terminando em .gms. + instalado neste dispositivo + build %1$d diff --git a/manager/src/main/res/values-ru/strings.xml b/manager/src/main/res/values-ru/strings.xml index a8389bc4d..7d70d757c 100644 --- a/manager/src/main/res/values-ru/strings.xml +++ b/manager/src/main/res/values-ru/strings.xml @@ -300,4 +300,19 @@ Любая работа в фоне Группа ожидания приложений Автозапуск производителя + Сервисы Google Play + Пропатченное приложение переподписано, поэтому сервисы Google Play отклоняют его везде, где проверяют сертификат вызывающей стороны, заметнее всего при входе. Оба варианта объявляют исходный сертификат приложения и различаются лишь тем, кто его читает. Если не выбрать ничего, ничего и не изменится. + Не менять + Направить замене + Переписывает обращения приложения к Google во время работы, навсегда переименовывает push-имена в манифесте и объявляет замене исходный сертификат. + Направлено в %1$s + Только объявить сертификат + Без перенаправления и без переименования push — для замены, установленной под именем самих сервисов Google Play, чей сертификат сообщает прошивка с подменой подписи. + Замена-получатель + не установлено + Интегрированный режим не видит другие пакеты, поэтому приложение её не найдёт. Нажмите, чтобы добавить QUERY_ALL_PACKAGES. + vendor.android.gms + Должно быть собственным пакетом замены и заканчиваться на .gms. + установлено на этом устройстве + сборка %1$d diff --git a/manager/src/main/res/values-tr/strings.xml b/manager/src/main/res/values-tr/strings.xml index 87d6f79b6..894539693 100644 --- a/manager/src/main/res/values-tr/strings.xml +++ b/manager/src/main/res/values-tr/strings.xml @@ -300,4 +300,19 @@ Her türlü arka plan çalışması Uygulama bekleme grubu Üretici otomatik başlatma + Google Play Hizmetleri + Yamalanan uygulama yeniden imzalanır; bu yüzden Google Play Hizmetleri, çağıranın sertifikasını denetlediği her yerde onu reddeder, en görünür biçimde oturum açarken. İki seçenek de uygulamanın özgün sertifikasını bildirir; yalnızca onu kimin okuduğu değişir. Hiçbirini seçmemek hiçbir şeyi değiştirmez. + Olduğu gibi bırak + Bir yedeğe yönlendir + Uygulamanın Google çağrılarını çalışma zamanında yeniden yazar, manifestteki push adlarını kalıcı olarak değiştirir ve özgün sertifikayı yedeğe bildirir. + %1$s adresine yönlendirildi + Yalnızca sertifikayı bildir + Yönlendirme ve push yeniden adlandırma yok — Google Play Hizmetleri\'nin kendi adıyla kurulmuş, sertifikasını imza taklidi yapan bir ROM\'un bildirdiği bir yedek için. + Hedef yedek + kurulu değil + Tümleşik mod diğer paketleri göremez, bu yüzden uygulama onu bulamaz. QUERY_ALL_PACKAGES eklemek için dokunun. + vendor.android.gms + Bir yedeğin kendi paketi olmalı ve .gms ile bitmelidir. + bu cihazda kurulu + yapı %1$d diff --git a/manager/src/main/res/values-uk/strings.xml b/manager/src/main/res/values-uk/strings.xml index 5f25b9b1c..8a6cfaef7 100644 --- a/manager/src/main/res/values-uk/strings.xml +++ b/manager/src/main/res/values-uk/strings.xml @@ -300,4 +300,19 @@ Будь-яка робота у фоні Група очікування застосунків Автозапуск виробника + Сервіси Google Play + Пропатчений застосунок перепідписано, тож сервіси Google Play відхиляють його всюди, де перевіряють сертифікат викликача, найпомітніше під час входу. Обидві відповіді оголошують початковий сертифікат застосунку й різняться лише тим, хто його читає. Якщо не обрати жодної, нічого не зміниться. + Не змінювати + Спрямувати на заміну + Переписує звернення застосунку до Google під час роботи, назавжди перейменовує push-імена в маніфесті й оголошує заміні початковий сертифікат. + Спрямовано до %1$s + Лише оголосити сертифікат + Без перенаправлення й без перейменування push — для заміни, встановленої під іменем самих сервісів Google Play, чий сертифікат повідомляє прошивка з підміною підпису. + Заміна-отримувач + не встановлено + Інтегрований режим не бачить інших пакетів, тож застосунок її не знайде. Торкніться, щоб додати QUERY_ALL_PACKAGES. + vendor.android.gms + Має бути власним пакетом заміни й закінчуватися на .gms. + встановлено на цьому пристрої + збірка %1$d diff --git a/manager/src/main/res/values-vi/strings.xml b/manager/src/main/res/values-vi/strings.xml index 6a2a0669a..268fb2075 100644 --- a/manager/src/main/res/values-vi/strings.xml +++ b/manager/src/main/res/values-vi/strings.xml @@ -300,4 +300,19 @@ Mọi hoạt động nền Nhóm chờ ứng dụng Tự khởi động của nhà sản xuất + Dịch vụ Google Play + Ứng dụng đã vá được ký lại, nên Dịch vụ Google Play từ chối nó ở mọi nơi có kiểm tra chứng chỉ của bên gọi, rõ nhất là khi đăng nhập. Cả hai lựa chọn đều khai báo chứng chỉ gốc của ứng dụng; chúng chỉ khác nhau ở người đọc nó. Không chọn gì thì không thay đổi gì. + Giữ nguyên + Chuyển tới bản thay thế + Viết lại các lệnh gọi Google khi chạy, đổi vĩnh viễn tên push trong manifest, và khai báo chứng chỉ gốc cho bản thay thế. + Chuyển tới %1$s + Chỉ khai báo chứng chỉ + Không chuyển hướng và không đổi tên push — dành cho bản thay thế được cài dưới chính tên Dịch vụ Google Play, với chứng chỉ do ROM có giả mạo chữ ký báo cáo. + Bản thay thế đích + chưa cài đặt + Chế độ tích hợp không thấy các gói khác nên ứng dụng sẽ không tìm ra nó. Chạm để thêm QUERY_ALL_PACKAGES. + vendor.android.gms + Phải là gói của chính bản thay thế, kết thúc bằng .gms. + đã cài trên thiết bị này + bản dựng %1$d diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml index 6fc417ca8..e330cec99 100644 --- a/manager/src/main/res/values-zh-rCN/strings.xml +++ b/manager/src/main/res/values-zh-rCN/strings.xml @@ -307,4 +307,19 @@ 任意后台运行 应用待机分组 厂商自启动 + Google Play 服务 + 修补后的应用已重新签名,凡是校验调用方证书的地方 Google Play 服务都会拒绝它 —— 最明显的是登录。两种做法都会声明应用的原始证书,区别只在于由谁读取。两个都不选即不作改动。 + 不改动 + 转向替代实现 + 运行时改写应用对 Google 的调用,永久改写清单中的推送名称,并向替代实现声明原始证书。 + 转向 %1$s + 仅声明证书 + 不转向,也不改推送名称 —— 适用于以 Google Play 服务自身包名安装的替代实现,由支持签名伪装的 ROM 上报证书。 + 转向到哪个替代实现 + 未安装 + 集成模式看不到其他应用,应用将找不到它。点击添加 QUERY_ALL_PACKAGES。 + vendor.android.gms + 必须是替代实现自身的包名,以 .gms 结尾。 + 本机已安装 + 版本号 %1$d diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml index c095b3448..742365ff0 100644 --- a/manager/src/main/res/values-zh-rTW/strings.xml +++ b/manager/src/main/res/values-zh-rTW/strings.xml @@ -300,4 +300,19 @@ 任意背景執行 應用待機分組 廠商自動啟動 + Google Play 服務 + 修補後的應用已重新簽名,凡是驗證呼叫方憑證的地方 Google Play 服務都會拒絕它 —— 最明顯的是登入。兩種做法都會宣告應用的原始憑證,差別只在於由誰讀取。兩個都不選即不做變更。 + 不變更 + 轉向替代實作 + 執行時改寫應用對 Google 的呼叫,永久改寫清單中的推播名稱,並向替代實作宣告原始憑證。 + 轉向 %1$s + 僅宣告憑證 + 不轉向,也不改推播名稱 —— 適用於以 Google Play 服務自身套件名安裝的替代實作,由支援簽章偽裝的 ROM 回報憑證。 + 轉向到哪個替代實作 + 未安裝 + 整合模式看不到其他應用,應用將找不到它。點擊加入 QUERY_ALL_PACKAGES。 + vendor.android.gms + 必須是替代實作自身的套件名,以 .gms 結尾。 + 本機已安裝 + 版本號 %1$d diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 764aa6648..4b1d6fb90 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -315,4 +315,19 @@ Any background work App standby bucket Vendor auto-start + Google Play services + A patched app is re-signed, so Google Play services refuses it wherever it checks the caller’s certificate — most visibly at sign-in. Both answers declare the app’s original certificate; they differ in who reads it. Choose neither to change nothing. + Leave alone + Send to a replacement + Rewrites the app’s Google calls at run time, renames its push names in the manifest for good, and declares the original certificate to the replacement. + Sent to %1$s + Declare certificate only + No redirect and no push rename — for a replacement installed as Google Play services itself, where a ROM with signature spoofing reports the certificate. + Replacement to send to + not installed + Integrated mode cannot see other packages, so the app will not find it. Tap to add QUERY_ALL_PACKAGES. + vendor.android.gms + Must be a replacement’s own package, ending in .gms. + installed on this device + build %1$d diff --git a/patch-loader/src/main/java/org/lsposed/lspatch/loader/GmsRedirector.java b/patch-loader/src/main/java/org/lsposed/lspatch/loader/GmsRedirector.java new file mode 100644 index 000000000..968a88198 --- /dev/null +++ b/patch-loader/src/main/java/org/lsposed/lspatch/loader/GmsRedirector.java @@ -0,0 +1,496 @@ +package org.lsposed.lspatch.loader; + +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.util.Log; + +import org.lsposed.lspatch.loader.util.XLog; +import org.lsposed.lspatch.share.Constants; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; + +/** + * Points a patched app's Google Play services traffic at an installed replacement. + * + * A patched app is signed with LSPatch's key, so Google Play services rejects it wherever it checks the caller's + * certificate -- most visibly at Google sign-in. A replacement answers the same requests under its own package, so + * this rewrites the package and component of every GMS-bound intent, and the authority of every GMS-bound provider + * call, to it. Which certificate that build then reports for the app is decided by the manifest metadata the + * patcher wrote, not here. + * + * Actions are deliberately left alone. A replacement is a drop-in: it declares Google's own action names under its + * own package, so rewriting the package is what routes a call, and rewriting the action as well would address a + * name nothing declares. Push is the one exception, for the reason given on {@link #redirectAction}. + * + * Nothing else is assumed about the build. Two kinds exist -- one that renamed Google's namespaces into its own so + * it can sit beside Google Play services, and one that keeps Google's names because it is meant to replace it + * outright -- and only the first needs its authorities and push names rewritten. That question is answered from the + * package name alone, by {@link Constants#renamesGoogleNamespaces}, so no vendor is named here. + * + * An intent the app addresses to itself is never rewritten: only Google's own package and {@code gsf} are matched, + * which matters because the sign-in library opens its own {@code SignInHubActivity} with Google's + * {@code GOOGLE_SIGN_IN} action, and that activity finishes on any other action. + */ +public class GmsRedirector { + private static final String TAG = "LSPatch-GmsRedirect"; + private static final String REAL_GMS = Constants.REAL_GMS_PACKAGE_NAME; + + private static String targetGms = null; + + /** + * Points the app at [gmsReplacementPackage], if that build is installed and the app can see it. + * + * The package name is the whole configuration. Whether this build renamed Google's namespaces into its own, + * and so whether its authorities and push names have to be rewritten to reach it, follows from the name alone; + * nothing else about the build is assumed and no vendor is named here. + */ + public static void activate(Context context, String gmsReplacementPackage) { + if (!Constants.isGmsReplacement(gmsReplacementPackage)) { + Log.w(TAG, "Not a usable replacement package, GMS redirect disabled: " + gmsReplacementPackage); + return; + } + if (!isInstalled(context, gmsReplacementPackage)) { + Log.w(TAG, gmsReplacementPackage + " is not installed or not visible to the app" + + " (add QUERY_ALL_PACKAGES), GMS redirect disabled"); + return; + } + targetGms = gmsReplacementPackage; + + Log.i(TAG, "Activating GMS redirect: " + REAL_GMS + " -> " + targetGms); + setupC2dmRedirects(); + + hookIntentSetPackage(); + hookIntentSetAction(); + hookIntentGetAction(); + hookIntentSetComponent(); + hookIntentSetClass(); + hookIntentResolve(); + hookActivityStart(); + hookContentResolverAcquire(); + + Log.i(TAG, "GMS redirect hooks installed"); + } + + /** {@code ConnectionResult.SUCCESS}, named here so the app's own copy of the class is not needed. */ + private static final int PLAY_SERVICES_SUCCESS = 0; + + /** Announced once per process: the check runs constantly, and the reader needs telling only once. */ + private static boolean availabilityAnnounced = false; + + /** + * Answers the client library's "is Google Play services here?" check on the replacement's behalf. + * + * That check is not an intent. It asks the package manager for Google's own package and inspects its + * version and signing certificate, so the redirect never sees it, and on a device whose Google Play + * services has been replaced rather than supplemented it fails -- whereupon the library stops before + * sending anything. It does not merely report the failure: it hands back stub intents, so every API + * behind it is refused even though the services it would have bound are present and answering. + * + * Only armed once the replacement has been found installed, so this reports something that is in fact + * true: the APIs are available, from a different package. The first time it actually changes an answer it + * is logged, because an app being told Google Play services is present when Google's own is absent is + * worth being able to find afterwards. + */ + static void answerAvailabilityChecks(ClassLoader appClassLoader) { + if (targetGms == null || appClassLoader == null) return; + // Four entry points, because the library answers the same question in several places and the APIs + // behind them do not agree on which to ask: the sign-in client goes through GoogleApiAvailability, + // the advertising-id client through GooglePlayServicesUtilLight. Hooking one leaves the others + // refusing. + for (String name : new String[]{ + "com.google.android.gms.common.GoogleApiAvailability", + "com.google.android.gms.common.GoogleApiAvailabilityLight", + "com.google.android.gms.common.GooglePlayServicesUtil", + "com.google.android.gms.common.GooglePlayServicesUtilLight"}) { + Class clazz = XposedHelpers.findClassIfExists(name, appClassLoader); + // Absent whenever the app does not ship the client library, which is the common case. + if (clazz == null) continue; + try { + XposedBridge.hookAllMethods(clazz, "isGooglePlayServicesAvailable", new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + Object result = param.getResult(); + if (!(result instanceof Integer)) return; + int reported = (Integer) result; + if (reported == PLAY_SERVICES_SUCCESS) return; + param.setResult(PLAY_SERVICES_SUCCESS); + announceAvailability(reported); + } + }); + Log.i(TAG, "Answering availability checks on " + name); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook " + name + ".isGooglePlayServicesAvailable", t); + } + } + } + + private static void announceAvailability(int reported) { + synchronized (GmsRedirector.class) { + if (availabilityAnnounced) return; + availabilityAnnounced = true; + } + Log.i(TAG, "Reported Google Play services as available: the client answered " + reported + + ", and the calls behind it go to " + targetGms); + } + + private static boolean isInstalled(Context context, String pkg) { + try { + context.getPackageManager().getPackageInfo(pkg, 0); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + /** + * The package to use in place of [pkg], or null to leave it alone. + * + * {@code gsf} maps to the replacement unconditionally, unlike its authorities: a replacement serves the gsf + * services from inside its own package, so an intent addressed to {@code com.google.android.gsf} reaches a + * package that is not installed at all, whereas its *authorities* may well be registered under Google's names. + * The two questions have different answers and are asked separately. + */ + static String redirectPackage(String pkg) { + if (REAL_GMS.equals(pkg) || GSF_PACKAGE.equals(pkg)) { + return targetGms; + } + return null; + } + + private static final String GSF_PACKAGE = "com.google.android.gsf"; + private static final String CHIMERA_AUTHORITY = REAL_GMS + ".chimera"; + + /** + * The authority to use in place of [authority], or null to leave it alone. + * + * Only a replacement that renamed its own namespaces serves these under its vendor; one that kept Google's + * names registers them exactly as Google does, and rewriting them would address something nothing serves. + * Chimera is left to Google in either case: no replacement provides Dynamite modules, so a rewrite there + * only breaks a lookup that would otherwise be answered. + */ + static String redirectAuthority(String authority) { + if (authority == null) return null; + if (CHIMERA_AUTHORITY.equals(authority)) return null; + String vendor = Constants.gmsReplacementVendor(targetGms); + if (vendor == null) return null; + if (authority.equals(REAL_GMS)) { + return targetGms; + } + if (authority.startsWith(REAL_GMS + ".")) { + return targetGms + authority.substring(REAL_GMS.length()); + } + // The gsf authorities keep their own segment under the replacement's vendor. + if (authority.startsWith(GSF_PACKAGE)) { + return vendor + authority.substring("com.google".length()); + } + return null; + } + + private static final String CHOOSE_ACCOUNT_ACTION = + "com.google.android.gms.common.account.CHOOSE_ACCOUNT"; + private static final ComponentName SYSTEM_ACCOUNT_PICKER; + static { + ComponentName picker = null; + try { + picker = ComponentName.unflattenFromString("android/.accounts.ChooseTypeAndAccountActivity"); + } catch (Throwable ignored) {} + SYSTEM_ACCOUNT_PICKER = picker; + } + + /** + * The namespace this replacement's push names live in, or null when it kept Google's. + * + * A prefix rather than a list of actions, so that the rule here is the same rule the patcher applied to the + * manifest: it renames every {@code com.google.android.c2dm.*} string it finds, and anything this side left out + * of a list would be an action rewritten in one place and not the other. + */ + private static String c2dmPrefix = null; + + private static void setupC2dmRedirects() { + c2dmPrefix = Constants.gmsReplacementC2dmPrefix(targetGms); + if (c2dmPrefix != null) { + Log.i(TAG, "C2DM names rewritten to " + c2dmPrefix + "*"); + } else { + Log.i(TAG, "C2DM names left alone: " + targetGms + " keeps Google's C2DM namespace"); + } + } + + static boolean isChooseAccountAction(String action) { + return CHOOSE_ACCOUNT_ACTION.equals(action) + || (targetGms != null && (targetGms + ".common.account.CHOOSE_ACCOUNT").equals(action)); + } + + private static void routeChooseAccountToSystem(Intent intent) { + if (intent == null || SYSTEM_ACCOUNT_PICKER == null || !isChooseAccountAction(intent.getAction())) { + return; + } + if (!SYSTEM_ACCOUNT_PICKER.equals(intent.getComponent()) || intent.getPackage() != null) { + XLog.d(TAG, "Routing CHOOSE_ACCOUNT directly to the system account picker"); + intent.setComponent(SYSTEM_ACCOUNT_PICKER); + intent.setPackage(null); + } + } + + /** + * The action to use in place of [action], or null to leave it alone, which is the usual answer. + * + * Only the C2DM family is rewritten. A replacement is a drop-in: it answers Google's own action names under + * its own package, so rewriting the package is what routes a call to it and rewriting the action as well + * would send that call to a name nothing declares. Push is the exception, and only because the receiving + * side cannot be reached under Google's names at all -- the permission guarding them is a signature + * permission of Google Play services that no other build can hold -- which is why the patcher renames the + * app's own C2DM names in the manifest to match. + */ + static String redirectAction(String action) { + if (action == null || c2dmPrefix == null) return null; + if (!action.startsWith(Constants.GOOGLE_C2DM_PREFIX)) return null; + return c2dmPrefix + action.substring(Constants.GOOGLE_C2DM_PREFIX.length()); + } + + /** The Google name for [action] if this rewrote it on the way out, or null when it is not one of ours. */ + static String restoreAction(String action) { + if (action == null || c2dmPrefix == null) return null; + if (!action.startsWith(c2dmPrefix)) return null; + return Constants.GOOGLE_C2DM_PREFIX + action.substring(c2dmPrefix.length()); + } + + private static void hookIntentSetPackage() { + try { + XposedBridge.hookAllMethods(Intent.class, "setPackage", new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + Intent intent = (Intent) param.thisObject; + // Defense for the specific ordering of "setAction(CHOOSE_ACCOUNT)" before "setPackage(...)". + // The reverse sequence ("setPackage" before "setAction") is guarded by hookIntentSetAction's + // afterHookedMethod and final egress in hookActivityStart. + if (isChooseAccountAction(intent.getAction())) { + param.args[0] = null; + return; + } + String pkg = (String) param.args[0]; + String redirected = redirectPackage(pkg); + if (redirected != null) param.args[0] = redirected; + } + + @Override + protected void afterHookedMethod(MethodHookParam param) { + Intent intent = (Intent) param.thisObject; + routeChooseAccountToSystem(intent); + } + }); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook Intent.setPackage", t); + } + } + + private static void hookIntentSetAction() { + try { + XposedBridge.hookAllMethods(Intent.class, "setAction", new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + Intent intent = (Intent) param.thisObject; + String action = (String) param.args[0]; + String redirected = redirectAction(action); + if (redirected != null) { + XLog.d(TAG, "Redirecting action in setAction: " + action + " -> " + redirected); + param.args[0] = redirected; + } + } + + @Override + protected void afterHookedMethod(MethodHookParam param) { + routeChooseAccountToSystem((Intent) param.thisObject); + } + }); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook Intent.setAction", t); + } + } + + private static void hookIntentGetAction() { + try { + XposedBridge.hookAllMethods(Intent.class, "getAction", new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + // On the hottest path this class hooks, so it is a prefix test and not a scan: the framework + // calls getAction on intents that have nothing to do with Google. + String restored = restoreAction((String) param.getResult()); + if (restored != null) param.setResult(restored); + } + }); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook Intent.getAction", t); + } + } + + private static void hookIntentSetComponent() { + try { + XposedBridge.hookAllMethods(Intent.class, "setComponent", new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + ComponentName cn = (ComponentName) param.args[0]; + if (cn != null) { + String redirected = redirectPackage(cn.getPackageName()); + if (redirected != null) { + param.args[0] = new ComponentName(redirected, cn.getClassName()); + } + } + } + + @Override + protected void afterHookedMethod(MethodHookParam param) { + Intent intent = (Intent) param.thisObject; + routeChooseAccountToSystem(intent); + } + }); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook Intent.setComponent", t); + } + } + + /** + * {@code setClass}, {@code setClassName} and {@code readFromParcel} assign the component without going through + * {@code setComponent}, so the rewrite there never sees them and the component would keep pointing at Google's + * package. {@code readFromParcel} is how an activity's own launch intent arrives. + */ + private static void hookIntentSetClass() { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + Intent intent = (Intent) param.thisObject; + ComponentName cn = intent.getComponent(); + if (cn == null) return; + String redirected = redirectPackage(cn.getPackageName()); + if (redirected != null) { + XLog.d(TAG, "Redirecting component from " + cn.getClassName() + "'s package"); + XposedHelpers.setObjectField(intent, "mComponent", new ComponentName(redirected, cn.getClassName())); + } + } + }; + for (String method : new String[]{"setClass", "setClassName", "readFromParcel"}) { + try { + XposedBridge.hookAllMethods(Intent.class, method, hook); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook Intent." + method, t); + } + } + } + + private static void hookIntentResolve() { + try { + XposedBridge.hookAllConstructors(Intent.class, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + Intent intent = (Intent) param.thisObject; + ComponentName cn = intent.getComponent(); + if (cn != null) { + String redirected = redirectPackage(cn.getPackageName()); + if (redirected != null) { + intent.setComponent(new ComponentName(redirected, cn.getClassName())); + } + } + String pkg = intent.getPackage(); + if (pkg != null) { + String redirected = redirectPackage(pkg); + if (redirected != null) { + intent.setPackage(redirected); + } + } + String action = intent.getAction(); + if (action != null) { + String redirectedAction = redirectAction(action); + if (redirectedAction != null) { + intent.setAction(redirectedAction); + } + } + routeChooseAccountToSystem(intent); + } + }); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook Intent constructors", t); + } + } + + /** + * Egress interceptor for direct startActivity / startActivityForResult invocations. + * Note: Only covers direct caller invocations; indirect deliveries (e.g. wrapped in PendingIntent) + * are not in this scope and rely on Intent constructor / setter hooks. + */ + private static void hookActivityStart() { + try { + XC_MethodHook egressHook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (param.args == null) return; + for (Object arg : param.args) { + if (arg instanceof Intent) { + routeChooseAccountToSystem((Intent) arg); + break; + } + } + } + }; + XposedBridge.hookAllMethods(android.content.ContextWrapper.class, "startActivity", egressHook); + XposedBridge.hookAllMethods(android.app.Activity.class, "startActivityForResult", egressHook); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook startActivity/startActivityForResult", t); + } + } + + private static void hookContentResolverAcquire() { + try { + for (String method : new String[]{ + "acquireProvider", "acquireContentProviderClient", + "acquireUnstableProvider", "acquireUnstableContentProviderClient" + }) { + try { + XposedBridge.hookAllMethods(ContentResolver.class, method, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (param.args[0] instanceof Uri) { + Uri uri = (Uri) param.args[0]; + String newAuth = redirectAuthority(uri.getAuthority()); + if (newAuth != null) { + param.args[0] = uri.buildUpon().authority(newAuth).build(); + } + } else if (param.args[0] instanceof String) { + String newAuth = redirectAuthority((String) param.args[0]); + if (newAuth != null) { + param.args[0] = newAuth; + } + } + } + }); + } catch (Throwable ignored) {} + } + + XposedBridge.hookAllMethods(ContentResolver.class, "call", new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + for (int i = 0; i < param.args.length; i++) { + if (param.args[i] instanceof Uri) { + Uri uri = (Uri) param.args[i]; + String newAuth = redirectAuthority(uri.getAuthority()); + if (newAuth != null) { + param.args[i] = uri.buildUpon().authority(newAuth).build(); + } + } else if (param.args[i] instanceof String && i == 0) { + String newAuth = redirectAuthority((String) param.args[i]); + if (newAuth != null) { + param.args[i] = newAuth; + } + } + } + } + }); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook ContentResolver", t); + } + } +} 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 50949eddf..912eb3ccc 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 @@ -143,6 +143,11 @@ public static void onLoad() throws RemoteException, IOException { switchAllClassLoader(); + // Only once the app's class loader exists: GoogleApiAvailability belongs to the client library the app + // ships, not to the framework, so there is nothing to hook before this point. Unconditional because it + // does nothing unless the redirect armed, which keeps the choice read in one place. + GmsRedirector.answerAvailabilityChecks(appLoadedApk.getClassLoader()); + // Only after the app class loader exists, and before installContentProviders runs on return // from makeApplication, so the injected DocumentsProvider is resolvable when the platform // instantiates it. @@ -255,6 +260,13 @@ private static void loadModulesAndDeliver(Context context) { } } + // Armed before the class loader for the same reason: the app's GMS client may already run from that + // initializer. + var gmsReplacementPackage = config.optString("gmsReplacementPackage", null); + if (gmsReplacementPackage != null && !gmsReplacementPackage.isEmpty()) { + GmsRedirector.activate(context, gmsReplacementPackage); + } + // Register the resource directory for legacy resource hooking. The LoadedApk constructor hook // normally does this, but the target's LoadedApk was hand-built before that hook existed. // Guarded on its own: registering the res dir loads XResources, whose runtime superclass may be diff --git a/patch/src/main/java/org/lsposed/patch/ApkPatcher.java b/patch/src/main/java/org/lsposed/patch/ApkPatcher.java index 2a92ad7e3..bd6923498 100644 --- a/patch/src/main/java/org/lsposed/patch/ApkPatcher.java +++ b/patch/src/main/java/org/lsposed/patch/ApkPatcher.java @@ -19,6 +19,11 @@ import com.wind.meditor.property.ModificationProperty; import com.wind.meditor.utils.NodeValue; +import pxb.android.axml.AxmlReader; +import pxb.android.axml.AxmlVisitor; +import pxb.android.axml.AxmlWriter; +import pxb.android.axml.NodeVisitor; + import org.apache.commons.io.FilenameUtils; import org.lsposed.lspatch.share.Constants; import org.lsposed.lspatch.share.LSPConfig; @@ -187,10 +192,12 @@ private void patchOne(File srcApkFile, File outputFile, int index, int total) th spec.injectDex(), spec.manifestOverrides().addedPermissions.toArray(new String[0]), spec.manifestOverrides().injectDocumentsProvider, + spec.manifestOverrides().gmsReplacementPackage, + spec.manifestOverrides().declareFakeSignature, spec.useManager() ? spec.managerPackageName() : null); byte[] configBytes = new Gson().toJson(config).getBytes(StandardCharsets.UTF_8); - rewriteManifest(srcZFile, dstZFile, configBytes, manifest.packageName, manifest.minSdkVersion, index, total); + rewriteManifest(srcZFile, dstZFile, configBytes, manifest.packageName, manifest.minSdkVersion, originalSignature, index, total); injectLoader(srcZFile, dstZFile, index, total); if (!spec.useManager()) { addLoaderPayload(dstZFile); @@ -240,11 +247,16 @@ private void registerSigner(ZFile dstZFile, int index, int total) throws IOExcep /** * The original app's signature, recorded so the loader can report it back to the app. * - * Only read when signature bypass is on: a patched apk is signed with LSPatch's key, and an app - * that checks its own signature would otherwise notice. Nothing needs it when the bypass is off. + * Only read when signature bypass, a Google Play services redirect or the fake signature is on: a + * patched apk is signed with LSPatch's key, and an app that checks its own signature -- or a + * replacement reporting the app to Google -- would otherwise notice. Nothing needs it when all are off. */ private String readOriginalSignature(File srcApkFile) throws IOException { - if (spec.sigBypassLevel() <= Constants.SIGBYPASS_LV_DISABLE) return null; + if (spec.sigBypassLevel() <= Constants.SIGBYPASS_LV_DISABLE + && spec.manifestOverrides().gmsReplacementPackage == null + && !spec.manifestOverrides().declareFakeSignature) { + return null; + } String signature = ApkSignatureHelper.getApkSignInfo(srcApkFile.getAbsolutePath()); if (signature == null || signature.isEmpty()) { throw new PatchException("Could not read the original apk's signature"); @@ -290,13 +302,13 @@ private void packSplit(NestedZip srcZFile, ZFile dstZFile, int index, int total) */ private void rewriteManifest( NestedZip srcZFile, ZFile dstZFile, byte[] configBytes, String packageName, int minSdkVersion, - int index, int total) + String originalSignature, int index, int total) throws IOException { logger.stage(Logger.Stage.REWRITING, index, total); logger.i("Patching apk..."); String metadata = Base64.getEncoder().encodeToString(configBytes); StoredEntry manifestEntry = Objects.requireNonNull(srcZFile.get(ANDROID_MANIFEST_XML)); - try (InputStream is = new ByteArrayInputStream(modifyManifestFile(manifestEntry.open(), metadata, packageName, minSdkVersion))) { + try (InputStream is = new ByteArrayInputStream(modifyManifestFile(manifestEntry.open(), metadata, packageName, minSdkVersion, originalSignature))) { dstZFile.add(ANDROID_MANIFEST_XML, is); } catch (IOException e) { throw new PatchException("Error when modifying manifest", e); @@ -427,7 +439,7 @@ private static boolean isSignatureEntry(String name) { && (name.endsWith(".SF") || name.endsWith(".MF") || name.endsWith(".RSA")); } - private byte[] modifyManifestFile(InputStream is, String metadata, String packageName, int minSdkVersion) + private byte[] modifyManifestFile(InputStream is, String metadata, String packageName, int minSdkVersion, String originalSignature) throws IOException { ModificationProperty property = new ModificationProperty(); @@ -438,7 +450,7 @@ private byte[] modifyManifestFile(InputStream is, String metadata, String packag property.addApplicationAttribute(new AttributeItem(NodeValue.Application.DEBUGGABLE, spec.debuggable())); property.addApplicationAttribute(new AttributeItem("appComponentFactory", PROXY_APP_COMPONENT_FACTORY)); property.addMetaData(new ModificationProperty.MetaData("lspatch", metadata)); - applyManifestOverrides(property, packageName); + applyManifestOverrides(property, packageName, originalSignature); // TODO: replace query_all with queries -> manager if (spec.useManager()) { property.addUsesPermission("android.permission.QUERY_ALL_PACKAGES"); @@ -449,7 +461,7 @@ private byte[] modifyManifestFile(InputStream is, String metadata, String packag new ManifestEditor(in, os, property).processManifest(); } os.flush(); - return os.toByteArray(); + return renameC2dmForGmsReplacement(os.toByteArray(), spec.manifestOverrides().gmsReplacementPackage); } /** @@ -461,7 +473,8 @@ private byte[] modifyManifestFile(InputStream is, String metadata, String packag * override changes the compatibility behaviours the platform applies; the two booleans flip * install-time and network policy an app otherwise fixes against a module's needs. */ - private void applyManifestOverrides(ModificationProperty property, String packageName) { + private void applyManifestOverrides(ModificationProperty property, String packageName, String originalSignature) + throws IOException { ManifestOverrides o = spec.manifestOverrides(); if (o.isEmpty()) return; if (o.versionCode != null) { @@ -493,6 +506,100 @@ private void applyManifestOverrides(ModificationProperty property, String packag if (o.injectDocumentsProvider) { addDocumentsProvider(property, packageName); } + if (o.gmsReplacementPackage != null) { + if (!Constants.isGmsReplacement(o.gmsReplacementPackage)) { + throw new PatchException(o.gmsReplacementPackage + " cannot stand in for " + + Constants.REAL_GMS_PACKAGE_NAME + "; give the package of an installed replacement"); + } + addGmsReplacementSupport(property, o.gmsReplacementPackage, originalSignature); + } + if (o.declareFakeSignature) { + logger.i("Add fake signature"); + property.addMetaData(new ModificationProperty.MetaData("fake-signature", originalSignature)); + property.addUsesPermission("android.permission.FAKE_PACKAGE_SIGNATURE"); + } + } + + /** + * Declares the app's original certificate to the chosen replacement, which otherwise sees LSPatch's. + * + * A replacement reads {@code .SPOOFED_PACKAGE_SIGNATURE} from the calling app's manifest -- + * the certificate's SHA-1 in lower-case hex -- and needs no ROM support. The key is built from the package so + * any such build is addressable, not one particular vendor. + */ + private void addGmsReplacementSupport(ModificationProperty property, String gmsReplacementPackage, String originalSignature) { + logger.i("Declare the original signature to " + gmsReplacementPackage); + property.addMetaData(new ModificationProperty.MetaData( + gmsReplacementPackage + Constants.GMS_REPLACEMENT_SIGNATURE_METADATA_SUFFIX, sha1Hex(originalSignature))); + } + + /** + * Renames every {@code com.google.android.c2dm.*} string in the manifest to the chosen replacement's + * namespace, so the pushes it delivers reach the app. Returns the manifest unchanged when none was chosen, + * and when the one chosen kept Google's C2DM names, which are then already the right ones. + * + * A replacement that renamed them looks the app's push receiver up by its own RECEIVE action, and the + * receiver only + * accepts a sender holding {@code com.google.android.c2dm.permission.SEND} -- a signature permission of + * Google Play services that no other build can hold -- so both the intent-filter action and the permissions + * have to move with it. The manifest editor maps permissions but never descends into intent filters, so this + * is a pass of its own over every attribute. The target namespace is derived from the package name, so this + * works for any such build; one whose package yields no namespace is left alone and reported. + */ + private byte[] renameC2dmForGmsReplacement(byte[] manifest, String gmsReplacementPackage) throws IOException { + if (gmsReplacementPackage == null) return manifest; + String targetPrefix = Constants.gmsReplacementC2dmPrefix(gmsReplacementPackage); + if (targetPrefix == null) { + logger.i("Push names left alone: " + gmsReplacementPackage + " keeps Google's C2DM namespace"); + return manifest; + } + logger.i("Rename C2DM names to " + targetPrefix + "*"); + AxmlWriter writer = new AxmlWriter(); + new AxmlReader(manifest).accept(new AxmlVisitor(writer) { + @Override + public NodeVisitor child(String ns, String name) { + return new C2dmRenamer(super.child(ns, name), targetPrefix); + } + }); + return writer.toByteArray(); + } + + private static final class C2dmRenamer extends NodeVisitor { + private final String targetPrefix; + + C2dmRenamer(NodeVisitor nv, String targetPrefix) { + super(nv); + this.targetPrefix = targetPrefix; + } + + @Override + public NodeVisitor child(String ns, String name) { + return new C2dmRenamer(super.child(ns, name), targetPrefix); + } + + @Override + public void attr(String ns, String name, int resourceId, int type, Object obj) { + if (obj instanceof String && ((String) obj).startsWith(Constants.GOOGLE_C2DM_PREFIX)) { + obj = targetPrefix + ((String) obj).substring(Constants.GOOGLE_C2DM_PREFIX.length()); + } + super.attr(ns, name, resourceId, type, obj); + } + } + + /** SHA-1 of a certificate given as hex, as lower-case hex. */ + static String sha1Hex(String certificateHex) { + byte[] certificate = new byte[certificateHex.length() / 2]; + for (int i = 0; i < certificate.length; i++) { + certificate[i] = (byte) Integer.parseInt(certificateHex.substring(2 * i, 2 * i + 2), 16); + } + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-1").digest(certificate); + StringBuilder hex = new StringBuilder(digest.length * 2); + for (byte b : digest) hex.append(String.format(Locale.ROOT, "%02x", b)); + return hex.toString(); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } } /** diff --git a/patch/src/main/java/org/lsposed/patch/LSPatch.java b/patch/src/main/java/org/lsposed/patch/LSPatch.java index a4d2cfc86..eac0f09a7 100644 --- a/patch/src/main/java/org/lsposed/patch/LSPatch.java +++ b/patch/src/main/java/org/lsposed/patch/LSPatch.java @@ -83,6 +83,12 @@ public class LSPatch { @Parameter(names = {"--documents-provider"}, description = "Inject a DocumentsProvider exposing the app's private data to the system file picker") private boolean injectDocumentsProvider = false; + @Parameter(names = {"--gms-replacement"}, description = "Redirect Google Play services calls and pushes to this replacement package (e.g. vendor.android.gms) and declare the original signature to it") + private String gmsReplacementPackage = null; + + @Parameter(names = {"--fake-signature"}, description = "Declare the original signature as fake-signature, for a ROM with signature spoofing") + private boolean declareFakeSignature = false; + private final JCommander jCommander; public LSPatch(String... args) { @@ -138,6 +144,8 @@ public PatchSpec toSpec() throws PatchException { .map(ManifestOverrides::normalizePermission) .collect(Collectors.toList())) .injectDocumentsProvider(injectDocumentsProvider) + .gmsReplacementPackage(gmsReplacementPackage) + .declareFakeSignature(declareFakeSignature) .build(); return PatchSpec.builder() .apks(apkPaths.stream().map(File::new).collect(Collectors.toList())) diff --git a/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java b/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java index a40cc3945..e7579683d 100644 --- a/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java +++ b/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java @@ -67,6 +67,29 @@ public final class ManifestOverrides { */ public final boolean injectDocumentsProvider; + /** + * The replacement to redirect the app's Google Play services traffic to, or null to leave it alone. + * + * A patched app is re-signed, so Google Play services rejects it wherever it checks the caller's certificate. + * A replacement answers the same requests under its own package; the redirect ships in the loader, while + * the patch declares the original certificate in the metadata that build reads and renames the app's C2DM + * names to its namespace. Which build is used is the user's choice, so it is carried as a package name -- + * nothing here is specific to one vendor. A build that installs as Google Play services itself needs none of + * this; see {@link #declareFakeSignature}. + * + * The app must also be able to see the chosen package, which its own {@code } do not cover: a + * local-mode app already holds {@code QUERY_ALL_PACKAGES}, and an integrated one needs it added. + */ + public final String gmsReplacementPackage; + + /** + * Whether to declare the original certificate as {@code fake-signature}, with {@code FAKE_PACKAGE_SIGNATURE}, for a + * ROM with signature spoofing to report in place of LSPatch's. A replacement installed as Google Play services + * itself reads no metadata of its own, so this is how that setup sees the original certificate. Only takes + * effect on a ROM that implements the spoofing. + */ + public final boolean declareFakeSignature; + private ManifestOverrides(Builder b) { this.versionCode = b.versionCode; this.label = b.label; @@ -75,13 +98,16 @@ private ManifestOverrides(Builder b) { this.usesCleartextTraffic = b.usesCleartextTraffic; this.addedPermissions = Collections.unmodifiableList(new ArrayList<>(b.addedPermissions)); this.injectDocumentsProvider = b.injectDocumentsProvider; + this.gmsReplacementPackage = b.gmsReplacementPackage; + this.declareFakeSignature = b.declareFakeSignature; } /** True when nothing is overridden, so the patcher can skip the work entirely. */ public boolean isEmpty() { return versionCode == null && label == null && targetSdkVersion == null && extractNativeLibs == null && usesCleartextTraffic == null - && addedPermissions.isEmpty() && !injectDocumentsProvider; + && addedPermissions.isEmpty() && !injectDocumentsProvider && gmsReplacementPackage == null + && !declareFakeSignature; } /** @@ -121,6 +147,8 @@ public static final class Builder { // order the user added them in is what the report and the re-patch see. private final LinkedHashSet addedPermissions = new LinkedHashSet<>(); private boolean injectDocumentsProvider; + private String gmsReplacementPackage; + private boolean declareFakeSignature; public Builder versionCode(Integer versionCode) { this.versionCode = versionCode; @@ -167,6 +195,18 @@ public Builder injectDocumentsProvider(boolean injectDocumentsProvider) { return this; } + public Builder gmsReplacementPackage(String gmsReplacementPackage) { + this.gmsReplacementPackage = (gmsReplacementPackage != null && !gmsReplacementPackage.isEmpty()) + ? gmsReplacementPackage + : null; + return this; + } + + public Builder declareFakeSignature(boolean declareFakeSignature) { + this.declareFakeSignature = declareFakeSignature; + return this; + } + public ManifestOverrides build() { return new ManifestOverrides(this); } 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 65bd4a279..9efb77df8 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 @@ -22,6 +22,71 @@ public class Constants { final static public String MIGRATE_ASSET_PATH = "assets/lspatch/migrate.zip"; final static public int MIN_ROLLING_VERSION_CODE = 348; + final static public String REAL_GMS_PACKAGE_NAME = "com.google.android.gms"; + + /** + * What a Google Play services replacement is called. + * + * Every such build is a fork of the same service implementation and keeps the trailing segment, whether it + * renamed the rest of itself or not. Requiring it is not ceremony: the redirect sends an app's Google + * traffic wherever it points, so accepting any package at all would let that traffic be aimed at something + * that is not a replacement. + */ + final static public String GMS_REPLACEMENT_SUFFIX = ".gms"; + + /** + * The shape of a replacement that renamed its own namespace, and so its C2DM names with it. + * + * Not every one does. A build renamed to {@code .android.gms} renames its permissions and push + * names so it can be installed beside Google's own; one that keeps Google's names for those cannot be, and + * is meant for a device with no Google Play services to clash with. Only the second question is answered + * from the name. + */ + final static public String GMS_VENDOR_NAMESPACE_SUFFIX = ".android.gms"; + + /** + * The C2DM namespace every Google Play services client is built against. + * + * Shared because the patcher and the loader have to agree on it exactly: the patcher renames these names in + * the app's manifest, and the loader rewrites the same names on the intents the app sends, so a rule that + * drifted between the two would leave pushes addressed to a name nothing declares. + */ + final static public String GOOGLE_C2DM_PREFIX = "com.google.android.c2dm."; + + /** The metadata key a replacement reads the calling app's certificate from, under its own package. */ + final static public String GMS_REPLACEMENT_SIGNATURE_METADATA_SUFFIX = ".SPOOFED_PACKAGE_SIGNATURE"; + + /** Whether [packageName] can be redirected to: a replacement's own package, not Google's. */ + public static boolean isGmsReplacement(String packageName) { + return packageName != null + && packageName.endsWith(GMS_REPLACEMENT_SUFFIX) + && packageName.length() > GMS_REPLACEMENT_SUFFIX.length() + && !REAL_GMS_PACKAGE_NAME.equals(packageName); + } + + /** + * Whether [packageName] renamed Google's namespaces along with its own package. + * + * Such a build renames its permissions, its provider authorities and its C2DM names so that it can be + * installed beside Google's own. A build that keeps Google's names for those cannot be, and serves them + * under Google's names -- so rewriting them for it would address names nothing serves. + */ + public static boolean renamesGoogleNamespaces(String packageName) { + return isGmsReplacement(packageName) && packageName.endsWith(GMS_VENDOR_NAMESPACE_SUFFIX); + } + + /** The vendor prefix of a replacement that renamed its namespaces, or null when it kept Google's. */ + public static String gmsReplacementVendor(String packageName) { + if (!renamesGoogleNamespaces(packageName)) return null; + return packageName.substring(0, packageName.length() - GMS_VENDOR_NAMESPACE_SUFFIX.length()); + } + + /** The C2DM namespace of a replacement that renamed its own, or null when it kept Google's. */ + public static String gmsReplacementC2dmPrefix(String packageName) { + String vendor = gmsReplacementVendor(packageName); + return vendor == null ? null : vendor + ".android.c2dm."; + } + final static public int SIGBYPASS_LV_DISABLE = 0; final static public int SIGBYPASS_LV_PM = 1; final static public int SIGBYPASS_LV_PM_OPENAT = 2; 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 2f2f34726..07535d526 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 @@ -33,6 +33,18 @@ public class PatchConfig { */ public final boolean injectDocumentsProvider; + /** + * The replacement the app's Google Play services traffic is redirected to, or null when it is not. + * + * A package name rather than a flag: the metadata key the patcher writes and the C2DM names it renames are + * both derived from it, so the build the user chose has to survive into the apk. Read by the loader, and + * recorded for the same reason as {@link #addedPermissions}: a re-patch would otherwise silently drop it. + */ + public final String gmsReplacementPackage; + + /** Whether {@code fake-signature} was declared. Recorded for the same reason as {@link #addedPermissions}. */ + public final boolean declareFakeSignature; + /** * The manager package a manager-mode app should bind to for module loading, or null for an * apk patched before this was recorded (and for integrated apps, which never bind a manager). @@ -53,6 +65,8 @@ public PatchConfig( boolean injectDex, String[] addedPermissions, boolean injectDocumentsProvider, + String gmsReplacementPackage, + boolean declareFakeSignature, String managerPackageName ) { this.useManager = useManager; @@ -64,6 +78,10 @@ public PatchConfig( this.injectDex = injectDex; this.addedPermissions = addedPermissions; this.injectDocumentsProvider = injectDocumentsProvider; + this.gmsReplacementPackage = (gmsReplacementPackage != null && !gmsReplacementPackage.isEmpty()) + ? gmsReplacementPackage + : null; + this.declareFakeSignature = declareFakeSignature; if (useManager) { this.managerPackageName = (managerPackageName != null && !managerPackageName.isEmpty()) ? managerPackageName