diff --git a/manager/src/main/java/org/lsposed/lspatch/Patcher.kt b/manager/src/main/java/org/lsposed/lspatch/Patcher.kt index 91eaaab68..bb32d062e 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) + .useMicroGRe(useMicroGRe) + .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..41bfefda2 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,10 @@ 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, + // Redirect Google Play services to an installed MicroG RE. Recorded in the config for the same reason. + val useMicroGRe: Boolean = false, + // 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..1a1266035 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("MicroG RE ${request.useMicroGRe}") + 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/page/AppDetailScreen.kt b/manager/src/main/java/org/lsposed/lspatch/ui/page/AppDetailScreen.kt index 5148a09f3..1561bda06 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,14 @@ fun AppDetailScreen(navigator: Navigator, packageName: String) { stringResource(R.string.appdetail_info_inject_dex), yesNo(config?.injectDex == true), ) + InfoRow( + stringResource(R.string.patch_manifest_microg_re), + yesNo(config?.useMicroGRe == true), + ) + InfoRow( + stringResource(R.string.patch_manifest_fake_signature), + yesNo(config?.declareFakeSignature == true), + ) // 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..331ff5140 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 @@ -38,6 +38,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Notes import androidx.compose.material.icons.outlined.Ballot +import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Android import androidx.compose.material.icons.rounded.Api @@ -63,6 +64,7 @@ import androidx.compose.material.icons.rounded.Share import androidx.compose.material.icons.rounded.Smartphone 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 @@ -340,6 +342,8 @@ fun NewPatchScreen(navigator: Navigator, token: String) { onAddPermission = viewModel::addPermission, onRemovePermission = viewModel::removePermission, onInjectDocumentsProvider = viewModel::setInjectDocumentsProvider, + onUseMicroGRe = viewModel::setUseMicroGRe, + onDeclareFakeSignature = viewModel::setDeclareFakeSignature, ) else -> Column( @@ -387,6 +391,8 @@ private fun ConfigureBody( onAddPermission: (String) -> Unit, onRemovePermission: (String) -> Unit, onInjectDocumentsProvider: (Boolean) -> Unit, + onUseMicroGRe: (Boolean) -> Unit, + onDeclareFakeSignature: (Boolean) -> Unit, ) { val scope = rememberCoroutineScope() val snackbarHost = LocalSnackbarHost.current @@ -595,6 +601,20 @@ private fun ConfigureBody( checked = request.injectDocumentsProvider, onCheckedChange = onInjectDocumentsProvider, ) + ToggleRow( + title = stringResource(R.string.patch_manifest_microg_re), + icon = Icons.Rounded.AccountCircle, + subtitle = stringResource(R.string.patch_manifest_microg_re_desc), + checked = request.useMicroGRe, + onCheckedChange = onUseMicroGRe, + ) + ToggleRow( + title = stringResource(R.string.patch_manifest_fake_signature), + icon = Icons.Rounded.VerifiedUser, + subtitle = stringResource(R.string.patch_manifest_fake_signature_desc), + checked = request.declareFakeSignature, + onCheckedChange = onDeclareFakeSignature, + ) // Last of the manifest controls: its chip list and field are the tallest thing here, // so it sits below the compact toggles rather than pushing them down the screen. PermissionEditor( @@ -779,6 +799,12 @@ private fun advancedChips(request: PatchRequest): List = buildLi if (request.injectDocumentsProvider) { add(OptionChipData(stringResource(R.string.patch_manifest_documents_provider), true, Icons.Rounded.FolderOpen)) } + if (request.useMicroGRe) { + add(OptionChipData(stringResource(R.string.patch_manifest_microg_re), true, Icons.Rounded.AccountCircle)) + } + if (request.declareFakeSignature) { + add(OptionChipData(stringResource(R.string.patch_manifest_fake_signature), true, Icons.Rounded.VerifiedUser)) + } if (!MyKeyStore.useDefault) { add(OptionChipData(stringResource(R.string.settings_keystore_custom), true, Icons.Outlined.Ballot)) } 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..9536146f1 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, + useMicroGRe = config?.useMicroGRe ?: false, + 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..f1958aa53 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 setUseMicroGRe(value: Boolean) = mutate { it.copy(useMicroGRe = 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/LocalAppsUpdater.kt b/manager/src/main/java/org/lsposed/lspatch/util/LocalAppsUpdater.kt index 34dabfc62..601b47ff0 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) + .useMicroGRe(config.useMicroGRe) + .declareFakeSignature(config.declareFakeSignature) .build() ) .keystore( diff --git a/manager/src/main/res/values-zh-rCN/strings.xml b/manager/src/main/res/values-zh-rCN/strings.xml index 6fc417ca8..632328b4f 100644 --- a/manager/src/main/res/values-zh-rCN/strings.xml +++ b/manager/src/main/res/values-zh-rCN/strings.xml @@ -269,6 +269,10 @@ 移除 已添加 %1$d 个 暴露私有数据 + MicroG RE 支持 + 将 Google Play 服务的调用与推送转向已安装的 MicroG RE(ReVanced GmsCore),并向它声明原始签名,使重新签名后仍能使用 Google 登录与推送。集成模式需另外加入 QUERY_ALL_PACKAGES 权限。不适用原版 microG。 + 声明伪装签名 + 以 fake-signature 声明原始签名,让支持签名伪装的 ROM 报告原始签名,供原版 microG 使用。ROM 不支持签名伪装时没有作用。 添加一个提供程序,让文件管理器无需 root 即可通过系统文件选择器浏览应用的私有数据目录。这会扩大可访问应用数据的范围——若无需要请保持关闭。 包名 更改包名 diff --git a/manager/src/main/res/values-zh-rTW/strings.xml b/manager/src/main/res/values-zh-rTW/strings.xml index c095b3448..c184e3985 100644 --- a/manager/src/main/res/values-zh-rTW/strings.xml +++ b/manager/src/main/res/values-zh-rTW/strings.xml @@ -165,6 +165,10 @@ 移除 已新增 %1$d 個 公開私有資料 + MicroG RE 支援 + 將 Google Play 服務的呼叫與推播導向已安裝的 MicroG RE(ReVanced GmsCore),並向它宣告原始簽章,讓重新簽名後仍能使用 Google 登入與推播。整合模式需另外加入 QUERY_ALL_PACKAGES 權限。不適用原版 microG。 + 宣告偽裝簽章 + 以 fake-signature 宣告原始簽章,讓支援簽章偽裝的 ROM 回報原始簽章,供原版 microG 使用。ROM 不支援簽章偽裝時沒有作用。 新增一個提供者,讓檔案管理器無需 root 即可透過系統檔案選擇器瀏覽應用程式的私有資料目錄。這會擴大可存取應用程式資料的範圍——若無需要請保持關閉。 選擇要修補的應用程式 正在載入已安裝的應用程式… diff --git a/manager/src/main/res/values/strings.xml b/manager/src/main/res/values/strings.xml index 764aa6648..1dd613215 100644 --- a/manager/src/main/res/values/strings.xml +++ b/manager/src/main/res/values/strings.xml @@ -119,6 +119,10 @@ Remove %1$d added Expose private data + MicroG RE support + Send Google Play services calls and pushes to an installed MicroG RE (ReVanced GmsCore) and declare the original signature to it, so Google sign-in and push keep working after re-signing. In Integrated mode, also add the QUERY_ALL_PACKAGES permission. Not for upstream microG. + Declare fake signature + Declare the original signature as fake-signature, for a ROM with signature spoofing to report in place of the patched one — for upstream microG. No effect on a ROM without signature spoofing. Add a provider so a file manager can browse the app\'s private data folder through the system file picker, no root needed. Widens what can reach the app\'s data — leave off unless you need it. Patching… Patch failed 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..6eb46bedb --- /dev/null +++ b/patch-loader/src/main/java/org/lsposed/lspatch/loader/GmsRedirector.java @@ -0,0 +1,446 @@ +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 java.util.HashMap; +import java.util.Map; + +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 MicroG RE (ReVanced GmsCore). + * + * 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. MicroG RE answers the same requests under its own package, so this + * rewrites the package, component, provider authority and action of every GMS-bound intent or provider call to it. + * Which certificate MicroG RE then reports for the app is decided by the manifest metadata the patcher wrote, not here. + * + * MicroG RE renames the package to {@code app.revanced.android.gms} and every action along with it, so actions are + * rewritten too. Upstream microG is not a target: it installs as Google Play services itself, so nothing needs + * redirecting, and the patcher's C2DM rename is MicroG RE's. + * + * An intent the app addresses to itself is never rewritten: the Google sign-in library opens its own + * {@code SignInHubActivity} with {@code com.google.android.gms.auth.GOOGLE_SIGN_IN}, 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; + private static String selfPackage = null; + + public static void activate(Context context) { + selfPackage = context.getPackageName(); + + if (!isInstalled(context, Constants.MICROG_RE_PACKAGE_NAME)) { + Log.w(TAG, "MicroG RE is not installed or not visible to the app (add QUERY_ALL_PACKAGES), GMS redirect disabled"); + return; + } + targetGms = Constants.MICROG_RE_PACKAGE_NAME; + + 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"); + } + + private static boolean isInstalled(Context context, String pkg) { + try { + context.getPackageManager().getPackageInfo(pkg, 0); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + static void setTargetGmsForTest(String target, String self) { + targetGms = target; + selfPackage = self; + setupC2dmRedirects(); + } + + static String redirectPackage(String pkg) { + if (REAL_GMS.equals(pkg) || "com.google.android.gsf".equals(pkg)) { + return targetGms; + } + return null; + } + + // MicroG does not provide Dynamite/Chimera modules. Redirecting chimera causes + // provider Authority mismatch and Uri disagreements. Left untouched, real GMS answers it. + private static final String CHIMERA_AUTHORITY = REAL_GMS + ".chimera"; + + static String redirectAuthority(String authority) { + if (authority == null) return null; + if (CHIMERA_AUTHORITY.equals(authority)) return null; + if (authority.startsWith(REAL_GMS + ".")) { + return targetGms + authority.substring(REAL_GMS.length()); + } + if (authority.equals(REAL_GMS)) { + return targetGms; + } + if (authority.startsWith("com.google.android.gsf")) { + return authority.replace("com.google.android.gsf", targetGms); + } + 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; + } + + // Standard Google C2DM actions that need vendor redirection if targetGms has a custom namespace. + private static final Map c2dmRedirectMap = new HashMap<>(); + private static final String[] C2DM_STANDARD_ACTIONS = { + "com.google.android.c2dm.intent.REGISTER", + "com.google.android.c2dm.intent.RECEIVE", + "com.google.android.c2dm.intent.UNREGISTER", + "com.google.android.c2dm.intent.REGISTRATION", + }; + private static final String GMS_ACTION_PREFIX = "com.google.android.gms."; + + private static void setupC2dmRedirects() { + c2dmRedirectMap.clear(); + if (hasVendorNamespace()) { + String vendorC2dmPrefix = targetGms.substring(0, targetGms.length() - ".android.gms".length()) + ".android.c2dm"; + for (String standard : C2DM_STANDARD_ACTIONS) { + c2dmRedirectMap.put(standard, vendorC2dmPrefix + standard.substring("com.google.android.c2dm".length())); + } + Log.i(TAG, "C2DM redirect prefix derived: " + vendorC2dmPrefix); + } else { + Log.i(TAG, "C2DM redirect disabled for targetGms: " + targetGms); + } + } + + /** Whether the MicroG build renamed {@code com.google.android.gms} to {@code .android.gms}, actions included. */ + private static boolean hasVendorNamespace() { + return targetGms != null && !REAL_GMS.equals(targetGms) && targetGms.endsWith(".android.gms"); + } + + 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); + } + } + + static String redirectAction(String action) { + if (action == null) return null; + if (isChooseAccountAction(action)) return null; + String redirected = c2dmRedirectMap.get(action); + if (redirected != null) return redirected; + if (hasVendorNamespace() && action.startsWith(GMS_ACTION_PREFIX)) { + return targetGms + "." + action.substring(GMS_ACTION_PREFIX.length()); + } + return null; + } + + /** The action {@link #redirectAction} rewrote into {@code action}, or null if it is not a rewritten one. */ + static String originalAction(String action) { + if (action == null) return null; + for (Map.Entry entry : c2dmRedirectMap.entrySet()) { + if (entry.getValue().equals(action)) return entry.getKey(); + } + if (hasVendorNamespace() && action.startsWith(targetGms + ".") && !isChooseAccountAction(action)) { + return GMS_ACTION_PREFIX + action.substring(targetGms.length() + 1); + } + return null; + } + + static boolean isSelfTargeted(String pkg, ComponentName component) { + if (selfPackage == null) return false; + return selfPackage.equals(pkg) || (component != null && selfPackage.equals(component.getPackageName())); + } + + /** + * Undoes the action rewrite on an intent addressed to the app itself. + * + * The action is usually set before the target -- {@code new Intent(action).setClass(...)} -- so it has already been + * rewritten by the time the intent turns out to be the app's own. The field is written directly because + * {@code setAction} is hooked and would rewrite it again. + */ + private static void restoreSelfTargetedAction(Intent intent) { + if (intent == null || !isSelfTargeted(intent.getPackage(), intent.getComponent())) return; + String action = (String) XposedHelpers.getObjectField(intent, "mAction"); + String original = originalAction(action); + if (original != null) { + XLog.d(TAG, "Keeping app-internal action: " + action + " -> " + original); + XposedHelpers.setObjectField(intent, "mAction", original); + } + } + + 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; + restoreSelfTargetedAction(intent); + 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; + if (isSelfTargeted(intent.getPackage(), intent.getComponent())) return; + 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) { + String action = (String) param.getResult(); + if (action != null && targetGms != null) { + for (Map.Entry entry : c2dmRedirectMap.entrySet()) { + if (entry.getValue().equals(action)) { + XLog.d(TAG, "Restoring original action for getAction: " + action + " -> " + entry.getKey()); + param.setResult(entry.getKey()); + return; + } + } + } + } + }); + } 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; + restoreSelfTargetedAction(intent); + routeChooseAccountToSystem(intent); + } + }); + } catch (Throwable t) { + Log.e(TAG, "Failed to hook Intent.setComponent", t); + } + } + + /** + * {@code setClass} and {@code setClassName} assign the component without going through {@code setComponent}, and + * {@code readFromParcel} -- how an activity's own launch intent arrives -- reads the action through + * {@code setAction} before it reads the component. + */ + private static void hookIntentSetClass() { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + restoreSelfTargetedAction((Intent) param.thisObject); + } + }; + 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); + } + } + restoreSelfTargetedAction(intent); + 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) { + restoreSelfTargetedAction((Intent) arg); + 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 06d0b13d6..16ca737ad 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 @@ -134,6 +134,11 @@ public static void onLoad() throws RemoteException, IOException { // realized, so a signature/APK read performed from that initializer already sees the spoof. SigBypass.doSigBypass(context, config.optInt("sigBypassLevel")); + // Armed before the class loader for the same reason: the app's GMS client may already run from that initializer. + if (config.optBoolean("useMicroGRe")) { + GmsRedirector.activate(context); + } + // Realize the target's class loader now that hooks, modules and signature bypass are all armed. // getClassLoader() -> createOrUpdateClassLoaderLocked -> createAppFactory triggers // onPackageLoaded (pre-) then, on return, onPackageReady and legacy handleLoadPackage. diff --git a/patch/src/main/java/org/lsposed/patch/ApkPatcher.java b/patch/src/main/java/org/lsposed/patch/ApkPatcher.java index 2a92ad7e3..36cd39ec5 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().useMicroGRe, + 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,15 @@ 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, MicroG RE or the fake signature is on: a patched apk is signed + * with LSPatch's key, and an app that checks its own signature -- or MicroG, 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().useMicroGRe && !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 +301,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 +438,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 +449,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 +460,7 @@ private byte[] modifyManifestFile(InputStream is, String metadata, String packag new ManifestEditor(in, os, property).processManifest(); } os.flush(); - return os.toByteArray(); + return spec.manifestOverrides().useMicroGRe ? renameC2dmForMicroGRe(os.toByteArray()) : os.toByteArray(); } /** @@ -461,7 +472,7 @@ 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) { ManifestOverrides o = spec.manifestOverrides(); if (o.isEmpty()) return; if (o.versionCode != null) { @@ -493,6 +504,86 @@ private void applyManifestOverrides(ModificationProperty property, String packag if (o.injectDocumentsProvider) { addDocumentsProvider(property, packageName); } + if (o.useMicroGRe) { + addMicroGReSupport(property, 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 MicroG RE, which otherwise reports LSPatch's to Google. + * + * MicroG RE reads {@code app.revanced.android.gms.SPOOFED_PACKAGE_SIGNATURE} from the calling app's + * manifest -- the certificate's SHA-1 in lower-case hex -- and needs no ROM support. + */ + private void addMicroGReSupport(ModificationProperty property, String originalSignature) { + logger.i("Add MicroG RE support"); + property.addMetaData(new ModificationProperty.MetaData( + Constants.MICROG_RE_PACKAGE_NAME + ".SPOOFED_PACKAGE_SIGNATURE", sha1Hex(originalSignature))); + } + + private static final String GOOGLE_C2DM_PREFIX = "com.google.android.c2dm."; + private static final String MICROG_RE_C2DM_PREFIX = Constants.MICROG_RE_PACKAGE_NAME.substring( + 0, Constants.MICROG_RE_PACKAGE_NAME.length() - ".gms".length()) + ".c2dm."; + + /** + * Renames every {@code com.google.android.c2dm.*} string in the manifest to ReVanced GmsCore's + * {@code app.revanced.android.c2dm.*}, so pushes MicroG delivers reach the app. + * + * MicroG 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 MicroG cannot hold; both the intent-filter action and the permissions have to + * be renamed. The manifest editor maps permissions but never descends into intent filters, so this + * is a pass of its own over every attribute. + */ + private static byte[] renameC2dmForMicroGRe(byte[] manifest) throws IOException { + 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)); + } + }); + return writer.toByteArray(); + } + + private static final class C2dmRenamer extends NodeVisitor { + C2dmRenamer(NodeVisitor nv) { + super(nv); + } + + @Override + public NodeVisitor child(String ns, String name) { + return new C2dmRenamer(super.child(ns, name)); + } + + @Override + public void attr(String ns, String name, int resourceId, int type, Object obj) { + if (obj instanceof String && ((String) obj).startsWith(GOOGLE_C2DM_PREFIX)) { + obj = MICROG_RE_C2DM_PREFIX + ((String) obj).substring(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..a0305558c 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 = {"--microg-re"}, description = "Redirect Google Play services calls and pushes to an installed MicroG RE (ReVanced GmsCore) and declare the original signature to it") + private boolean useMicroGRe = false; + + @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) + .useMicroGRe(useMicroGRe) + .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..5ae5aa333 100644 --- a/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java +++ b/patch/src/main/java/org/lsposed/patch/ManifestOverrides.java @@ -67,6 +67,25 @@ public final class ManifestOverrides { */ public final boolean injectDocumentsProvider; + /** + * Whether to redirect the app's Google Play services traffic to an installed MicroG RE (ReVanced GmsCore), so + * Google sign-in and push keep working for an app re-signed with LSPatch's key. The redirect ships in the loader; + * this declares the original certificate in the {@code SPOOFED_PACKAGE_SIGNATURE} metadata MicroG RE reads, + * and renames the app's C2DM actions and permissions to MicroG RE's. Specific to MicroG RE: upstream microG + * installs as Google Play services itself and needs none of this. + * + * The app must also be able to see MicroG RE, which its own {@code } do not cover: a local-mode + * app already holds {@code QUERY_ALL_PACKAGES}, and an integrated one needs it added as a permission. + */ + public final boolean useMicroGRe; + + /** + * 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. Upstream microG has no metadata of its own for this, + * so it 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 +94,16 @@ private ManifestOverrides(Builder b) { this.usesCleartextTraffic = b.usesCleartextTraffic; this.addedPermissions = Collections.unmodifiableList(new ArrayList<>(b.addedPermissions)); this.injectDocumentsProvider = b.injectDocumentsProvider; + this.useMicroGRe = b.useMicroGRe; + 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 && !useMicroGRe + && !declareFakeSignature; } /** @@ -121,6 +143,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 boolean useMicroGRe; + private boolean declareFakeSignature; public Builder versionCode(Integer versionCode) { this.versionCode = versionCode; @@ -167,6 +191,16 @@ public Builder injectDocumentsProvider(boolean injectDocumentsProvider) { return this; } + public Builder useMicroGRe(boolean useMicroGRe) { + this.useMicroGRe = useMicroGRe; + 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..c12c71d2d 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,13 @@ 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"; + /** + * ReVanced GmsCore, the MicroG build that reads a calling app's certificate from the app's manifest metadata + * {@code .SPOOFED_PACKAGE_SIGNATURE} rather than relying on a ROM's signature spoofing. + */ + final static public String MICROG_RE_PACKAGE_NAME = "app.revanced.android.gms"; + 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..8a7dc2f41 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,15 @@ public class PatchConfig { */ public final boolean injectDocumentsProvider; + /** + * Whether the app's Google Play services traffic is redirected to an installed MicroG RE. Read by the loader, and + * recorded for the same reason as {@link #addedPermissions}: a re-patch would otherwise silently drop it. + */ + public final boolean useMicroGRe; + + /** 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 +62,8 @@ public PatchConfig( boolean injectDex, String[] addedPermissions, boolean injectDocumentsProvider, + boolean useMicroGRe, + boolean declareFakeSignature, String managerPackageName ) { this.useManager = useManager; @@ -64,6 +75,8 @@ public PatchConfig( this.injectDex = injectDex; this.addedPermissions = addedPermissions; this.injectDocumentsProvider = injectDocumentsProvider; + this.useMicroGRe = useMicroGRe; + this.declareFakeSignature = declareFakeSignature; if (useManager) { this.managerPackageName = (managerPackageName != null && !managerPackageName.isEmpty()) ? managerPackageName