diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3cfd77b..81ac7ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,8 +101,8 @@ android { applicationId = "com.ez.zalopatch" minSdk = 24 targetSdk = 34 - versionCode = 160 - versionName = "0.4.156-report-field-contract+B160-20260816T1756Z" + versionCode = 162 + versionName = "0.4.158-non-root-capabilities+B162-20260820T1521Z" testInstrumentationRunner = "android.test.InstrumentationTestRunner" buildConfigField("String", "DIAGNOSTIC_INTAKE_URL", "\"$diagnosticIntakeUri\"") buildConfigField("String", "SYMBOL_CATALOG_URL", "\"$symbolCatalogUri\"") diff --git a/app/src/androidTest/java/com/ez/zalopatch/DiagnosticReportingTest.java b/app/src/androidTest/java/com/ez/zalopatch/DiagnosticReportingTest.java index 51957c9..d2187c6 100644 --- a/app/src/androidTest/java/com/ez/zalopatch/DiagnosticReportingTest.java +++ b/app/src/androidTest/java/com/ez/zalopatch/DiagnosticReportingTest.java @@ -21,6 +21,10 @@ public void testMetadataEnvelopeUsesSharedIntakeContractWithoutPrivatePayloadFie assertTrue(BuildConfig.DIAGNOSTIC_INTAKE_URL.endsWith("/v1/reports")); JSONObject product = root.getJSONObject("productMetadata"); + assertTrue(product.has("runtimeFramework")); + assertTrue(product.has("runtimeEnvironmentReported")); + assertTrue(product.has("resourceHooksObserved")); + assertTrue(product.has("resourceHooksStatus")); JSONObject remap = product.getJSONObject("remapEvidence"); assertFalse(remap.getBoolean("requestedForReport")); assertTrue(remap.has("exactBundledProfileMapped")); @@ -34,7 +38,11 @@ public void testMetadataEnvelopeUsesSharedIntakeContractWithoutPrivatePayloadFie assertTrue(setup.has("symbolSchemaValid")); assertTrue(setup.has("runtimeSelfCheckPresent")); assertTrue(setup.has("internetPermissionGranted")); - assertEquals("not_checked", setup.getString("rootAccessStatus")); + String rootStatus = setup.getString("rootAccessStatus"); + assertTrue("not_checked".equals(rootStatus) + || "granted".equals(rootStatus) + || "denied".equals(rootStatus) + || "error".equals(rootStatus)); JSONObject raw = root.getJSONObject("rawDiagnostics"); assertEquals("", raw.getString("diagnosticEventsAndLogs")); @@ -133,4 +141,83 @@ public void testRuntimeDiscoveryReceiverFallbackPersistsEvidenceAndCompletion() TweakStore.initialize(getContext()); } } + + public void testRuntimeEnvironmentProviderAndReceiverFallbackPersistEvidence() { + long zaloVersion = SymbolSchema.installedZaloVersionCode(getContext()); + java.util.Map previous = RuntimeEnvironment.snapshotForTest(getContext()); + RuntimeEnvironment.clearForTest(getContext()); + try { + android.os.Bundle extras = new android.os.Bundle(); + extras.putString("framework", "lspatch"); + extras.putBoolean("resource_hooks_observed", false); + extras.putInt("module_version_code", BuildConfig.VERSION_CODE); + extras.putLong("zalo_version_code", zaloVersion); + android.os.Bundle response = getContext().getContentResolver().call( + android.net.Uri.parse("content://com.ez.zalopatch.config"), + "record_runtime_environment", null, extras); + assertNotNull(response); + assertTrue(response.getBoolean("recorded", false)); + RuntimeEnvironment.Snapshot provider = RuntimeEnvironment.current(getContext()); + assertTrue(provider.reported); + assertEquals(RuntimeEnvironment.Framework.LSPATCH, provider.framework); + assertFalse(provider.resourceHooksObserved); + assertEquals(RuntimeEnvironment.ResourceHooks.PENDING, provider.resourceHooks); + + if (android.os.Build.VERSION.SDK_INT < 34) return; + android.content.Intent fallback = new android.content.Intent( + SelfCheckReceiver.ACTION_RECORD_RUNTIME_ENVIRONMENT) + .setComponent(new android.content.ComponentName(getContext(), + SelfCheckReceiver.class)) + .putExtra("framework", "lspatch") + .putExtra("resource_hooks_status", "observed") + .putExtra("resource_hooks_observed", true) + .putExtra("module_version_code", BuildConfig.VERSION_CODE) + .putExtra("zalo_version_code", zaloVersion); + android.app.BroadcastOptions options = android.app.BroadcastOptions.makeBasic(); + options.setShareIdentityEnabled(true); + getContext().sendBroadcast(fallback, null, options.toBundle()); + long deadline = android.os.SystemClock.uptimeMillis() + 2_000L; + while (!RuntimeEnvironment.current(getContext()).resourceHooksObserved + && android.os.SystemClock.uptimeMillis() < deadline) { + android.os.SystemClock.sleep(25L); + } + assertTrue(RuntimeEnvironment.current(getContext()).resourceHooksObserved); + } finally { + RuntimeEnvironment.restoreForTest(getContext(), previous); + } + } + + public void testNotificationRulesReachProviderWithoutPropertyMirror() throws Exception { + android.content.SharedPreferences prefs = TweakStore.preferences(getContext()); + boolean hadValue = prefs.contains(NotificationRuleStore.PREF_KEY); + String previous = prefs.getString(NotificationRuleStore.PREF_KEY, ""); + NotificationRuleStore.RuleSet rules = new NotificationRuleStore.RuleSet( + java.util.Collections.singletonList("provider-only-rule"), null, null, null); + String encoded = NotificationRuleStore.encode(rules); + try { + assertTrue(prefs.edit().putString(NotificationRuleStore.PREF_KEY, encoded).commit()); + android.database.Cursor cursor = getContext().getContentResolver().query( + android.net.Uri.withAppendedPath( + ConfigProvider.URI, NotificationRuleStore.PREF_KEY), + null, null, null, null); + assertNotNull(cursor); + String providerJson = null; + try { + assertTrue(cursor.moveToFirst()); + providerJson = cursor.getString(cursor.getColumnIndexOrThrow("value")); + } finally { + cursor.close(); + } + NotificationRuleStore.RuleSet resolved = + NotificationRuleStore.resolve(null, providerJson); + assertEquals(1, resolved.total()); + assertEquals("provider-only-rule", resolved.list( + NotificationRuleStore.Type.KEYWORD_BLOCKLIST).get(0)); + } finally { + android.content.SharedPreferences.Editor editor = prefs.edit(); + if (hadValue) editor.putString(NotificationRuleStore.PREF_KEY, previous); + else editor.remove(NotificationRuleStore.PREF_KEY); + editor.commit(); + } + } } diff --git a/app/src/androidTest/java/com/ez/zalopatch/RemoteCatalogActivationTest.java b/app/src/androidTest/java/com/ez/zalopatch/RemoteCatalogActivationTest.java index 88cb04f..627d7ae 100644 --- a/app/src/androidTest/java/com/ez/zalopatch/RemoteCatalogActivationTest.java +++ b/app/src/androidTest/java/com/ez/zalopatch/RemoteCatalogActivationTest.java @@ -15,8 +15,7 @@ public final class RemoteCatalogActivationTest extends AndroidTestCase { public void testCachedSignedProfileActivatesWithoutBundledExactProfile() throws Exception { Context context = getContext(); ZaloArtifactIdentity identity = ZaloArtifactIdentity.capture(context, true); - SymbolCatalogContract.Entry entry = SymbolCatalogCache.load(context, - identity.versionCode, identity.baseApkSha256, identity.signerSha256); + SymbolCatalogContract.Entry entry = SymbolCatalogCache.load(context, identity.versionCode); assertNotNull("No signed catalog entry cached for installed Zalo", entry); JSONObject bundle = new JSONObject(readBundledSchema(context)); @@ -39,10 +38,11 @@ public void testCachedSignedProfileActivatesWithoutBundledExactProfile() throws assertNotNull(remote); assertTrue(remote.valid); assertEquals("Remote catalog " + entry.sequence, remote.source); - assertEquals(identity.baseApkSha256, - remote.string("artifact.base_apk_sha256", "")); - assertEquals(identity.signerSha256, - remote.string("artifact.signer_sha256", "")); + // The entry names the container it was mapped from, which need not be the installed one: + // lookup and verification bind versionCode alone (Decision 15). Assert the mapped identity + // is carried, not that it equals this device's container. + assertEquals(64, remote.string("artifact.base_apk_sha256", "").length()); + assertEquals(64, remote.string("artifact.signer_sha256", "").length()); } private static String readBundledSchema(Context context) throws Exception { diff --git a/app/src/androidTest/java/com/ez/zalopatch/SettingsUiSmokeTest.java b/app/src/androidTest/java/com/ez/zalopatch/SettingsUiSmokeTest.java index e74c32b..fedec67 100644 --- a/app/src/androidTest/java/com/ez/zalopatch/SettingsUiSmokeTest.java +++ b/app/src/androidTest/java/com/ez/zalopatch/SettingsUiSmokeTest.java @@ -126,20 +126,67 @@ public void testMainRestartRemainsVisibleWithPendingChanges() { StatusActivity activity = getActivity(); SharedPreferences ui = SettingsChanges.preferences(activity); Map originalUi = new HashMap<>(ui.getAll()); + Map originalRoot = RootAccess.snapshotForTest(activity); try { + assertTrue(ui.edit().clear().commit()); + RootAccess.setForTest(activity, RootAccess.State.DENIED); SettingsChanges.markChanged(activity, "test.pending"); + getInstrumentation().runOnMainSync(() -> activity.refreshApplyBar(false)); StatusActivity.DashboardFragment fragment = new StatusActivity.DashboardFragment(); open(activity, fragment); Preference restart = fragment.findPreference(StatusActivity.INTERNAL_RESTART); + Preference appInfo = fragment.findPreference(StatusActivity.INTERNAL_ZALO_APP_INFO); + Preference root = fragment.findPreference(StatusActivity.INTERNAL_ROOT_ACCESS); assertNotNull(restart); assertTrue(restart.isVisible()); + assertFalse(restart.isEnabled()); + assertEquals("Root required; force stop Zalo to apply.", + String.valueOf(restart.getSummary())); + assertNotNull(appInfo); + assertNotNull(root); + assertEquals(android.view.View.VISIBLE, + activity.findViewById(R.id.zp_apply_bar).getVisibility()); + assertEquals("Root required; force stop Zalo to apply.", + String.valueOf(((android.widget.TextView) activity.findViewById( + R.id.zp_apply_message)).getText())); + assertFalse(activity.findViewById(R.id.zp_apply_button).isEnabled()); } finally { + RootAccess.restoreForTest(activity, originalRoot); restorePreferences(ui, originalUi); } } + public void testResourceHookRequirementUsesPendingThenUnavailableRendering() { + StatusActivity activity = getActivity(); + Map originalRuntime = RuntimeEnvironment.snapshotForTest(activity); + try { + RuntimeEnvironment.clearForTest(activity); + SectionActivity.SettingsFragment pending = + SectionActivity.SettingsFragment.forSection(Tweaks.SECTION_INBOX); + open(activity, pending); + assertTrue(pending.findPreference(Tweaks.KEY_HIDE_ZCLOUD_BANNER) + instanceof ZpSwitchPreference); + + long zaloVersion = SymbolSchema.installedZaloVersionCode(activity); + assertTrue(RuntimeEnvironment.record(activity, "lspatch", "unavailable", + BuildConfig.VERSION_CODE, zaloVersion)); + SectionActivity.SettingsFragment unsupported = + SectionActivity.SettingsFragment.forSection(Tweaks.SECTION_INBOX); + open(activity, unsupported); + Preference row = unsupported.findPreference(Tweaks.KEY_HIDE_ZCLOUD_BANNER); + assertNotNull(row); + assertFalse(row instanceof ZpSwitchPreference); + assertFalse(row.isSelectable()); + assertNull(row.getOnPreferenceChangeListener()); + assertTrue(String.valueOf(row.getSummary()).contains( + "did not expose resource hooks")); + } finally { + RuntimeEnvironment.restoreForTest(activity, originalRuntime); + } + } + public void testTelemetryUsesMainSwitchPatternAndTracksChildren() { StatusActivity activity = getActivity(); String originalDisplayMode = UiSettings.displayMode(activity); @@ -301,6 +348,10 @@ public void testVietnameseSettingsResourcesArePackaged() { assertEquals("Ngôn ngữ", vietnamese.getString(R.string.zp_language_title)); assertEquals("Telemetry", vietnamese.getString(R.string.zp_telemetry_title)); assertEquals("Self-check", vietnamese.getString(R.string.zp_self_check_page_title)); + assertEquals("Runtime environment", + vietnamese.getString(R.string.zp_runtime_environment_title)); + assertEquals("Root access", vietnamese.getString(R.string.zp_root_access_title)); + assertEquals("Root required.", vietnamese.getString(R.string.zp_requirement_root)); } public void testApplicationColdStartUsesSelectedLanguage() { diff --git a/app/src/main/java/com/ez/zalopatch/ConfigProvider.java b/app/src/main/java/com/ez/zalopatch/ConfigProvider.java index 68d6b58..306ccd4 100644 --- a/app/src/main/java/com/ez/zalopatch/ConfigProvider.java +++ b/app/src/main/java/com/ez/zalopatch/ConfigProvider.java @@ -231,6 +231,19 @@ public Bundle call(String method, String arg, Bundle extras) { result.putBoolean("recorded", recorded); return result; } + if ("record_runtime_environment".equals(method)) { + if (!callerAllowed() || extras == null) return null; + boolean recorded = RuntimeEnvironment.record(getContext(), + extras.getString("framework", "unknown"), + extras.getString("resource_hooks_status", + extras.getBoolean("resource_hooks_observed", false) + ? "observed" : "pending"), + extras.getInt("module_version_code", -1), + extras.getLong("zalo_version_code", -1L)); + Bundle result = new Bundle(); + result.putBoolean("recorded", recorded); + return result; + } if ("complete_runtime_discovery".equals(method)) { if (!callerAllowed()) return null; long versionCode; diff --git a/app/src/main/java/com/ez/zalopatch/DiagnosticCaptureCollector.java b/app/src/main/java/com/ez/zalopatch/DiagnosticCaptureCollector.java index 81aff74..56ba187 100644 --- a/app/src/main/java/com/ez/zalopatch/DiagnosticCaptureCollector.java +++ b/app/src/main/java/com/ez/zalopatch/DiagnosticCaptureCollector.java @@ -18,8 +18,12 @@ final class DiagnosticCaptureCollector { } CapturedData collect(long startedAtWallMs) { - String rootStatus = checkRootAccess(runner); - if (!"granted".equals(rootStatus)) { + return collect(startedAtWallMs, RootAccess.probe(runner)); + } + + CapturedData collect(long startedAtWallMs, RootAccess.State rootState) { + String rootStatus = rootState.reportValue(); + if (rootState != RootAccess.State.GRANTED) { return new CapturedData("metadata_only_root_denied", rootStatus, "", "", "", java.util.Collections.singletonList("root_access"), java.util.Collections.emptyMap()); @@ -83,12 +87,7 @@ CapturedData collect(long startedAtWallMs) { } static String checkRootAccess(DiagnosticRootProcessRunner runner) { - DiagnosticRootProcessRunner.Result result = runner.run( - "id -u", DiagnosticReportContract.COMMAND_TIMEOUT_MS); - if (result.timedOut) return "error"; - if (result.exitCode == 0 && "0".equals(result.output.trim())) return "granted"; - if (result.exitCode < 0 && result.output.trim().isEmpty()) return "error"; - return "denied"; + return RootAccess.probe(runner).reportValue(); } static String filterModuleLines(String output) { diff --git a/app/src/main/java/com/ez/zalopatch/DiagnosticCaptureManager.java b/app/src/main/java/com/ez/zalopatch/DiagnosticCaptureManager.java index 1b97642..21a13dc 100644 --- a/app/src/main/java/com/ez/zalopatch/DiagnosticCaptureManager.java +++ b/app/src/main/java/com/ez/zalopatch/DiagnosticCaptureManager.java @@ -23,20 +23,18 @@ static StartResult start(Context context, String category, String description) { DiagnosticsState.clearRuntimeDiscoveryRequest(context); DiagnosticDraftStore.clear(context); DiagnosticRootProcessRunner runner = new DiagnosticRootProcessRunner(); - String rootStatus = DiagnosticCaptureCollector.checkRootAccess(runner); - if (!"granted".equals(rootStatus)) { - return StartResult.failure("denied".equals(rootStatus) - ? StartFailure.ROOT_DENIED : StartFailure.ROOT_ERROR); - } + RootAccess.State rootState = RootAccess.getOrProbe(context); + boolean debugLoggingManaged = rootState == RootAccess.State.GRANTED; Session session = new Session( DiagnosticReportContract.newReportId(), category, description, System.currentTimeMillis(), SystemClock.elapsedRealtime(), - HookConfig.isDebugEnabled()); + debugLoggingManaged && HookConfig.isDebugEnabled(), rootState, + debugLoggingManaged); if (!writeSession(context, session)) { return StartResult.failure(StartFailure.STATE_WRITE_FAILED); } scheduleTimeout(context, session.startedAtElapsedMs); - if (!setDebugLogging(runner, true)) { + if (debugLoggingManaged && !setDebugLogging(runner, true)) { if (setDebugLogging(runner, session.previousDebugLogging)) { clearSession(context); cancelTimeout(context); @@ -48,7 +46,9 @@ static StartResult start(Context context, String category, String description) { && !SymbolSchema.active(context).valid && !DiagnosticsState.requestRuntimeDiscovery(context)) { DiagnosticsState.clearRuntimeDiscoveryRequest(context); - setDebugLogging(runner, session.previousDebugLogging); + if (session.debugLoggingManaged) { + setDebugLogging(runner, session.previousDebugLogging); + } clearSession(context); cancelTimeout(context); return StartResult.failure(StartFailure.STATE_WRITE_FAILED); @@ -64,8 +64,10 @@ static FinishedCapture finish(Context context) { } DiagnosticRootProcessRunner runner = new DiagnosticRootProcessRunner(); DiagnosticCaptureCollector.CapturedData data = - new DiagnosticCaptureCollector(runner).collect(session.startedAtWallMs); - if (!setDebugLogging(runner, session.previousDebugLogging)) { + new DiagnosticCaptureCollector(runner).collect( + session.startedAtWallMs, session.rootState); + if (session.debugLoggingManaged + && !setDebugLogging(runner, session.previousDebugLogging)) { return null; } if ("compatibility".equals(session.category) @@ -85,7 +87,8 @@ static boolean cancel(Context context) { return true; } boolean previous = prefs.getBoolean(KEY_PREVIOUS_DEBUG, false); - if (!setDebugLogging(new DiagnosticRootProcessRunner(), previous)) return false; + if (debugManaged(prefs) + && !setDebugLogging(new DiagnosticRootProcessRunner(), previous)) return false; if ("compatibility".equals(prefs.getString(KEY_CATEGORY, ""))) { DiagnosticsState.clearRuntimeDiscoveryRequest(context); } @@ -101,7 +104,8 @@ static boolean expireIfNeeded(Context context) { Session session = readSession(context); if (session != null && !expired(session, SystemClock.elapsedRealtime())) return false; boolean previous = prefs.getBoolean(KEY_PREVIOUS_DEBUG, false); - if (!setDebugLogging(new DiagnosticRootProcessRunner(), previous)) return false; + if (debugManaged(prefs) + && !setDebugLogging(new DiagnosticRootProcessRunner(), previous)) return false; if ("compatibility".equals(prefs.getString(KEY_CATEGORY, ""))) { DiagnosticsState.clearRuntimeDiscoveryRequest(context); } @@ -155,10 +159,15 @@ private static Session readSession(Context context) { || !DiagnosticReportContract.validDescription(description)) { return null; } + boolean managed = debugManaged(prefs); return new Session(reportId, category, description, prefs.getLong(KEY_STARTED_WALL, -1L), prefs.getLong(KEY_STARTED_ELAPSED, -1L), - prefs.getBoolean(KEY_PREVIOUS_DEBUG, false)); + prefs.getBoolean(KEY_PREVIOUS_DEBUG, false), + prefs.contains(KEY_ROOT_STATE) + ? parseRootState(prefs.getString(KEY_ROOT_STATE, "")) + : managed ? RootAccess.State.GRANTED : RootAccess.State.ABSENT, + managed); } private static boolean writeSession(Context context, Session session) { @@ -170,6 +179,8 @@ private static boolean writeSession(Context context, Session session) { .putLong(KEY_STARTED_WALL, session.startedAtWallMs) .putLong(KEY_STARTED_ELAPSED, session.startedAtElapsedMs) .putBoolean(KEY_PREVIOUS_DEBUG, session.previousDebugLogging) + .putString(KEY_ROOT_STATE, session.rootState.name()) + .putBoolean(KEY_DEBUG_MANAGED, session.debugLoggingManaged) .commit(); } @@ -235,15 +246,20 @@ static final class Session { final long startedAtWallMs; final long startedAtElapsedMs; final boolean previousDebugLogging; + final RootAccess.State rootState; + final boolean debugLoggingManaged; Session(String reportId, String category, String description, long startedAtWallMs, - long startedAtElapsedMs, boolean previousDebugLogging) { + long startedAtElapsedMs, boolean previousDebugLogging, + RootAccess.State rootState, boolean debugLoggingManaged) { this.reportId = reportId; this.category = category; this.description = description; this.startedAtWallMs = startedAtWallMs; this.startedAtElapsedMs = startedAtElapsedMs; this.previousDebugLogging = previousDebugLogging; + this.rootState = rootState == null ? RootAccess.State.ABSENT : rootState; + this.debugLoggingManaged = debugLoggingManaged; } } @@ -267,4 +283,20 @@ static final class FinishedCapture { private static final String KEY_STARTED_WALL = "started_wall"; private static final String KEY_STARTED_ELAPSED = "started_elapsed"; private static final String KEY_PREVIOUS_DEBUG = "previous_debug"; + private static final String KEY_ROOT_STATE = "root_state"; + private static final String KEY_DEBUG_MANAGED = "debug_managed"; + + private static RootAccess.State parseRootState(String value) { + try { + return RootAccess.State.valueOf(value); + } catch (IllegalArgumentException ignored) { + return RootAccess.State.ABSENT; + } + } + + private static boolean debugManaged(SharedPreferences prefs) { + // Sessions created before capability-aware capture always required and managed root. + return !prefs.contains(KEY_DEBUG_MANAGED) + || prefs.getBoolean(KEY_DEBUG_MANAGED, false); + } } diff --git a/app/src/main/java/com/ez/zalopatch/DiagnosticReportActivity.java b/app/src/main/java/com/ez/zalopatch/DiagnosticReportActivity.java index 884d60b..565345e 100644 --- a/app/src/main/java/com/ez/zalopatch/DiagnosticReportActivity.java +++ b/app/src/main/java/com/ez/zalopatch/DiagnosticReportActivity.java @@ -207,16 +207,23 @@ private void buildScreen() { } else if (session != null) { actions.add(PreferenceUi.info(context, getString(R.string.zp_diagnostic_capture_active), - getString("compatibility".equals(session.category) + getString(!session.debugLoggingManaged + ? R.string.zp_diagnostic_metadata_only_capture_active_summary + : "compatibility".equals(session.category) ? SymbolSchema.active(context).valid ? R.string.zp_diagnostic_compatibility_mapped_capture_active_summary : R.string.zp_diagnostic_compatibility_capture_active_summary : R.string.zp_diagnostic_capture_active_summary))); ZpRowPreference restart = PreferenceUi.action(context, - getString(R.string.zp_diagnostic_restart_zalo), - getString(R.string.zp_diagnostic_restart_zalo_summary)); + getString(session.debugLoggingManaged + ? R.string.zp_diagnostic_restart_zalo + : R.string.zp_open_zalo_app_info), + getString(session.debugLoggingManaged + ? R.string.zp_diagnostic_restart_zalo_summary + : R.string.zp_open_zalo_app_info_summary)); restart.setOnPreferenceClickListener(preference -> { - host().restartZalo(); + if (session.debugLoggingManaged) host().restartZalo(); + else host().openZaloAppInfo(); return true; }); actions.add(restart); @@ -399,7 +406,9 @@ private void startCapture() { session = result.session; draft = null; reviewedReportId = null; - statusMessage = getString(R.string.zp_diagnostic_capture_started); + statusMessage = getString(result.session.debugLoggingManaged + ? R.string.zp_diagnostic_capture_started + : R.string.zp_diagnostic_metadata_only_capture_started); } else { session = DiagnosticCaptureManager.current(context); statusMessage = startFailureMessage(result.failure); diff --git a/app/src/main/java/com/ez/zalopatch/DiagnosticReportFactory.java b/app/src/main/java/com/ez/zalopatch/DiagnosticReportFactory.java index 766eaf8..d231ec8 100644 --- a/app/src/main/java/com/ez/zalopatch/DiagnosticReportFactory.java +++ b/app/src/main/java/com/ez/zalopatch/DiagnosticReportFactory.java @@ -53,10 +53,14 @@ private static Draft create(Context context, String reportId, String category, root.put("category", category); root.put("description", description); PackageSnapshot packages = packageSnapshot(context); + String rootStatus = capture == null + ? RootAccess.hasFreshCache(context) + ? RootAccess.cached(context).reportValue() : "not_checked" + : capture.data.rootAccessStatus; root.put("commonMetadata", commonMetadata(context, packages)); - root.put("productMetadata", productMetadata(context, packages, - capture == null ? "not_checked" : capture.data.rootAccessStatus, capture)); - root.put("capture", captureMetadata(capture, createdAt)); + root.put("productMetadata", productMetadata( + context, packages, rootStatus, capture)); + root.put("capture", captureMetadata(capture, createdAt, rootStatus)); root.put("rawDiagnostics", rawDiagnostics(context, capture)); String json = root.toString(); if (!DiagnosticReportContract.validDraftJson(json)) { @@ -125,6 +129,11 @@ private static JSONObject productMetadata( product.put("debugLoggingEnabled", HookConfig.isDebugEnabled()); product.put("traceLoggingEnabled", systemPropertyEnabled("debug.zalopatch.trace")); product.put("rootAccessStatus", rootStatus); + RuntimeEnvironment.Snapshot environment = RuntimeEnvironment.current(context); + product.put("runtimeFramework", environment.framework.value()); + product.put("runtimeEnvironmentReported", environment.reported); + product.put("resourceHooksObserved", environment.resourceHooksObserved); + product.put("resourceHooksStatus", environment.resourceHooks.value()); product.put("pendingSettingsChanges", SettingsChanges.pendingCount(context)); product.put("customNotificationRuleCount", NotificationRuleStore.load(context).total()); @@ -332,7 +341,8 @@ static void collectRemapLinesForTest( } private static JSONObject captureMetadata( - DiagnosticCaptureManager.FinishedCapture capture, long finishedAtMs) + DiagnosticCaptureManager.FinishedCapture capture, long finishedAtMs, + String rootStatus) throws Exception { JSONObject metadata = new JSONObject(); if (capture == null) { @@ -340,7 +350,7 @@ private static JSONObject captureMetadata( metadata.put("startedAtUtc", JSONObject.NULL); metadata.put("finishedAtUtc", utc(finishedAtMs)); metadata.put("previousDiagnosticLoggingEnabled", JSONObject.NULL); - metadata.put("rootAccessStatus", "not_checked"); + metadata.put("rootAccessStatus", rootStatus); metadata.put("commandFailures", new JSONArray()); metadata.put("truncationFlags", new JSONObject()); return metadata; @@ -349,7 +359,8 @@ private static JSONObject captureMetadata( metadata.put("startedAtUtc", utc(capture.session.startedAtWallMs)); metadata.put("finishedAtUtc", utc(capture.finishedAtWallMs)); metadata.put("previousDiagnosticLoggingEnabled", - capture.session.previousDebugLogging); + capture.session.debugLoggingManaged + ? capture.session.previousDebugLogging : JSONObject.NULL); metadata.put("rootAccessStatus", capture.data.rootAccessStatus); JSONArray failures = new JSONArray(); for (String failure : capture.data.commandFailures) failures.put(failure); diff --git a/app/src/main/java/com/ez/zalopatch/HookConfig.java b/app/src/main/java/com/ez/zalopatch/HookConfig.java index 4dad2a3..56d140e 100644 --- a/app/src/main/java/com/ez/zalopatch/HookConfig.java +++ b/app/src/main/java/com/ez/zalopatch/HookConfig.java @@ -58,12 +58,11 @@ public static NotificationRuleStore.RuleSet notificationRules() { synchronized (HookConfig.class) { cached = notificationRules; if (cached == null) { - String json = SettingsPropertyMirror.readBlob(NotificationRuleStore.MIRROR_KEY); - try { - cached = NotificationRuleStore.decode(json); - } catch (Exception ignored) { - cached = NotificationRuleStore.RuleSet.empty(); - } + String propertyJson = SettingsPropertyMirror.readBlob( + NotificationRuleStore.MIRROR_KEY); + String providerJson = propertyJson == null + ? readValue(appContext, NotificationRuleStore.PREF_KEY) : null; + cached = NotificationRuleStore.resolve(propertyJson, providerJson); notificationRules = cached; } } diff --git a/app/src/main/java/com/ez/zalopatch/ModuleEntry.java b/app/src/main/java/com/ez/zalopatch/ModuleEntry.java index 499b611..5e763fe 100644 --- a/app/src/main/java/com/ez/zalopatch/ModuleEntry.java +++ b/app/src/main/java/com/ez/zalopatch/ModuleEntry.java @@ -22,6 +22,8 @@ public final class ModuleEntry implements IXposedHookLoadPackage, IXposedHookInitPackageResources, IXposedHookZygoteInit { private static final String TARGET_PACKAGE = "com.zing.zalo"; private static final String TAG = "ZaloPatch"; + private static final long RESOURCE_HOOK_OBSERVATION_WINDOW_MS = 5_000L; + private static final AtomicBoolean RESOURCE_HOOKS_OBSERVED = new AtomicBoolean(false); private final AtomicBoolean featuresStarted = new AtomicBoolean(false); @Override @@ -48,6 +50,8 @@ public void handleInitPackageResources(XC_InitPackageResources.InitPackageResour if (!TARGET_PACKAGE.equals(resparam.packageName)) { return; } + RESOURCE_HOOKS_OBSERVED.set(true); + RuntimeEnvironmentReporter.report(HookConfig.resolveFallbackContextForHooks(), true); resparam.res.hookLayout(TARGET_PACKAGE, "layout", "messageslist", new de.robv.android.xposed.callbacks.XC_LayoutInflated() { @Override public void handleLayoutInflated(LayoutInflatedParam liparam) { @@ -83,11 +87,20 @@ private void startFeatures(ClassLoader classLoader, boolean mainProcess) { if (!featuresStarted.compareAndSet(false, true)) { return; } - new Handler(Looper.getMainLooper()).post(new Runnable() { + Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(new Runnable() { @Override public void run() { HookConfig.logStartupSnapshot(); - MainFeatures.start(classLoader, mainProcess); + MainFeatures.start(classLoader, mainProcess, RESOURCE_HOOKS_OBSERVED.get()); + if (mainProcess) { + mainHandler.postDelayed(() -> RuntimeEnvironmentReporter.report( + HookConfig.resolveFallbackContextForHooks(), + RESOURCE_HOOKS_OBSERVED.get() + ? RuntimeEnvironment.ResourceHooks.OBSERVED + : RuntimeEnvironment.ResourceHooks.UNAVAILABLE), + RESOURCE_HOOK_OBSERVATION_WINDOW_MS); + } } }); } diff --git a/app/src/main/java/com/ez/zalopatch/NotificationRuleStore.java b/app/src/main/java/com/ez/zalopatch/NotificationRuleStore.java index ee2fe04..94616ed 100644 --- a/app/src/main/java/com/ez/zalopatch/NotificationRuleStore.java +++ b/app/src/main/java/com/ez/zalopatch/NotificationRuleStore.java @@ -15,7 +15,7 @@ public final class NotificationRuleStore { public static final String MIRROR_KEY = "notifications.custom_rules"; - private static final String PREF_KEY = "notifications.custom_rules_json"; + static final String PREF_KEY = "notifications.custom_rules_json"; private static final int FORMAT_VERSION = 1; public enum Type { @@ -154,6 +154,15 @@ public static RuleSet decode(String json) throws Exception { decodeList(root, Type.ACCOUNT_EXCEPTIONS)); } + static RuleSet resolve(String propertyJson, String providerJson) { + String selected = propertyJson != null ? propertyJson : providerJson; + try { + return decode(selected); + } catch (Exception ignored) { + return RuleSet.empty(); + } + } + public static List sanitize(List values) { LinkedHashSet unique = new LinkedHashSet<>(); if (values != null) { diff --git a/app/src/main/java/com/ez/zalopatch/RequirementGate.java b/app/src/main/java/com/ez/zalopatch/RequirementGate.java new file mode 100644 index 0000000..1258a67 --- /dev/null +++ b/app/src/main/java/com/ez/zalopatch/RequirementGate.java @@ -0,0 +1,23 @@ +package com.ez.zalopatch; + +/** Pure capability decision table for user-facing settings rows. */ +final class RequirementGate { + private RequirementGate() { + } + + static boolean isMet(Tweaks.Requirement requirement, RootAccess.State rootState, + RuntimeEnvironment.ResourceHooks resourceHooks) { + if (requirement == Tweaks.Requirement.ROOT) { + return rootState == RootAccess.State.GRANTED; + } + if (requirement == Tweaks.Requirement.RESOURCE_HOOKS) { + return resourceHooks != RuntimeEnvironment.ResourceHooks.UNAVAILABLE; + } + return true; + } + + static boolean isMet(android.content.Context context, Tweaks.Requirement requirement) { + RuntimeEnvironment.Snapshot runtime = RuntimeEnvironment.current(context); + return isMet(requirement, RootAccess.cached(context), runtime.resourceHooks); + } +} diff --git a/app/src/main/java/com/ez/zalopatch/RootAccess.java b/app/src/main/java/com/ez/zalopatch/RootAccess.java new file mode 100644 index 0000000..3730a89 --- /dev/null +++ b/app/src/main/java/com/ez/zalopatch/RootAccess.java @@ -0,0 +1,189 @@ +package com.ez.zalopatch; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.List; + +/** Cached, explicit root capability probe for module-process actions. */ +final class RootAccess { + enum State { + GRANTED, + DENIED, + ABSENT; + + String reportValue() { + return this == ABSENT ? "error" : name().toLowerCase(java.util.Locale.US); + } + } + + interface Callback { + void onResult(State state); + } + + private static final String KEY_STATE = "capability.root_access.state"; + private static final String KEY_MODULE_VERSION = "capability.root_access.module_version"; + private static final Object PROBE_LOCK = new Object(); + private static final List ACTIVE_CALLBACKS = new ArrayList<>(); + private static final List FORCED_CALLBACKS = new ArrayList<>(); + private static boolean probing; + private static boolean activeProbeForced; + private static boolean forcedProbeQueued; + + private RootAccess() { + } + + static State cached(Context context) { + SharedPreferences prefs = preferences(context); + if (prefs.getInt(KEY_MODULE_VERSION, -1) != BuildConfig.VERSION_CODE) { + return State.ABSENT; + } + return parse(prefs.getString(KEY_STATE, "")); + } + + static boolean hasFreshCache(Context context) { + SharedPreferences prefs = preferences(context); + return prefs.getInt(KEY_MODULE_VERSION, -1) == BuildConfig.VERSION_CODE + && !prefs.getString(KEY_STATE, "").isEmpty(); + } + + static State getOrProbe(Context context) { + if (hasFreshCache(context)) return cached(context); + return probeAndStore(context, new DiagnosticRootProcessRunner()); + } + + static void probeIfNeeded(Context context, Callback callback) { + if (hasFreshCache(context)) { + deliver(callback, cached(context)); + return; + } + probeAsync(context, false, callback); + } + + static void recheck(Context context, Callback callback) { + probeAsync(context, true, callback); + } + + static State probe(DiagnosticRootProcessRunner runner) { + return classify(runner.run("id -u", DiagnosticReportContract.COMMAND_TIMEOUT_MS)); + } + + static State classify(DiagnosticRootProcessRunner.Result result) { + if (!result.timedOut && result.exitCode == 0 && "0".equals(result.output.trim())) { + return State.GRANTED; + } + if (!result.timedOut && result.exitCode < 0 && result.output.trim().isEmpty()) { + return State.ABSENT; + } + return State.DENIED; + } + + static java.util.Map snapshotForTest(Context context) { + return new java.util.HashMap<>(preferences(context).getAll()); + } + + static void restoreForTest(Context context, java.util.Map values) { + SharedPreferences.Editor editor = preferences(context).edit().clear(); + if (values != null) { + for (java.util.Map.Entry entry : values.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Boolean) editor.putBoolean(entry.getKey(), (Boolean) value); + else if (value instanceof Integer) editor.putInt(entry.getKey(), (Integer) value); + else if (value instanceof Long) editor.putLong(entry.getKey(), (Long) value); + else if (value instanceof String) editor.putString(entry.getKey(), (String) value); + } + } + editor.commit(); + } + + static void setForTest(Context context, State state) { + preferences(context).edit() + .putString(KEY_STATE, state.name()) + .putInt(KEY_MODULE_VERSION, BuildConfig.VERSION_CODE) + .commit(); + } + + private static void probeAsync(Context context, boolean force, Callback callback) { + Context appContext = context.getApplicationContext(); + Context safeContext = appContext == null ? context : appContext; + if (!force && hasFreshCache(safeContext)) { + deliver(callback, cached(safeContext)); + return; + } + synchronized (PROBE_LOCK) { + if (probing) { + if (force && !activeProbeForced) { + forcedProbeQueued = true; + if (callback != null) FORCED_CALLBACKS.add(callback); + } else if (callback != null) { + ACTIVE_CALLBACKS.add(callback); + } + return; + } + probing = true; + activeProbeForced = force; + if (callback != null) ACTIVE_CALLBACKS.add(callback); + } + startProbe(safeContext); + } + + private static void startProbe(Context context) { + new Thread(() -> { + State state = State.ABSENT; + try { + state = probeAndStore(context, new DiagnosticRootProcessRunner()); + } catch (Throwable ignored) { + } + List completedCallbacks; + boolean runForcedProbe; + synchronized (PROBE_LOCK) { + completedCallbacks = new ArrayList<>(ACTIVE_CALLBACKS); + ACTIVE_CALLBACKS.clear(); + runForcedProbe = forcedProbeQueued; + if (runForcedProbe) { + forcedProbeQueued = false; + activeProbeForced = true; + ACTIVE_CALLBACKS.addAll(FORCED_CALLBACKS); + FORCED_CALLBACKS.clear(); + } else { + probing = false; + activeProbeForced = false; + } + } + for (Callback completedCallback : completedCallbacks) { + deliver(completedCallback, state); + } + if (runForcedProbe) startProbe(context); + }, "zalo-root-capability").start(); + } + + private static State probeAndStore( + Context context, DiagnosticRootProcessRunner runner) { + State state = probe(runner); + preferences(context).edit() + .putString(KEY_STATE, state.name()) + .putInt(KEY_MODULE_VERSION, BuildConfig.VERSION_CODE) + .commit(); + return state; + } + + private static State parse(String value) { + try { + return State.valueOf(value); + } catch (IllegalArgumentException ignored) { + return State.ABSENT; + } + } + + private static SharedPreferences preferences(Context context) { + return context.getSharedPreferences(UiSettings.PREFS_NAME, Context.MODE_PRIVATE); + } + + private static void deliver(Callback callback, State state) { + if (callback == null) return; + new Handler(Looper.getMainLooper()).post(() -> callback.onResult(state)); + } +} diff --git a/app/src/main/java/com/ez/zalopatch/RuntimeEnvironment.java b/app/src/main/java/com/ez/zalopatch/RuntimeEnvironment.java new file mode 100644 index 0000000..932929e --- /dev/null +++ b/app/src/main/java/com/ez/zalopatch/RuntimeEnvironment.java @@ -0,0 +1,204 @@ +package com.ez.zalopatch; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.Locale; + +/** Module-private record of the runtime that actually loaded the hook. */ +public final class RuntimeEnvironment { + private static final String PREFS = "runtime_environment_v1"; + private static final String KEY_REPORTED = "reported"; + private static final String KEY_FRAMEWORK = "framework"; + private static final String KEY_RESOURCE_HOOKS = "resource_hooks_observed"; + private static final String KEY_RESOURCE_HOOKS_STATUS = "resource_hooks_status"; + private static final String KEY_MODULE_VERSION = "module_version"; + private static final String KEY_ZALO_VERSION = "zalo_version"; + private static final String KEY_UPDATED_AT = "updated_at"; + + public enum Framework { + LSPOSED, + LSPATCH, + UNKNOWN; + + public String value() { + return name().toLowerCase(Locale.US); + } + } + + public enum ResourceHooks { + PENDING, + OBSERVED, + UNAVAILABLE; + + public String value() { + return name().toLowerCase(Locale.US); + } + } + + public static final class Snapshot { + public final boolean reported; + public final Framework framework; + public final ResourceHooks resourceHooks; + public final boolean resourceHooksObserved; + public final int moduleVersionCode; + public final long zaloVersionCode; + public final long updatedAtMs; + + Snapshot(boolean reported, Framework framework, ResourceHooks resourceHooks, + int moduleVersionCode, long zaloVersionCode, long updatedAtMs) { + this.reported = reported; + this.framework = framework == null ? Framework.UNKNOWN : framework; + this.resourceHooks = resourceHooks == null ? ResourceHooks.PENDING : resourceHooks; + this.resourceHooksObserved = this.resourceHooks == ResourceHooks.OBSERVED; + this.moduleVersionCode = moduleVersionCode; + this.zaloVersionCode = zaloVersionCode; + this.updatedAtMs = updatedAtMs; + } + + static Snapshot pending(long zaloVersionCode) { + return new Snapshot(false, Framework.UNKNOWN, ResourceHooks.PENDING, + BuildConfig.VERSION_CODE, zaloVersionCode, 0L); + } + } + + private RuntimeEnvironment() { + } + + public static Snapshot current(Context context) { + long installedVersion = SymbolSchema.installedZaloVersionCode(context); + SharedPreferences prefs = preferences(context); + if (!prefs.getBoolean(KEY_REPORTED, false) + || prefs.getInt(KEY_MODULE_VERSION, -1) != BuildConfig.VERSION_CODE + || prefs.getLong(KEY_ZALO_VERSION, -1L) != installedVersion) { + return Snapshot.pending(installedVersion); + } + return new Snapshot(true, parseFramework(prefs.getString(KEY_FRAMEWORK, "")), + readResourceHooks(prefs), + prefs.getInt(KEY_MODULE_VERSION, -1), + prefs.getLong(KEY_ZALO_VERSION, -1L), + prefs.getLong(KEY_UPDATED_AT, 0L)); + } + + static boolean record(Context context, String frameworkValue, String resourceHooksValue, + int moduleVersionCode, long zaloVersionCode) { + Framework framework = parseFramework(frameworkValue); + if (moduleVersionCode != BuildConfig.VERSION_CODE || zaloVersionCode <= 0L) return false; + SharedPreferences prefs = preferences(context); + Snapshot incoming = new Snapshot(true, framework, parseResourceHooks(resourceHooksValue), + moduleVersionCode, zaloVersionCode, System.currentTimeMillis()); + Snapshot merged = merge(readUnchecked(prefs), incoming); + return prefs.edit() + .putBoolean(KEY_REPORTED, true) + .putString(KEY_FRAMEWORK, merged.framework.value()) + .putString(KEY_RESOURCE_HOOKS_STATUS, merged.resourceHooks.value()) + .putBoolean(KEY_RESOURCE_HOOKS, merged.resourceHooksObserved) + .putInt(KEY_MODULE_VERSION, merged.moduleVersionCode) + .putLong(KEY_ZALO_VERSION, merged.zaloVersionCode) + .putLong(KEY_UPDATED_AT, merged.updatedAtMs) + .commit(); + } + + static Snapshot merge(Snapshot current, Snapshot incoming) { + if (current == null || !current.reported + || current.moduleVersionCode != incoming.moduleVersionCode + || current.zaloVersionCode != incoming.zaloVersionCode + || current.framework != incoming.framework) { + return incoming; + } + ResourceHooks mergedResourceHooks = mergeResourceHooks( + current.resourceHooks, incoming.resourceHooks); + return new Snapshot(true, incoming.framework, mergedResourceHooks, + incoming.moduleVersionCode, incoming.zaloVersionCode, incoming.updatedAtMs); + } + + public static Framework detect(boolean lspatchMarker, boolean lsposedMarker, + int xposedApiVersion, String... runtimeEvidence) { + if (lspatchMarker) return Framework.LSPATCH; + if (lsposedMarker || strongLsposedEvidence(runtimeEvidence) + || xposedApiVersion >= 100) { + return Framework.LSPOSED; + } + return Framework.UNKNOWN; + } + + static void clearForTest(Context context) { + preferences(context).edit().clear().commit(); + } + + static java.util.Map snapshotForTest(Context context) { + return new java.util.HashMap<>(preferences(context).getAll()); + } + + static void restoreForTest(Context context, java.util.Map values) { + SharedPreferences.Editor editor = preferences(context).edit().clear(); + if (values != null) { + for (java.util.Map.Entry entry : values.entrySet()) { + Object value = entry.getValue(); + if (value instanceof Boolean) editor.putBoolean(entry.getKey(), (Boolean) value); + else if (value instanceof Integer) editor.putInt(entry.getKey(), (Integer) value); + else if (value instanceof Long) editor.putLong(entry.getKey(), (Long) value); + else if (value instanceof String) editor.putString(entry.getKey(), (String) value); + } + } + editor.commit(); + } + + private static boolean strongLsposedEvidence(String[] evidence) { + if (evidence == null) return false; + for (String item : evidence) { + String value = item == null ? "" : item.toLowerCase(Locale.US); + if (value.contains("org.lsposed.lspd.") + || value.contains("/data/adb/lspd/") + || value.contains("/data/adb/modules/zygisk_lsposed/") + || value.contains("/data/adb/modules/lsposed/") + || value.contains("lspd.dex")) { + return true; + } + } + return false; + } + + private static Snapshot readUnchecked(SharedPreferences prefs) { + if (!prefs.getBoolean(KEY_REPORTED, false)) return null; + return new Snapshot(true, parseFramework(prefs.getString(KEY_FRAMEWORK, "")), + readResourceHooks(prefs), + prefs.getInt(KEY_MODULE_VERSION, -1), + prefs.getLong(KEY_ZALO_VERSION, -1L), + prefs.getLong(KEY_UPDATED_AT, 0L)); + } + + private static Framework parseFramework(String value) { + if ("lsposed".equalsIgnoreCase(value)) return Framework.LSPOSED; + if ("lspatch".equalsIgnoreCase(value)) return Framework.LSPATCH; + return Framework.UNKNOWN; + } + + private static ResourceHooks parseResourceHooks(String value) { + if ("observed".equalsIgnoreCase(value)) return ResourceHooks.OBSERVED; + if ("unavailable".equalsIgnoreCase(value)) return ResourceHooks.UNAVAILABLE; + return ResourceHooks.PENDING; + } + + private static ResourceHooks readResourceHooks(SharedPreferences prefs) { + if (prefs.contains(KEY_RESOURCE_HOOKS_STATUS)) { + return parseResourceHooks(prefs.getString(KEY_RESOURCE_HOOKS_STATUS, "")); + } + return prefs.getBoolean(KEY_RESOURCE_HOOKS, false) + ? ResourceHooks.OBSERVED : ResourceHooks.PENDING; + } + + private static ResourceHooks mergeResourceHooks(ResourceHooks current, ResourceHooks incoming) { + if (current == ResourceHooks.OBSERVED || incoming == ResourceHooks.OBSERVED) { + return ResourceHooks.OBSERVED; + } + if (current == ResourceHooks.UNAVAILABLE || incoming == ResourceHooks.UNAVAILABLE) { + return ResourceHooks.UNAVAILABLE; + } + return ResourceHooks.PENDING; + } + + private static SharedPreferences preferences(Context context) { + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/com/ez/zalopatch/RuntimeEnvironmentReporter.java b/app/src/main/java/com/ez/zalopatch/RuntimeEnvironmentReporter.java new file mode 100644 index 0000000..41d3906 --- /dev/null +++ b/app/src/main/java/com/ez/zalopatch/RuntimeEnvironmentReporter.java @@ -0,0 +1,167 @@ +package com.ez.zalopatch; + +import android.app.BroadcastOptions; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; + +import de.robv.android.xposed.XposedBridge; + +/** Hook-process detector and bounded transport for runtime capability evidence. */ +public final class RuntimeEnvironmentReporter { + private static final String TARGET_PACKAGE = "com.zing.zalo"; + private static final String[] LSPATCH_CLASSES = { + "org.lsposed.lspatch.loader.LSPApplication", + "org.lsposed.lspatch.metaloader.LSPAppComponentFactoryStub", + "org.lsposed.lspatch.service.ILSPApplicationService" + }; + private static final String[] LSPOSED_CLASSES = { + "org.lsposed.lspd.impl.LSPosedBridge", + "org.lsposed.lspd.nativebridge.NativeAPI", + "org.lsposed.lspd.nativebridge.HookBridge", + "org.lsposed.lspd.loader.Main" + }; + + private RuntimeEnvironmentReporter() { + } + + public static void report(Context context, boolean resourceHooksObserved) { + report(context, resourceHooksObserved + ? RuntimeEnvironment.ResourceHooks.OBSERVED + : RuntimeEnvironment.ResourceHooks.PENDING); + } + + public static void report(Context context, RuntimeEnvironment.ResourceHooks resourceHooks) { + if (context == null || !TARGET_PACKAGE.equals(context.getPackageName())) return; + RuntimeEnvironment.Framework framework = RuntimeEnvironment.detect( + hasLspatchMarker(context), hasAnyClass(LSPOSED_CLASSES, context), + xposedApiVersion(), runtimeClassLoaderEvidence(context)); + long zaloVersion = SymbolSchema.installedZaloVersionCode(context); + Bundle extras = new Bundle(); + extras.putString("framework", framework.value()); + RuntimeEnvironment.ResourceHooks safeResourceHooks = resourceHooks == null + ? RuntimeEnvironment.ResourceHooks.PENDING : resourceHooks; + extras.putString("resource_hooks_status", safeResourceHooks.value()); + extras.putBoolean("resource_hooks_observed", + safeResourceHooks == RuntimeEnvironment.ResourceHooks.OBSERVED); + extras.putInt("module_version_code", BuildConfig.VERSION_CODE); + extras.putLong("zalo_version_code", zaloVersion); + Context providerContext = HookConfig.resolveModuleContextForHooks(); + if (providerContext == null) providerContext = context; + try { + Bundle response = providerContext.getContentResolver().call( + android.net.Uri.parse("content://com.ez.zalopatch.config"), + "record_runtime_environment", null, extras); + if (response != null && response.getBoolean("recorded", false)) return; + } catch (Throwable ignored) { + } + sendFallback(context, extras); + } + + private static boolean hasLspatchMarker(Context context) { + try { + PackageInfo info = context.getPackageManager().getPackageInfo( + TARGET_PACKAGE, PackageManager.GET_PROVIDERS); + if (info.providers != null) { + for (ProviderInfo provider : info.providers) { + if (provider.authority != null + && provider.authority.contains("lspatch.documents")) { + return true; + } + } + } + if (info.applicationInfo != null) { + String application = info.applicationInfo.className; + if (startsWithLspatch(application)) return true; + if (Build.VERSION.SDK_INT >= 28 + && startsWithLspatch(info.applicationInfo.appComponentFactory)) { + return true; + } + } + } catch (Throwable ignored) { + } + return hasAnyClass(LSPATCH_CLASSES, context); + } + + private static boolean startsWithLspatch(String value) { + return value != null && value.startsWith("org.lsposed.lspatch."); + } + + private static int xposedApiVersion() { + try { + return Math.max(0, XposedBridge.getXposedVersion()); + } catch (Throwable ignored) { + return 0; + } + } + + private static boolean hasAnyClass(String[] names, Context context) { + for (String name : names) { + for (ClassLoader loader : runtimeClassLoaders(context)) { + try { + Class.forName(name, false, loader); + return true; + } catch (ClassNotFoundException | LinkageError ignored) { + } + } + } + return false; + } + + private static String[] runtimeClassLoaderEvidence(Context context) { + ClassLoader[] loaders = runtimeClassLoaders(context); + String[] evidence = new String[loaders.length * 2]; + int index = 0; + for (ClassLoader loader : loaders) { + if (loader == null) continue; + try { + evidence[index++] = bounded(loader.getClass().getName(), 256); + } catch (Throwable ignored) { + } + try { + evidence[index++] = bounded(String.valueOf(loader), 512); + } catch (Throwable ignored) { + } + } + return evidence; + } + + private static ClassLoader[] runtimeClassLoaders(Context context) { + return new ClassLoader[]{ + XposedBridge.class.getClassLoader(), + RuntimeEnvironmentReporter.class.getClassLoader(), + context == null ? null : context.getClassLoader(), + Thread.currentThread().getContextClassLoader(), + ClassLoader.getSystemClassLoader(), + null + }; + } + + private static String bounded(String value, int maxLength) { + if (value == null) return ""; + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } + + private static void sendFallback(Context context, Bundle extras) { + if (Build.VERSION.SDK_INT < 34) return; + try { + Intent intent = new Intent(SelfCheckReceiver.ACTION_RECORD_RUNTIME_ENVIRONMENT); + intent.setComponent(new ComponentName("com.ez.zalopatch", + "com.ez.zalopatch.SelfCheckReceiver")); + intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES); + intent.putExtras(extras); + BroadcastOptions options = BroadcastOptions.makeBasic(); + options.setShareIdentityEnabled(true); + context.sendBroadcast(intent, null, options.toBundle()); + } catch (Throwable throwable) { + Log.i("ZaloPatch", "Runtime environment fallback failed: " + + throwable.getClass().getSimpleName()); + } + } +} diff --git a/app/src/main/java/com/ez/zalopatch/SectionActivity.java b/app/src/main/java/com/ez/zalopatch/SectionActivity.java index 467e156..d7fe543 100644 --- a/app/src/main/java/com/ez/zalopatch/SectionActivity.java +++ b/app/src/main/java/com/ez/zalopatch/SectionActivity.java @@ -36,6 +36,7 @@ public static final class SettingsFragment extends ZpPreferenceFragment { private ZpRowPreference recordingsRow; private ZpMainSwitchPreference telemetryMaster; private boolean recordingCountLoading; + private boolean resourceRequirementMet; private final ActivityResultLauncher recordingNotificationPermissionLauncher = registerForActivityResult( new ActivityResultContracts.RequestPermission(), granted -> { @@ -69,6 +70,12 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { @Override public void onResume() { super.onResume(); + boolean currentResourceRequirement = RequirementGate.isMet( + requireContext(), Tweaks.Requirement.RESOURCE_HOOKS); + if (currentResourceRequirement != resourceRequirementMet) { + buildScreen(); + return; + } selfCheckRows = SelfCheckData.byFeature(SelfCheckData.load(requireContext())); for (Map.Entry entry : switches.entrySet()) { entry.getValue().setChecked(TweakStore.isEnabled(requireContext(), entry.getKey())); @@ -88,6 +95,13 @@ public void onResume() { private void buildScreen() { Context context = requireContext(); + switches.clear(); + pageStatuses.clear(); + developerRuntimeStatus = null; + recordingsRow = null; + telemetryMaster = null; + resourceRequirementMet = RequirementGate.isMet( + context, Tweaks.Requirement.RESOURCE_HOOKS); String section = requireArguments().getString(ARG_SECTION); SymbolSchema.Active schema = SymbolSchema.active(context); selfCheckRows = SelfCheckData.byFeature(SelfCheckData.load(context)); @@ -218,9 +232,14 @@ private void addRowsForKey(ZpSection section, SymbolSchema.Active schema, String if (item == null) { return; } - Preference row = item.implemented - ? implementedPreference(context, schema, item) - : plannedPreference(context, schema, item); + Preference row; + if (!item.implemented) { + row = plannedPreference(context, schema, item); + } else if (!RequirementGate.isMet(context, item.requirement)) { + row = unavailableRequirementPreference(context, item); + } else { + row = implementedPreference(context, schema, item); + } String dependency = null; if (Tweaks.KEY_CATEGORY_GROUPS.equals(item.key) || Tweaks.KEY_CATEGORY_STRANGERS.equals(item.key) @@ -407,10 +426,27 @@ private static boolean notificationPermissionGranted(Context context) { private Preference plannedPreference(Context context, SymbolSchema.Active schema, Tweaks.Item item) { String summary = item.summaryRes == 0 ? "" : getString(item.summaryRes); - return PreferenceUi.unavailable(context, + Preference preference = PreferenceUi.unavailable(context, getString(item.titleRes), summary.isEmpty() ? getString(R.string.zp_unavailable) : getString(R.string.zp_unavailable_with_summary, summary)); + preference.setKey(item.key); + return preference; + } + + private Preference unavailableRequirementPreference(Context context, Tweaks.Item item) { + int requirementRes = item.requirement == Tweaks.Requirement.ROOT + ? R.string.zp_requirement_root + : R.string.zp_requirement_resource_hooks; + String behavior = item.summaryRes == 0 ? "" : getString(item.summaryRes); + String summary = behavior.isEmpty() + ? getString(requirementRes) + : getString(R.string.zp_requirement_with_summary, + getString(requirementRes), behavior); + Preference preference = PreferenceUi.unavailable(context, + getString(item.titleRes), summary); + preference.setKey(item.key); + return preference; } /** Compact per-row runtime state. Replaces the old second tracking row under each toggle. */ diff --git a/app/src/main/java/com/ez/zalopatch/SelfCheckActivity.java b/app/src/main/java/com/ez/zalopatch/SelfCheckActivity.java index 7672240..ea27335 100644 --- a/app/src/main/java/com/ez/zalopatch/SelfCheckActivity.java +++ b/app/src/main/java/com/ez/zalopatch/SelfCheckActivity.java @@ -17,6 +17,7 @@ private SelfCheckActivity() { public static final class SelfCheckFragment extends ZpPreferenceFragment { private ZpRowPreference metrics; + private ZpRowPreference runtimeEnvironment; private PreferenceCategory failedCategory; private PreferenceCategory staleCategory; @@ -49,6 +50,9 @@ private void buildScreen() { metrics = PreferenceUi.metrics(context, getString(R.string.zp_self_check_heading), null); + runtimeEnvironment = PreferenceUi.info(context, + getString(R.string.zp_runtime_environment_title), null); + screen.addPreference(runtimeEnvironment); screen.addPreference(metrics); failedCategory = PreferenceUi.category(screen, ""); @@ -64,6 +68,20 @@ private void refresh() { if (metrics == null) { return; } + RuntimeEnvironment.Snapshot environment = RuntimeEnvironment.current(requireContext()); + if (!environment.reported) { + runtimeEnvironment.value(getString(R.string.zp_runtime_environment_pending)); + } else { + int frameworkRes = environment.framework == RuntimeEnvironment.Framework.LSPOSED + ? R.string.zp_runtime_framework_lsposed + : environment.framework == RuntimeEnvironment.Framework.LSPATCH + ? R.string.zp_runtime_framework_lspatch + : R.string.zp_runtime_framework_unknown; + runtimeEnvironment.value(getString(R.string.zp_runtime_environment_summary, + getString(frameworkRes), getString(resourceHooksStatusRes( + environment.resourceHooks)))); + } + runtimeEnvironment.refreshStyle(); List rows = SelfCheckData.load(requireContext()); SelfCheckData.Counts counts = SelfCheckData.counts(rows); String runtime = rows.isEmpty() @@ -76,6 +94,16 @@ private void refresh() { refreshAttentionCategory(staleCategory, rows, "stale"); } + private int resourceHooksStatusRes(RuntimeEnvironment.ResourceHooks status) { + if (status == RuntimeEnvironment.ResourceHooks.OBSERVED) { + return R.string.zp_capability_observed; + } + if (status == RuntimeEnvironment.ResourceHooks.UNAVAILABLE) { + return R.string.zp_capability_unavailable; + } + return R.string.zp_capability_pending; + } + private String headline(SelfCheckData.Counts counts) { int attention = counts.failed + counts.stale; if (attention == 0) { diff --git a/app/src/main/java/com/ez/zalopatch/SelfCheckReceiver.java b/app/src/main/java/com/ez/zalopatch/SelfCheckReceiver.java index 63b1639..013d9f6 100644 --- a/app/src/main/java/com/ez/zalopatch/SelfCheckReceiver.java +++ b/app/src/main/java/com/ez/zalopatch/SelfCheckReceiver.java @@ -18,6 +18,8 @@ public final class SelfCheckReceiver extends BroadcastReceiver { "com.ez.zalopatch.COMPLETE_RUNTIME_DISCOVERY"; public static final String ACTION_RECORD_RUNTIME_DISCOVERY_EVIDENCE = "com.ez.zalopatch.RECORD_RUNTIME_DISCOVERY_EVIDENCE"; + public static final String ACTION_RECORD_RUNTIME_ENVIRONMENT = + "com.ez.zalopatch.RECORD_RUNTIME_ENVIRONMENT"; public static final String EXTRA_VALUES = "values"; private static final AtomicBoolean REJECTION_LOGGED = new AtomicBoolean(false); @@ -35,6 +37,16 @@ public void onReceive(Context context, Intent intent) { } static void handleAllowed(Context context, Intent intent) { + if (ACTION_RECORD_RUNTIME_ENVIRONMENT.equals(intent.getAction())) { + RuntimeEnvironment.record(context, intent.getStringExtra("framework"), + intent.getStringExtra("resource_hooks_status") == null + ? (intent.getBooleanExtra("resource_hooks_observed", false) + ? "observed" : "pending") + : intent.getStringExtra("resource_hooks_status"), + intent.getIntExtra("module_version_code", -1), + intent.getLongExtra("zalo_version_code", -1L)); + return; + } if (ACTION_COMPLETE_RUNTIME_DISCOVERY.equals(intent.getAction())) { DiagnosticsState.completeRuntimeDiscovery( context, intent.getLongExtra("version_code", -1L)); diff --git a/app/src/main/java/com/ez/zalopatch/StatusActivity.java b/app/src/main/java/com/ez/zalopatch/StatusActivity.java index 97c617a..2c6a0cf 100644 --- a/app/src/main/java/com/ez/zalopatch/StatusActivity.java +++ b/app/src/main/java/com/ez/zalopatch/StatusActivity.java @@ -39,6 +39,8 @@ public final class StatusActivity extends ZpSettingsActivity { static final String ROUTE_BACKUP = "settings_backup"; static final String ROUTE_DIAGNOSTICS = "diagnostics"; static final String INTERNAL_RESTART = "internal.restart_zalo"; + static final String INTERNAL_ROOT_ACCESS = "internal.root_access"; + static final String INTERNAL_ZALO_APP_INFO = "internal.zalo_app_info"; private static final String ARG_PAGE_TITLE = "settings.page_title"; private static final int REQUEST_SETTINGS_EXPORT = 2001; @@ -152,6 +154,15 @@ protected void onRestartStateChanged(boolean inFlight) { } } + @Override + protected void onRootAccessChanged(RootAccess.State state) { + Fragment fragment = getSupportFragmentManager() + .findFragmentById(R.id.zp_settings_content); + if (fragment instanceof DashboardFragment) { + ((DashboardFragment) fragment).refresh(); + } + } + private void openInitialRoute(Intent intent) { String route = intent == null ? null : intent.getStringExtra(EXTRA_ROUTE); if (route == null || ROUTE_DASHBOARD.equals(route)) { @@ -317,6 +328,10 @@ private void showToast(String message, int duration) { public static final class DashboardFragment extends ZpPreferenceFragment { private ZpRowPreference restart; private ZpRowPreference filter; + private ZpRowPreference runtimeEnvironment; + private ZpRowPreference rootAccess; + private ZpRowPreference appInfo; + private boolean restartAvailable = true; @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { @@ -358,6 +373,26 @@ private void addPrimaryActions(PreferenceScreen screen) { return true; }); section.add(restart); + rootAccess = PreferenceUi.action(context, + getString(R.string.zp_root_access_title), null); + rootAccess.setKey(INTERNAL_ROOT_ACCESS); + rootAccess.setOnPreferenceClickListener(preference -> { + host().recheckRootAccess(); + return true; + }); + section.add(rootAccess); + appInfo = PreferenceUi.action(context, + getString(R.string.zp_open_zalo_app_info), + getString(R.string.zp_open_zalo_app_info_summary)); + appInfo.setKey(INTERNAL_ZALO_APP_INFO); + appInfo.setOnPreferenceClickListener(preference -> { + host().openZaloAppInfo(); + return true; + }); + section.add(appInfo); + runtimeEnvironment = PreferenceUi.info(context, + getString(R.string.zp_runtime_environment_title), null); + section.add(runtimeEnvironment); } private String ruleCountValue(int total) { @@ -453,7 +488,24 @@ private void refresh() { Context context = getContext(); if (context == null || restart == null) return; + RootAccess.State rootState = RootAccess.cached(context); + restart.setEnabled(restartAvailable && rootState == RootAccess.State.GRANTED); + restart.setSummary(rootState == RootAccess.State.GRANTED + ? null : getString(R.string.zp_restart_root_required_summary)); restart.refreshStyle(); + if (rootAccess != null) { + int rootSummary = rootState == RootAccess.State.GRANTED + ? R.string.zp_root_access_granted + : rootState == RootAccess.State.DENIED + ? R.string.zp_root_access_denied + : R.string.zp_root_access_absent; + rootAccess.value(getString(rootSummary)); + rootAccess.refreshStyle(); + } + if (runtimeEnvironment != null) { + runtimeEnvironment.value(runtimeEnvironmentSummary(context)); + runtimeEnvironment.refreshStyle(); + } if (filter != null) { filter.value(ruleCountValue(NotificationRuleStore.load(context).total())); filter.refreshStyle(); @@ -461,9 +513,36 @@ private void refresh() { } private void setRestartEnabled(boolean enabled) { + restartAvailable = enabled; if (restart != null) { - restart.setEnabled(enabled); + restart.setEnabled(enabled + && RootAccess.cached(requireContext()) == RootAccess.State.GRANTED); + } + } + + private String runtimeEnvironmentSummary(Context context) { + RuntimeEnvironment.Snapshot snapshot = RuntimeEnvironment.current(context); + if (!snapshot.reported) { + return getString(R.string.zp_runtime_environment_pending); + } + int frameworkRes = snapshot.framework == RuntimeEnvironment.Framework.LSPOSED + ? R.string.zp_runtime_framework_lsposed + : snapshot.framework == RuntimeEnvironment.Framework.LSPATCH + ? R.string.zp_runtime_framework_lspatch + : R.string.zp_runtime_framework_unknown; + return getString(R.string.zp_runtime_environment_summary, + getString(frameworkRes), getString(resourceHooksStatusRes( + snapshot.resourceHooks))); + } + + private int resourceHooksStatusRes(RuntimeEnvironment.ResourceHooks status) { + if (status == RuntimeEnvironment.ResourceHooks.OBSERVED) { + return R.string.zp_capability_observed; + } + if (status == RuntimeEnvironment.ResourceHooks.UNAVAILABLE) { + return R.string.zp_capability_unavailable; } + return R.string.zp_capability_pending; } } } diff --git a/app/src/main/java/com/ez/zalopatch/SymbolCatalogCache.java b/app/src/main/java/com/ez/zalopatch/SymbolCatalogCache.java index b192221..9b34da5 100644 --- a/app/src/main/java/com/ez/zalopatch/SymbolCatalogCache.java +++ b/app/src/main/java/com/ez/zalopatch/SymbolCatalogCache.java @@ -14,16 +14,15 @@ final class SymbolCatalogCache { private SymbolCatalogCache() { } - static SymbolCatalogContract.Entry load(Context context, long versionCode, - String baseHash, String signerHash) { - if (context == null || versionCode <= 0L || baseHash.isEmpty() || signerHash.isEmpty()) { + static SymbolCatalogContract.Entry load(Context context, long versionCode) { + if (context == null || versionCode <= 0L) { return null; } try { AtomicFile file = atomicFile(context); byte[] envelope = readBounded(file.openRead(), SymbolCatalogContract.MAX_ENTRY_BYTES); return SymbolCatalogContract.verify(envelope, publicKey(context), versionCode, - baseHash, signerHash, BuildConfig.VERSION_CODE); + BuildConfig.VERSION_CODE); } catch (Exception ignored) { return null; } @@ -33,7 +32,7 @@ static boolean save(Context context, ZaloArtifactIdentity identity, byte[] envel if (context == null || identity == null) return false; try { SymbolCatalogContract.verify(envelope, publicKey(context), identity.versionCode, - identity.baseApkSha256, identity.signerSha256, BuildConfig.VERSION_CODE); + BuildConfig.VERSION_CODE); AtomicFile file = atomicFile(context); FileOutputStream output = file.startWrite(); try { diff --git a/app/src/main/java/com/ez/zalopatch/SymbolCatalogContract.java b/app/src/main/java/com/ez/zalopatch/SymbolCatalogContract.java index 5e8bbd0..c48f109 100644 --- a/app/src/main/java/com/ez/zalopatch/SymbolCatalogContract.java +++ b/app/src/main/java/com/ez/zalopatch/SymbolCatalogContract.java @@ -17,8 +17,21 @@ final class SymbolCatalogContract { private SymbolCatalogContract() { } + /** + * Verifies a signed catalog entry against the installed artifact. + * + *

Binding is on exact {@code versionCode}. The container hashes carried in the payload are + * the identity of the artifact the profile was mapped from, which the module reads to report a + * match tier; they are deliberately not compared against the installed artifact here. The base + * APK hash identifies a download rather than a build (Decision 14), and re-signing rezips + * without rebuilding dex (Decision 15), so comparing either made a correctly signed entry + * unusable on a container carrying identical code. + * + *

Entry authenticity is unaffected: it rests on the pinned catalog public key, and + * {@code versionCode} stays in the binding, so an entry for another release is still refused. + */ static Entry verify(byte[] envelopeBytes, byte[] publicKeyPem, long versionCode, - String baseApkSha256, String signerSha256, int moduleVersionCode) + int moduleVersionCode) throws Exception { if (envelopeBytes == null || envelopeBytes.length == 0 || envelopeBytes.length > MAX_ENTRY_BYTES) { @@ -50,8 +63,6 @@ static Entry verify(byte[] envelopeBytes, byte[] publicKeyPem, long versionCode, || sequence <= 0 || !SymbolSchema.TARGET_PACKAGE.equals(root.optString("packageName", "")) || root.optLong("versionCode", -1L) != versionCode - || !baseApkSha256.equals(root.optString("baseApkSha256", "")) - || !signerSha256.equals(root.optString("signerSha256", "")) || minimumModule > moduleVersionCode || profile == null) { throw new IllegalArgumentException("catalog artifact mismatch"); diff --git a/app/src/main/java/com/ez/zalopatch/SymbolSchema.java b/app/src/main/java/com/ez/zalopatch/SymbolSchema.java index 673e2a8..98ec32c 100644 --- a/app/src/main/java/com/ez/zalopatch/SymbolSchema.java +++ b/app/src/main/java/com/ez/zalopatch/SymbolSchema.java @@ -92,10 +92,8 @@ private static Active remoteForModule(Context context, long installedVersionCode if (!current.lightweightKey.equals(storedLightweight)) { return null; } - SymbolCatalogContract.Entry entry = SymbolCatalogCache.load(context, - installedVersionCode, - preferences.getString(ZaloArtifactState.KEY_BASE_SHA256, ""), - preferences.getString(ZaloArtifactState.KEY_SIGNER_SHA256, "")); + SymbolCatalogContract.Entry entry = SymbolCatalogCache.load( + context, installedVersionCode); if (entry == null) return null; Active active = select(entry.profileJson, "Remote catalog " + entry.sequence, installedVersionCode); @@ -154,8 +152,10 @@ static Active selectBundledForVersion(Context context, long installedVersionCode * Newest bundled profile, regardless of the installed Zalo version. This exists only so an * unmapped version can be probed: running the structural checks says which anchor families * would have resolved, which is the evidence an exact-profile gate otherwise suppresses. It - * must never back a hook. Obfuscated names shuffle between Zalo releases, so a name that - * resolves here may belong to an unrelated class. + * must never back a hook: it is picked by schema revision alone, without preflight. Obfuscated + * names shuffle between Zalo releases, so a name that resolves here may belong to an unrelated + * class. {@link #fallbackProfilesForHooks(Context, long)} is the path that may back a hook, and + * it differs precisely in that the caller preflights each candidate first. */ public static Active probeProfileForHooks(Context context) { context = hookContext(context); @@ -188,6 +188,86 @@ public static Active probeProfileForHooks(Context context) { } } + /** + * Bundled profiles other than the one mapped for {@code installedVersionCode}, nearest release + * first, for the caller to try when the exact profile resolved nothing. + * + *

Ordering is by absolute versionCode distance, breaking ties toward the newer release. The + * list is candidates only: a caller must structurally preflight each against the live + * classloader and take one only if its anchors resolve. Obfuscated names shuffle between + * releases, so a name resolving here can belong to an unrelated class, and preflight is what + * separates the two. + */ + public static List fallbackProfilesForHooks(Context context, + long installedVersionCode) { + context = hookContext(context); + List candidates = new ArrayList<>(); + try { + String bundleJson = readBundledJson(context); + JSONArray profiles = new JSONObject(bundleJson).optJSONArray("profiles"); + if (profiles == null) { + return candidates; + } + List codes = new ArrayList<>(); + for (int index = 0; index < profiles.length(); index++) { + JSONObject profile = profiles.optJSONObject(index); + if (profile == null) continue; + JSONObject range = profile.optJSONObject("zalo_version"); + long code = range == null ? -1L : range.optLong("min_code", -1L); + if (code > 0L && code != installedVersionCode && !codes.contains(code)) { + codes.add(code); + } + } + orderByVersionDistance(codes, installedVersionCode); + for (long code : codes) { + Active candidate = select(bundleJson, "Fallback profile " + code, code); + if (candidate.valid) { + candidates.add(candidate); + } + } + } catch (Throwable ignored) { + return candidates; + } + return candidates; + } + + /** + * Orders candidate profile versionCodes by absolute distance from the installed one, nearest + * first, breaking ties toward the newer release. + * + *

Distance is the only signal available before preflight runs. A tie means one mapped + * release sits either side of the installed one; the newer is tried first because Zalo carries + * anchors forward more often than it reinstates an older shape. + */ + static void orderByVersionDistance(List codes, long installedVersionCode) { + Collections.sort(codes, (left, right) -> { + long leftDistance = Math.abs(left - installedVersionCode); + long rightDistance = Math.abs(right - installedVersionCode); + if (leftDistance != rightDistance) { + return Long.compare(leftDistance, rightDistance); + } + return Long.compare(right, left); + }); + } + + /** + * Installs a profile as the one every hook-side symbol read resolves to for this process. + * + *

Feature symbol lookups all funnel through {@link #activeForHooks(Context)}, so a fallback + * chosen after preflight has to land in that cache or the features would keep reading the exact + * profile that just failed. Scoped to the Zalo process lifetime; nothing is persisted, and the + * module process still reconciles and reports from the exact profile. + */ + public static void adoptForHooks(Active profile, long installedVersionCode) { + if (profile == null || !profile.valid) { + return; + } + synchronized (SymbolSchema.class) { + cachedHookSchema = profile; + cachedHookVersionCode = installedVersionCode; + } + } + public static void setModuleApkPath(String path) { if (path != null && !path.isEmpty() && !path.equals(moduleApkPath)) { moduleApkPath = path; diff --git a/app/src/main/java/com/ez/zalopatch/Tweaks.java b/app/src/main/java/com/ez/zalopatch/Tweaks.java index cae240c..5b81339 100644 --- a/app/src/main/java/com/ez/zalopatch/Tweaks.java +++ b/app/src/main/java/com/ez/zalopatch/Tweaks.java @@ -5,6 +5,11 @@ import java.util.List; public final class Tweaks { + public enum Requirement { + NONE, + ROOT, + RESOURCE_HOOKS + } /** * The only preference file the hooked Zalo process can read. It is opened world-readable and * mirrored to system properties for exactly that reason, so nothing private belongs in it. @@ -155,15 +160,23 @@ public static final class Item { public final int summaryRes; public final boolean implemented; public final boolean defaultEnabled; + public final Requirement requirement; public Item(String section, String key, int titleRes, int summaryRes, boolean implemented, boolean defaultEnabled) { + this(section, key, titleRes, summaryRes, implemented, defaultEnabled, + Requirement.NONE); + } + + public Item(String section, String key, int titleRes, int summaryRes, + boolean implemented, boolean defaultEnabled, Requirement requirement) { this.section = section; this.key = key; this.titleRes = titleRes; this.summaryRes = summaryRes; this.implemented = implemented; this.defaultEnabled = defaultEnabled; + this.requirement = requirement == null ? Requirement.NONE : requirement; } } @@ -221,7 +234,7 @@ public Item(String section, String key, int titleRes, int summaryRes, true, false), new Item(SECTION_INBOX, KEY_HIDE_ZCLOUD_BANNER, R.string.zp_tweak_hide_zcloud_banner, R.string.zp_tweak_hide_zcloud_banner_summary, - true, false), + true, false, Requirement.RESOURCE_HOOKS), new Item(SECTION_INBOX, KEY_FILTER_POPOVER_CATEGORIES, R.string.zp_tweak_inbox_chip_bar, R.string.zp_tweak_inbox_chip_bar_summary, true, false), diff --git a/app/src/main/java/com/ez/zalopatch/ZaloArtifactState.java b/app/src/main/java/com/ez/zalopatch/ZaloArtifactState.java index aeb8c4b..797527b 100644 --- a/app/src/main/java/com/ez/zalopatch/ZaloArtifactState.java +++ b/app/src/main/java/com/ez/zalopatch/ZaloArtifactState.java @@ -39,6 +39,14 @@ public final class ZaloArtifactState { * Symbols are accepted; per-anchor structural preflight remains the load-bearing check. */ public static final String EVIDENCE_VERSION_SIGNER = "version_signer"; + /** + * The installed artifact carries the profile's exact versionCode, but neither the mapped base + * APK container nor the Zalo signing certificate. Re-signing rezips without rebuilding dex, and + * a repacker has no source to re-obfuscate with, so the mapped symbol names normally survive. + * Symbols are accepted and every anchor is left to structural preflight. Provenance is + * unverified at this tier, so it is always surfaced rather than folded into a plain ready. + */ + public static final String EVIDENCE_VERSION_ONLY = "version_only"; public static final String EVIDENCE_NONE = "none"; /** * Authorized, but the stored state predates the match tier or has not been reconciled since. @@ -104,22 +112,29 @@ static Result reconcile(Context context) { ZaloArtifactIdentity identity = ZaloArtifactIdentity.capture(context, true); String previousGeneration = preferences.getString(KEY_GENERATION, ""); boolean generationChanged = !identity.generation.equals(previousGeneration); - SymbolCatalogContract.Entry catalogEntry = SymbolCatalogCache.load(context, - identity.versionCode, identity.baseApkSha256, identity.signerSha256); + SymbolCatalogContract.Entry catalogEntry = SymbolCatalogCache.load( + context, identity.versionCode); long now = System.currentTimeMillis(); long catalogCheckedAt = preferences.getLong(KEY_CATALOG_CHECKED_AT, 0L); String catalogStatus = catalogEntry == null ? "missing" : "cached"; String catalogError = ""; + // A 404 caches nothing, so an uncached artifact alone must not force the fetch: that + // kept `catalogEntry == null` true forever and re-queried on every reconcile, ignoring + // the interval entirely. A recorded `unknown` for this same generation is a negative + // answer and is honoured until the interval elapses. Manual retry still works because + // it zeroes KEY_CATALOG_CHECKED_AT, which drives the interval clause. + boolean knownAbsent = "unknown".equals( + preferences.getString(KEY_CATALOG_STATUS, "missing")); if (!instrumentationInstalled(context) - && (catalogEntry == null || generationChanged - || now - catalogCheckedAt >= CATALOG_CHECK_INTERVAL_MS)) { + && (generationChanged + || now - catalogCheckedAt >= CATALOG_CHECK_INTERVAL_MS + || (catalogEntry == null && !knownAbsent))) { SymbolCatalogClient.Result catalogResult = SymbolCatalogClient.resolve( BuildConfig.SYMBOL_CATALOG_URL, identity, catalogEntry); catalogStatus = catalogResult.status; if ("available".equals(catalogResult.status)) { if (SymbolCatalogCache.save(context, identity, catalogResult.envelope)) { - catalogEntry = SymbolCatalogCache.load(context, identity.versionCode, - identity.baseApkSha256, identity.signerSha256); + catalogEntry = SymbolCatalogCache.load(context, identity.versionCode); catalogStatus = catalogEntry == null ? "invalid" : "updated"; } else { catalogStatus = "invalid"; @@ -202,12 +217,22 @@ static Result reconcile(Context context) { /** * Decides whether the installed artifact may use a selected profile. * - *

The profile is already selected by exact {@code versionCode}; symbols are never taken from - * another version, a range, or the nearest known profile. Within one {@code versionCode} the - * Zalo signing certificate is the provenance check, and the base APK hash is recorded evidence - * rather than a gate: Play serves per-device bundle variants and re-stamps its signing block per - * download, so one release legitimately has several base APK hashes over identical code. Anchors - * that did move are caught per feature by structural preflight. + *

The profile is selected by exact {@code versionCode}; symbols are never taken from another + * version here. Container identity is recorded evidence rather than a gate, at two tiers. + * + *

The base APK hash identifies a download, not a build: Play serves per-device bundle + * variants and re-stamps its signing block per serving, so one release legitimately has several + * base APK hashes over byte-identical dex (Decision 14). + * + *

The signer is likewise not a gate (Decision 15). Re-signing requires a rezip, not a dex + * rebuild, so clone tools and mirror redistributions produce a fresh signer over unchanged code. + * Repackaging does not re-obfuscate either, because that needs source the repacker does not + * have, so schema symbol names survive it. Where a container genuinely did diverge, the names + * fail to resolve and per-anchor structural preflight declines that feature. + * + *

Preflight validates structure, not identity, so it cannot prove a resolved name belongs to + * the class it was mapped from. The tier is therefore reported rather than hidden: it is the + * triage signal that says whether a defect report describes this module or someone's patch. */ static Decision decide(boolean profileValid, String profileValidation, String catalogStatus, String expectedSigner, String expectedBaseHash, @@ -217,8 +242,7 @@ static Decision decide(boolean profileValid, String profileValidation, String ca profileValidation + "; catalog " + catalogStatus); } if (expectedSigner == null || !expectedSigner.equals(actualSigner)) { - return new Decision("mismatch", EVIDENCE_NONE, - "Zalo signing certificate does not match the selected profile"); + return new Decision("ready", EVIDENCE_VERSION_ONLY, ""); } if (expectedBaseHash != null && expectedBaseHash.equals(actualBaseHash)) { return new Decision("ready", EVIDENCE_EXACT_APK, ""); @@ -230,14 +254,18 @@ static Decision decide(boolean profileValid, String profileValidation, String ca * Authorization inputs, deliberately excluding the install identity. Splits, install path and * install time vary across devices and across ordinary Play activity on one device, and carry * no symbol information. What remains: the module reconciled to {@code ready}, a profile is - * selected for the installed versionCode, the Zalo signer matches the profile, and the hook - * process resolved the same profile bytes the module did. + * selected for the installed versionCode, and the hook process resolved the same profile bytes + * the module did. + * + *

The signer comparison this used to make was the provenance gate a second time, in the hook + * process. Provenance is a reported tier rather than a gate (Decision 15), and the profile hash + * is what carries the cross-process consistency intent: a version move changes the selected + * profile, which moves the hash. */ - static boolean authorizes(String status, boolean profileValid, String profileSigner, - String storedSigner, String profileHash, String storedProfileHash) { + static boolean authorizes(String status, boolean profileValid, String profileHash, + String storedProfileHash) { return "ready".equals(status) && profileValid - && profileSigner != null && profileSigner.equals(storedSigner) && profileHash != null && profileHash.equals(storedProfileHash); } @@ -247,7 +275,6 @@ public static Compatibility forHooks(Context context) { String status = HookConfig.getRawString(KEY_STATUS, "pending"); String storedLightweight = HookConfig.getRawString(KEY_LIGHTWEIGHT, ""); String generation = HookConfig.getRawString(KEY_GENERATION, ""); - String storedSigner = HookConfig.getRawString(KEY_SIGNER_SHA256, ""); String storedProfileHash = HookConfig.getRawString(KEY_PROFILE_SHA256, ""); String evidence = HookConfig.getRawString(KEY_EVIDENCE, EVIDENCE_UNKNOWN); String error = HookConfig.getRawString(KEY_ERROR, ""); @@ -264,7 +291,6 @@ public static Compatibility forHooks(Context context) { requestFromHook(context); } boolean authorized = authorizes(status, profile.valid, - profile.string("artifact.signer_sha256", ""), storedSigner, currentProfileHash, storedProfileHash); if (!authorized) { requestFromHook(context); @@ -298,8 +324,6 @@ public static Compatibility currentCompatibility(Context context) { SymbolSchema.Active profile = SymbolSchema.active(context); String profileHash = profile.valid ? ZaloArtifactIdentity.sha256(profile.json) : ""; boolean authorized = authorizes(status, profile.valid, - profile.string("artifact.signer_sha256", ""), - preferences.getString(KEY_SIGNER_SHA256, ""), profileHash, preferences.getString(KEY_PROFILE_SHA256, "")); String reason = authorized ? "" : preferences.getString(KEY_ERROR, ""); if (!authorized && reason.isEmpty()) { @@ -338,6 +362,11 @@ public static String summary(Context context) { summary.append("\nBase APK container differs from the mapped one; matched on exact " + "versionCode and Zalo signing certificate."); } + if (EVIDENCE_VERSION_ONLY.equals(evidence)) { + summary.append("\nZalo signing certificate differs from the mapped one; matched on " + + "exact versionCode only. Provenance is unverified and every anchor is " + + "gated by structural preflight."); + } if (reconcileSuppressed(context)) { summary.append("\nRe-check suppressed while com.ez.zalopatch.test is installed; " + "uninstall it to resume artifact reconciliation."); @@ -432,6 +461,11 @@ public static final class Compatibility { public boolean containerUnverified() { return compatible && EVIDENCE_VERSION_SIGNER.equals(evidence); } + + /** True when the installed artifact does not carry the mapped Zalo signing certificate. */ + public boolean signerUnverified() { + return compatible && EVIDENCE_VERSION_ONLY.equals(evidence); + } } static final class Decision { diff --git a/app/src/main/java/com/ez/zalopatch/ZpSettingsActivity.java b/app/src/main/java/com/ez/zalopatch/ZpSettingsActivity.java index 89de668..aef14b7 100644 --- a/app/src/main/java/com/ez/zalopatch/ZpSettingsActivity.java +++ b/app/src/main/java/com/ez/zalopatch/ZpSettingsActivity.java @@ -9,6 +9,9 @@ import android.widget.FrameLayout; import android.widget.Toast; import android.content.SharedPreferences; +import android.content.Intent; +import android.net.Uri; +import android.provider.Settings; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; @@ -49,6 +52,10 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { settingsStack.addView(restartBlocker, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); refreshApplyBar(false); + RootAccess.probeIfNeeded(this, state -> { + refreshApplyBar(false); + onRootAccessChanged(state); + }); } @Override @@ -75,13 +82,16 @@ public boolean onOptionsItemSelected(MenuItem item) { return super.onOptionsItemSelected(item); } - private void refreshApplyBar(boolean animate) { + final void refreshApplyBar(boolean animate) { if (applyBar == null) { return; } int count = SettingsChanges.pendingCount(this); - applyMessage.setText(getResources().getQuantityString( - R.plurals.zp_pending_changes, count, count)); + boolean rootGranted = RootAccess.cached(this) == RootAccess.State.GRANTED; + applyMessage.setText(rootGranted + ? getResources().getQuantityString(R.plurals.zp_pending_changes, count, count) + : getString(R.string.zp_restart_root_required_summary)); + applyButton.setEnabled(rootGranted && !restartInFlight); boolean show = count > 0; if (show == (applyBar.getVisibility() == View.VISIBLE)) { return; @@ -108,7 +118,7 @@ private void refreshApplyBar(boolean animate) { } protected final void restartZalo() { - if (restartInFlight) { + if (restartInFlight || RootAccess.cached(this) != RootAccess.State.GRANTED) { return; } restartInFlight = true; @@ -124,7 +134,6 @@ protected final void restartZalo() { private void finishRestart(ZaloRestart.Result result) { restartInFlight = false; - applyButton.setEnabled(true); setRestartBlockerVisible(false); onRestartStateChanged(false); int message = result == ZaloRestart.Result.SENT @@ -152,4 +161,24 @@ protected void onRestartResult(ZaloRestart.Result result) { protected void onRestartStateChanged(boolean inFlight) { } + + protected void onRootAccessChanged(RootAccess.State state) { + } + + protected final void recheckRootAccess() { + RootAccess.recheck(this, state -> { + refreshApplyBar(false); + onRootAccessChanged(state); + }); + } + + protected final void openZaloAppInfo() { + try { + startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:com.zing.zalo"))); + } catch (RuntimeException exception) { + Toast.makeText(this, R.string.zp_open_zalo_app_info_failed, + Toast.LENGTH_SHORT).show(); + } + } } diff --git a/app/src/main/java/com/ez/zalopatch/xposed/core/MainFeatures.java b/app/src/main/java/com/ez/zalopatch/xposed/core/MainFeatures.java index 40008b7..9f60d53 100644 --- a/app/src/main/java/com/ez/zalopatch/xposed/core/MainFeatures.java +++ b/app/src/main/java/com/ez/zalopatch/xposed/core/MainFeatures.java @@ -1,7 +1,10 @@ package com.ez.zalopatch.xposed.core; +import android.content.Context; + import com.ez.zalopatch.DiagnosticsState; import com.ez.zalopatch.HookConfig; +import com.ez.zalopatch.RuntimeEnvironmentReporter; import com.ez.zalopatch.SymbolSchema; import com.ez.zalopatch.Tweaks; import com.ez.zalopatch.ZaloArtifactState; @@ -26,25 +29,47 @@ public final class MainFeatures { private static final String FEATURE_RUNTIME_DISCOVERY = "runtime_discovery"; private static final String FEATURE_SYMBOL_PROBE = "symbol_probe"; + private static final String FEATURE_SYMBOL_FALLBACK = "symbol_fallback"; private MainFeatures() { } - public static void start(ClassLoader classLoader, boolean mainProcess) { + public static void start( + ClassLoader classLoader, boolean mainProcess, boolean resourceHooksObserved) { + Context context = HookConfig.resolveFallbackContextForHooks(); + if (mainProcess) { + RuntimeEnvironmentReporter.report(context, resourceHooksObserved); + } if (mainProcess) { runFeature(new SymbolSchemaHealthFeature(classLoader)); } List features = new ArrayList<>(); features.add(new NotificationFeature(classLoader)); - ZaloArtifactState.Compatibility artifact = ZaloArtifactState.forHooks( - HookConfig.resolveFallbackContextForHooks()); + ZaloArtifactState.Compatibility artifact = ZaloArtifactState.forHooks(context); features.add(new TelemetryFeature(classLoader, artifact.compatible)); features.add(new InteractionTraceFeature(classLoader)); - if (artifact.compatible) { - markArtifactReady(artifact); - SymbolPreflight.Result preflight = SymbolPreflight.inspect( - SymbolSchema.activeForHooks(HookConfig.resolveFallbackContextForHooks()), - classLoader); + + SymbolPreflight.Result preflight = artifact.compatible + ? SymbolPreflight.inspect(SymbolSchema.activeForHooks(context), classLoader) + : null; + // The exact profile resolved nothing, or no profile covers this release at all. Try the + // neighbouring releases before giving up: a release that did not move the anchors this + // module hooks is common, and the alternative is every feature silently off until the + // version is remapped. Only an artifact that reconciled to `unsupported` may take this + // path; `pending` and `failed` mean verification has not finished, which is not the same + // as a version nobody mapped. + Adopted fallback = null; + if (preflight == null || preflight.resolved() == 0) { + if (artifact.compatible || "unsupported".equals(artifact.status)) { + fallback = adoptNearestResolvingProfile(context, classLoader); + if (fallback != null) { + preflight = fallback.preflight; + } + } + } + + if (artifact.compatible || fallback != null) { + markArtifactReady(artifact, fallback); boolean bottomEnabled = HookConfig.isEnabled(Tweaks.KEY_HIDE_DISCOVERY_TAB) || HookConfig.isEnabled(Tweaks.KEY_HIDE_TIMELINE_TAB) || HookConfig.isEnabled(Tweaks.KEY_KEEP_GROUP_TAB) @@ -107,7 +132,72 @@ private static void probeSymbols(ClassLoader classLoader) { } } - private static void markArtifactReady(ZaloArtifactState.Compatibility artifact) { + /** + * Takes the nearest bundled profile from another release whose anchors actually resolve here. + * + *

Candidates are preflighted in versionCode order, nearest first, and the first one to + * resolve any anchor family is adopted for the rest of this Zalo process. Structural preflight + * is the whole of the check: it validates that a mapped name exists with the mapped shape, and + * it cannot prove the name still denotes the class it was mapped from, so a profile is adopted + * for the families it resolved and every other family stays gated off as usual. + */ + private static Adopted adoptNearestResolvingProfile(Context context, + ClassLoader classLoader) { + try { + long installed = SymbolSchema.installedZaloVersionCode(context); + if (installed <= 0L) { + return null; + } + for (SymbolSchema.Active candidate + : SymbolSchema.fallbackProfilesForHooks(context, installed)) { + SymbolPreflight.Result result = SymbolPreflight.inspect(candidate, classLoader); + if (result.resolved() == 0) { + continue; + } + SymbolSchema.adoptForHooks(candidate, installed); + SelfCheckRegistry.markStatus(FEATURE_SYMBOL_FALLBACK, "ok", + candidate.source + ": " + result.resolved() + "/" + result.total() + + " resolved " + result.breakdown(), + "no exact profile resolved; symbols taken from a neighbouring release", + ""); + return new Adopted(candidate, result); + } + SelfCheckRegistry.markStatus(FEATURE_SYMBOL_FALLBACK, "ok", + "no neighbouring profile resolved", "", ""); + return null; + } catch (Throwable throwable) { + SelfCheckRegistry.markStatus(FEATURE_SYMBOL_FALLBACK, "ok", + "fallback unavailable", "", throwable.getClass().getSimpleName()); + return null; + } + } + + /** A neighbouring-release profile that preflighted clean, with the result that chose it. */ + private static final class Adopted { + final SymbolSchema.Active profile; + final SymbolPreflight.Result preflight; + + Adopted(SymbolSchema.Active profile, SymbolPreflight.Result preflight) { + this.profile = profile; + this.preflight = preflight; + } + } + + private static void markArtifactReady(ZaloArtifactState.Compatibility artifact, + Adopted fallback) { + if (fallback != null) { + SelfCheckRegistry.markStatus("zalo_artifact", "stale", "neighbouring release profile", + "No exact profile resolved for the installed Zalo; symbols taken from " + + fallback.profile.source + " and gated by preflight", ""); + return; + } + if (artifact.signerUnverified()) { + SelfCheckRegistry.markStatus("zalo_artifact", "ok", "versionCode profile", + "Zalo signing certificate differs from the mapped one; provenance unverified, " + + "anchors gated by preflight", + ""); + return; + } if (artifact.containerUnverified()) { SelfCheckRegistry.markStatus("zalo_artifact", "ok", "versionCode and signer profile", "Base APK container differs from the mapped one; anchors gated by preflight", diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index d874caa..0f935e5 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -13,6 +13,9 @@ Đã gửi lệnh khởi động lại Quyền root bị từ chối Không thể khởi động lại + Mở thông tin ứng dụng Zalo + Buộc dừng Zalo thủ công, sau đó mở lại. + Không thể mở thông tin ứng dụng Zalo Trạng thái Giao diện Zalo Quảng cáo và thông báo diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e88e9e..7667a20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,9 +7,26 @@ Restart command sent Root access denied Restart command failed + Root required; force stop Zalo to apply. + Root access + Granted · tap to check again + Denied · tap to check again + Not available · tap to check again + Open Zalo app info + Force stop Zalo manually, then reopen it. + Could not open Zalo app info Runtime status No runtime data yet %1$d active · %2$d failed · %3$d stale + Runtime environment + Waiting for a Zalo runtime report + %1$s · Resource hooks: %2$s + LSPosed + LSPatch + Unknown Xposed runtime + observed + pending observation + unavailable Status Zalo interface Ads & notifications @@ -44,6 +61,9 @@ Tiếng Việt Unavailable. Unavailable. %1$s + Root required. + This runtime did not expose resource hooks. + %1$s %2$s Hide Discovery tab @@ -159,11 +179,12 @@ Prepare metadata-only report Uses current package, symbol, self-check, and safe setting state. No root commands. Start guided capture - Temporarily enables debug.zalopatch for up to 30 minutes. Root approval is required. + Root is required for filtered logs. Without root, the report contains metadata and structured runtime evidence only. Additional diagnostics recommended This category usually needs runtime evidence. Guided capture temporarily enables filtered diagnostics. You can still prepare a metadata-only report. Capture active Debug logging is temporarily enabled. Restart Zalo, reproduce the problem, return here, then finish. It restores automatically after 30 minutes. + Root is unavailable, so no logs are being collected. Reproduce the problem, return here, then finish the metadata-only report. Schema discovery and debug logging are temporarily armed. Restart Zalo, open Messages until rows load, open Me, return here, then finish. Nothing activates automatically. This Zalo version already has an exact bundled map. Debug logging is temporarily enabled for the compatibility problem; remap discovery is not armed. Exact version map available @@ -197,6 +218,7 @@ Zalo Patch report ID Report ID copied Capture started. Restart Zalo and reproduce the problem. + Metadata-only capture started. Root logs are unavailable. Capture finished. Debug logging restored and report ready. Capture cancelled. Debug logging restored. Capture expired. Previous debug logging restored. diff --git a/app/src/test/java/com/ez/zalopatch/DiagnosticReportContractTest.java b/app/src/test/java/com/ez/zalopatch/DiagnosticReportContractTest.java index fa84a21..4ba491a 100644 --- a/app/src/test/java/com/ez/zalopatch/DiagnosticReportContractTest.java +++ b/app/src/test/java/com/ez/zalopatch/DiagnosticReportContractTest.java @@ -166,4 +166,19 @@ public void uploadRequiresReviewOfTheExactDraft() { assertFalse(DiagnosticReportActivity.canUpload("R1-A", "R1-B")); assertTrue(DiagnosticReportActivity.canUpload("R1-A", "R1-A")); } + + @Test + public void rootlessCaptureProducesExplicitMetadataOnlyEnvelope() { + DiagnosticCaptureCollector.CapturedData data = + new DiagnosticCaptureCollector(new DiagnosticRootProcessRunner()) + .collect(123L, RootAccess.State.ABSENT); + + assertEquals("metadata_only_root_denied", data.outcome); + assertEquals("error", data.rootAccessStatus); + assertEquals(java.util.Collections.singletonList("root_access"), + data.commandFailures); + assertEquals("", data.logs); + assertEquals("", data.crashExcerpt); + assertEquals("", data.lsposedLines); + } } diff --git a/app/src/test/java/com/ez/zalopatch/NotificationRuleStoreModelTest.java b/app/src/test/java/com/ez/zalopatch/NotificationRuleStoreModelTest.java index 86f6ce6..74d6458 100644 --- a/app/src/test/java/com/ez/zalopatch/NotificationRuleStoreModelTest.java +++ b/app/src/test/java/com/ez/zalopatch/NotificationRuleStoreModelTest.java @@ -9,6 +9,42 @@ import java.util.List; public final class NotificationRuleStoreModelTest { + @Test + public void propertyRulesWinWhenMirrorIsPresent() throws Exception { + NotificationRuleStore.RuleSet property = new NotificationRuleStore.RuleSet( + java.util.Collections.singletonList("property"), null, null, null); + NotificationRuleStore.RuleSet provider = new NotificationRuleStore.RuleSet( + java.util.Collections.singletonList("provider"), null, null, null); + + NotificationRuleStore.RuleSet resolved = NotificationRuleStore.resolve( + NotificationRuleStore.encode(property), NotificationRuleStore.encode(provider)); + + assertEquals(java.util.Collections.singletonList("property"), + resolved.list(NotificationRuleStore.Type.KEYWORD_BLOCKLIST)); + } + + @Test + public void providerRulesFillAnAbsentMirror() throws Exception { + NotificationRuleStore.RuleSet provider = new NotificationRuleStore.RuleSet( + null, java.util.Collections.singletonList("allowed"), null, null); + + NotificationRuleStore.RuleSet resolved = NotificationRuleStore.resolve( + null, NotificationRuleStore.encode(provider)); + + assertEquals(java.util.Collections.singletonList("allowed"), + resolved.list(NotificationRuleStore.Type.KEYWORD_EXCEPTIONS)); + } + + @Test + public void malformedPresentMirrorDoesNotFallThroughToProvider() throws Exception { + NotificationRuleStore.RuleSet provider = new NotificationRuleStore.RuleSet( + java.util.Collections.singletonList("provider"), null, null, null); + + NotificationRuleStore.RuleSet resolved = NotificationRuleStore.resolve( + "not-json", NotificationRuleStore.encode(provider)); + + assertEquals(0, resolved.total()); + } @Test public void rulesAreTrimmedAndDeduplicated() { NotificationRuleStore.RuleSet rules = new NotificationRuleStore.RuleSet( diff --git a/app/src/test/java/com/ez/zalopatch/RequirementGateTest.java b/app/src/test/java/com/ez/zalopatch/RequirementGateTest.java new file mode 100644 index 0000000..3c86ed5 --- /dev/null +++ b/app/src/test/java/com/ez/zalopatch/RequirementGateTest.java @@ -0,0 +1,38 @@ +package com.ez.zalopatch; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class RequirementGateTest { + @Test + public void rootRequirementNeedsGrantedRoot() { + assertTrue(RequirementGate.isMet(Tweaks.Requirement.ROOT, + RootAccess.State.GRANTED, RuntimeEnvironment.ResourceHooks.PENDING)); + assertFalse(RequirementGate.isMet(Tweaks.Requirement.ROOT, + RootAccess.State.DENIED, RuntimeEnvironment.ResourceHooks.OBSERVED)); + assertFalse(RequirementGate.isMet(Tweaks.Requirement.ROOT, + RootAccess.State.ABSENT, RuntimeEnvironment.ResourceHooks.PENDING)); + } + + @Test + public void resourceRequirementStaysEnabledUntilObservationExists() { + assertTrue(RequirementGate.isMet(Tweaks.Requirement.RESOURCE_HOOKS, + RootAccess.State.ABSENT, RuntimeEnvironment.ResourceHooks.PENDING)); + assertTrue(RequirementGate.isMet(Tweaks.Requirement.RESOURCE_HOOKS, + RootAccess.State.ABSENT, RuntimeEnvironment.ResourceHooks.OBSERVED)); + assertFalse(RequirementGate.isMet(Tweaks.Requirement.RESOURCE_HOOKS, + RootAccess.State.GRANTED, RuntimeEnvironment.ResourceHooks.UNAVAILABLE)); + } + + @Test + public void registryDeclaresOnlyObservedResourceRequirement() { + for (Tweaks.Item item : Tweaks.ITEMS) { + assertEquals(item.key.equals(Tweaks.KEY_HIDE_ZCLOUD_BANNER) + ? Tweaks.Requirement.RESOURCE_HOOKS : Tweaks.Requirement.NONE, + item.requirement); + } + } +} diff --git a/app/src/test/java/com/ez/zalopatch/RootAccessTest.java b/app/src/test/java/com/ez/zalopatch/RootAccessTest.java new file mode 100644 index 0000000..ab45ada --- /dev/null +++ b/app/src/test/java/com/ez/zalopatch/RootAccessTest.java @@ -0,0 +1,33 @@ +package com.ez.zalopatch; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class RootAccessTest { + @Test + public void rootUidIsGranted() { + assertEquals(RootAccess.State.GRANTED, RootAccess.classify( + new DiagnosticRootProcessRunner.Result(0, "0\n", false, false))); + } + + @Test + public void nonRootOrDeniedCommandIsDenied() { + assertEquals(RootAccess.State.DENIED, RootAccess.classify( + new DiagnosticRootProcessRunner.Result(1, "permission denied", false, false))); + assertEquals(RootAccess.State.DENIED, RootAccess.classify( + new DiagnosticRootProcessRunner.Result(0, "2000\n", false, false))); + } + + @Test + public void missingSuBinaryIsAbsent() { + assertEquals(RootAccess.State.ABSENT, RootAccess.classify( + new DiagnosticRootProcessRunner.Result(-1, "", false, false))); + } + + @Test + public void timeoutIsDeniedRatherThanReprobedRepeatedly() { + assertEquals(RootAccess.State.DENIED, RootAccess.classify( + new DiagnosticRootProcessRunner.Result(-1, "", true, false))); + } +} diff --git a/app/src/test/java/com/ez/zalopatch/RuntimeEnvironmentTest.java b/app/src/test/java/com/ez/zalopatch/RuntimeEnvironmentTest.java new file mode 100644 index 0000000..8256f40 --- /dev/null +++ b/app/src/test/java/com/ez/zalopatch/RuntimeEnvironmentTest.java @@ -0,0 +1,68 @@ +package com.ez.zalopatch; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class RuntimeEnvironmentTest { + @Test + public void lspatchMarkerWinsOverSharedLsposedBridge() { + assertEquals(RuntimeEnvironment.Framework.LSPATCH, RuntimeEnvironment.detect( + true, true, 102, "/data/adb/lspd/framework/lspd.dex")); + } + + @Test + public void lsposedUsesBoundedClassApiAndLoaderEvidence() { + assertEquals(RuntimeEnvironment.Framework.LSPOSED, RuntimeEnvironment.detect( + false, true, 82)); + assertEquals(RuntimeEnvironment.Framework.LSPOSED, RuntimeEnvironment.detect( + false, false, 102)); + assertEquals(RuntimeEnvironment.Framework.LSPOSED, RuntimeEnvironment.detect( + false, false, 82, "/data/adb/modules/zygisk_lsposed/bin/daemon")); + assertEquals(RuntimeEnvironment.Framework.UNKNOWN, RuntimeEnvironment.detect( + false, false, 82, "/data/app/com.ez.zalopatch/base.apk")); + } + + @Test + public void resourceObservationIsMonotonicOnlyWithinSameEpoch() { + RuntimeEnvironment.Snapshot first = snapshot( + RuntimeEnvironment.Framework.LSPOSED, + RuntimeEnvironment.ResourceHooks.PENDING, 1, 100L, 1L); + RuntimeEnvironment.Snapshot observed = snapshot( + RuntimeEnvironment.Framework.LSPOSED, + RuntimeEnvironment.ResourceHooks.OBSERVED, 1, 100L, 2L); + RuntimeEnvironment.Snapshot merged = RuntimeEnvironment.merge(first, observed); + assertTrue(merged.resourceHooksObserved); + + RuntimeEnvironment.Snapshot laterFalse = snapshot( + RuntimeEnvironment.Framework.LSPOSED, + RuntimeEnvironment.ResourceHooks.PENDING, 1, 100L, 3L); + assertTrue(RuntimeEnvironment.merge(merged, laterFalse).resourceHooksObserved); + + RuntimeEnvironment.Snapshot unavailable = snapshot( + RuntimeEnvironment.Framework.LSPOSED, + RuntimeEnvironment.ResourceHooks.UNAVAILABLE, 1, 100L, 4L); + assertEquals(RuntimeEnvironment.ResourceHooks.UNAVAILABLE, + RuntimeEnvironment.merge(first, unavailable).resourceHooks); + assertEquals(RuntimeEnvironment.ResourceHooks.OBSERVED, + RuntimeEnvironment.merge(unavailable, observed).resourceHooks); + assertEquals(RuntimeEnvironment.ResourceHooks.OBSERVED, + RuntimeEnvironment.merge(observed, unavailable).resourceHooks); + + RuntimeEnvironment.Snapshot lspatch = snapshot( + RuntimeEnvironment.Framework.LSPATCH, + RuntimeEnvironment.ResourceHooks.UNAVAILABLE, 1, 100L, 5L); + assertFalse(RuntimeEnvironment.merge(merged, lspatch).resourceHooksObserved); + assertEquals(RuntimeEnvironment.ResourceHooks.UNAVAILABLE, + RuntimeEnvironment.merge(merged, lspatch).resourceHooks); + } + + private static RuntimeEnvironment.Snapshot snapshot( + RuntimeEnvironment.Framework framework, RuntimeEnvironment.ResourceHooks resources, + int moduleVersion, long zaloVersion, long updatedAt) { + return new RuntimeEnvironment.Snapshot(true, framework, resources, + moduleVersion, zaloVersion, updatedAt); + } +} diff --git a/app/src/test/java/com/ez/zalopatch/SymbolCatalogContractTest.java b/app/src/test/java/com/ez/zalopatch/SymbolCatalogContractTest.java index ba377a4..b2f5be2 100644 --- a/app/src/test/java/com/ez/zalopatch/SymbolCatalogContractTest.java +++ b/app/src/test/java/com/ez/zalopatch/SymbolCatalogContractTest.java @@ -19,27 +19,33 @@ public final class SymbolCatalogContractTest { private static final String BASE_HASH = "a".repeat(64); private static final String SIGNER_HASH = "b".repeat(64); + /** + * The payload names the container the profile was mapped from; the installed artifact need not + * be that container, so verification binds versionCode only. An entry therefore stays usable on + * a Play bundle variant (Decision 14) and on a re-signed redistribution (Decision 15). Which + * tier the artifact matched on is decided in ZaloArtifactState, not here. + */ @Test public void verifiesSignedExactArtifactEnvelope() throws Exception { Fixture fixture = fixture(152); SymbolCatalogContract.Entry entry = SymbolCatalogContract.verify( - fixture.envelope, fixture.publicKeyPem, VERSION, BASE_HASH, SIGNER_HASH, 152); + fixture.envelope, fixture.publicKeyPem, VERSION, 152); assertEquals(14, entry.sequence); assertEquals(fixture.digest, entry.digest); assertEquals(14, new JSONObject(entry.profileJson).getInt("schema_revision")); } @Test - public void wrongArtifactSignatureAndMinimumModuleFailClosed() throws Exception { + public void wrongVersionSignatureAndMinimumModuleFailClosed() throws Exception { Fixture fixture = fixture(153); assertThrows(IllegalArgumentException.class, () -> SymbolCatalogContract.verify( - fixture.envelope, fixture.publicKeyPem, VERSION, "c".repeat(64), SIGNER_HASH, 152)); + fixture.envelope, fixture.publicKeyPem, VERSION + 1L, 153)); assertThrows(IllegalArgumentException.class, () -> SymbolCatalogContract.verify( - fixture.envelope, fixture.publicKeyPem, VERSION, BASE_HASH, SIGNER_HASH, 152)); + fixture.envelope, fixture.publicKeyPem, VERSION, 152)); byte[] changed = fixture.envelope.clone(); changed[changed.length - 8] ^= 1; assertThrows(Exception.class, () -> SymbolCatalogContract.verify( - changed, fixture.publicKeyPem, VERSION, BASE_HASH, SIGNER_HASH, 153)); + changed, fixture.publicKeyPem, VERSION, 153)); } @Test diff --git a/app/src/test/java/com/ez/zalopatch/SymbolFallbackOrderTest.java b/app/src/test/java/com/ez/zalopatch/SymbolFallbackOrderTest.java new file mode 100644 index 0000000..329c9dd --- /dev/null +++ b/app/src/test/java/com/ez/zalopatch/SymbolFallbackOrderTest.java @@ -0,0 +1,65 @@ +package com.ez.zalopatch; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +/** + * When no profile covers the installed release, neighbouring bundled profiles are preflighted in + * this order and the first whose anchors resolve backs the hooks (Decision 15). Distance is the + * only signal available before preflight runs, so the order decides which mapping is tried against + * the live classloader first. + */ +public final class SymbolFallbackOrderTest { + private static final long V_26_06_02 = 260602901L; + private static final long V_26_07_01 = 260701901L; + private static final long V_26_08_01 = 260801903L; + + @Test + public void nearestMappedReleaseIsTriedFirst() { + // An unmapped release just after 26.08.01, the realistic case: Zalo shipped, nobody remapped + // yet, and the newest mapping is the closest thing to it. + List codes = codes(V_26_06_02, V_26_07_01, V_26_08_01); + + SymbolSchema.orderByVersionDistance(codes, 260900001L); + + assertEquals(Arrays.asList(V_26_08_01, V_26_07_01, V_26_06_02), codes); + } + + @Test + public void anOlderInstalledReleasePrefersTheOlderMapping() { + List codes = codes(V_26_06_02, V_26_07_01, V_26_08_01); + + SymbolSchema.orderByVersionDistance(codes, 260600001L); + + assertEquals(Arrays.asList(V_26_06_02, V_26_07_01, V_26_08_01), codes); + } + + @Test + public void anInstalledReleaseBetweenTwoMappingsTakesTheNearer() { + List codes = codes(V_26_07_01, V_26_08_01); + + SymbolSchema.orderByVersionDistance(codes, V_26_08_01 - 10L); + + assertEquals(Arrays.asList(V_26_08_01, V_26_07_01), codes); + } + + @Test + public void anExactTieBreaksTowardTheNewerRelease() { + // Equidistant mappings either side. Zalo carries anchors forward more often than it + // reinstates an older shape, so the newer mapping is the better first guess. + List codes = codes(100L, 300L); + + SymbolSchema.orderByVersionDistance(codes, 200L); + + assertEquals(Arrays.asList(300L, 100L), codes); + } + + private static List codes(Long... values) { + return new ArrayList<>(Arrays.asList(values)); + } +} diff --git a/app/src/test/java/com/ez/zalopatch/ZaloArtifactGateTest.java b/app/src/test/java/com/ez/zalopatch/ZaloArtifactGateTest.java index c6c3f30..b1a8c87 100644 --- a/app/src/test/java/com/ez/zalopatch/ZaloArtifactGateTest.java +++ b/app/src/test/java/com/ez/zalopatch/ZaloArtifactGateTest.java @@ -7,11 +7,17 @@ import static org.junit.Assert.assertTrue; /** - * The artifact gate matches on exact versionCode plus Zalo signing certificate. Google Play serves - * per-device bundle variants and re-stamps its signing block per download, so a single release has - * several base APK hashes over byte-identical code. Zalo 26.08.01 (260801903) was observed with - * base APK hashes afd9aa96 (Play), 84f6700b and a3c19cf7 (APKMirror variants) whose classes*.dex - * are identical. + * The artifact gate matches on exact versionCode. Container identity is reported as a tier, not + * gated. + * + *

Google Play serves per-device bundle variants and re-stamps its signing block per download, so + * a single release has several base APK hashes over byte-identical code. Zalo 26.08.01 (260801903) + * was observed with base APK hashes afd9aa96 (Play), 84f6700b and a3c19cf7 (APKMirror variants) + * whose classes*.dex are identical (Decision 14). + * + *

The signer is likewise not a gate (Decision 15). Re-signing rezips without rebuilding dex, so + * clone tools and mirror redistributions carry a fresh signer over unchanged code, and repackaging + * does not re-obfuscate. Signer 2d6eeb20 was observed against versionCode 260801903. */ public final class ZaloArtifactGateTest { private static final String SIGNER = @@ -20,6 +26,8 @@ public final class ZaloArtifactGateTest { "afd9aa96e7f4beb772ad1632d17f5fe4a6bd12c1e3ce5978a6b2ec43ac9d2a57"; private static final String VARIANT_APK = "84f6700bb2ac5017d4cd39d01042bee68e8d54e2ab8a11a6e60240eacf0c81c6"; + private static final String FOREIGN_SIGNER = + "2d6eeb20365289e16e7be662f6aaa06682f4ebf6725c6f865916a720e0e277b8"; @Test public void mappedContainerReportsExactApkEvidence() { @@ -42,12 +50,25 @@ public void otherContainerOfSameReleaseStaysReadyAsUnverifiedMatch() { } @Test - public void foreignSignerIsRejected() { + public void foreignSignerStaysReadyAsVersionOnlyMatch() { ZaloArtifactState.Decision decision = ZaloArtifactState.decide( - true, "", "cached", SIGNER, MAPPED_APK, "00" + SIGNER.substring(2), MAPPED_APK); + true, "", "cached", SIGNER, MAPPED_APK, FOREIGN_SIGNER, VARIANT_APK); - assertEquals("mismatch", decision.status); - assertEquals(ZaloArtifactState.EVIDENCE_NONE, decision.evidence); + assertEquals("ready", decision.status); + assertEquals(ZaloArtifactState.EVIDENCE_VERSION_ONLY, decision.evidence); + assertEquals("", decision.error); + } + + /** + * A re-signed container that kept the mapped base APK hash cannot occur in practice, since + * re-signing rewrites the archive. Pinned anyway so the signer branch is what selects the tier. + */ + @Test + public void foreignSignerOutranksAMatchingBaseHash() { + ZaloArtifactState.Decision decision = ZaloArtifactState.decide( + true, "", "cached", SIGNER, MAPPED_APK, FOREIGN_SIGNER, MAPPED_APK); + + assertEquals(ZaloArtifactState.EVIDENCE_VERSION_ONLY, decision.evidence); } @Test @@ -55,24 +76,24 @@ public void authorizationSurvivesAnInstallChangeThatKeepsTheProfile() { // An on-demand split install moves the install identity but not the profile. Play adds // feature splits (mediapipe_faceeffect, tensorflowLite) whenever a surface first needs // them, and devices differ in which ones they carry on one Zalo release. - assertTrue(ZaloArtifactState.authorizes("ready", true, SIGNER, SIGNER, "profile", "profile")); + assertTrue(ZaloArtifactState.authorizes("ready", true, "profile", "profile")); } @Test public void aProfileTheHookCannotResolveIsNotAuthorized() { - assertFalse(ZaloArtifactState.authorizes("ready", false, SIGNER, SIGNER, "", "profile")); + assertFalse(ZaloArtifactState.authorizes("ready", false, "", "profile")); } @Test public void aVersionMoveChangesTheProfileHashAndDeauthorizes() { assertFalse(ZaloArtifactState.authorizes( - "ready", true, SIGNER, SIGNER, "new-version-profile", "old-version-profile")); + "ready", true, "new-version-profile", "old-version-profile")); } @Test public void pendingReconciliationIsNotAuthorized() { assertFalse(ZaloArtifactState.authorizes( - "pending", true, SIGNER, SIGNER, "profile", "profile")); + "pending", true, "profile", "profile")); } @Test