From 8a1cc3d6f629dcaa487f62574cb0713c05645aa0 Mon Sep 17 00:00:00 2001 From: amarinne <141455493+amarinne@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:52:28 +0700 Subject: [PATCH] Add frequent scheduled message backup --- README.md | 1 + app/build.gradle.kts | 4 +- .../com/ez/zalopatch/SettingsBackupTest.java | 4 + .../com/ez/zalopatch/SettingsUiSmokeTest.java | 23 ++-- .../ez/zalopatch/SymbolSchemaProfileTest.java | 9 ++ app/src/main/assets/symbol-schema.json | 4 + .../com/ez/zalopatch/SectionActivity.java | 99 ++++++++++++++++ .../main/java/com/ez/zalopatch/Settings.java | 2 + .../java/com/ez/zalopatch/StatusActivity.java | 77 ++---------- .../java/com/ez/zalopatch/TweakHookInfo.java | 11 ++ .../main/java/com/ez/zalopatch/Tweaks.java | 13 ++- .../com/ez/zalopatch/ZpSettingsActivity.java | 18 ++- .../zalopatch/xposed/core/MainFeatures.java | 6 + .../xposed/core/SymbolPreflight.java | 28 ++++- .../xposed/features/BackupPushDecision.java | 21 ++++ .../xposed/features/BackupPushFeature.java | 110 ++++++++++++++++++ .../xposed/features/ZaloPrefsReader.java | 62 ++++++++++ app/src/main/res/values-vi/arrays.xml | 6 + app/src/main/res/values-vi/strings.xml | 6 + app/src/main/res/values/arrays.xml | 12 ++ app/src/main/res/values/strings.xml | 7 +- .../com/ez/zalopatch/SettingsModelTest.java | 3 + .../features/BackupPushDecisionTest.java | 33 ++++++ 23 files changed, 473 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/com/ez/zalopatch/xposed/features/BackupPushDecision.java create mode 100644 app/src/main/java/com/ez/zalopatch/xposed/features/BackupPushFeature.java create mode 100644 app/src/main/java/com/ez/zalopatch/xposed/features/ZaloPrefsReader.java create mode 100644 app/src/test/java/com/ez/zalopatch/xposed/features/BackupPushDecisionTest.java diff --git a/README.md b/README.md index 9616293..26a74e0 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - Block analytics and advertising services. - Local notification history. - Call audio recording. +- More frequent scheduled message backups while preserving Zalo's native backup guards. ## Requirements diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e2457b4..2db7919 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 = 163 - versionName = "0.4.159-status-privacy+B163-20260820T1716Z" + versionCode = 166 + versionName = "0.4.162-frequent-message-backup+B166-20260821T1136Z" 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/SettingsBackupTest.java b/app/src/androidTest/java/com/ez/zalopatch/SettingsBackupTest.java index 7025d7f..6fb9b83 100644 --- a/app/src/androidTest/java/com/ez/zalopatch/SettingsBackupTest.java +++ b/app/src/androidTest/java/com/ez/zalopatch/SettingsBackupTest.java @@ -11,6 +11,8 @@ public void testPortableSettingsRoundTrip() throws Exception { Map source = new LinkedHashMap<>(); source.put(Tweaks.KEY_HIDE_DISCOVERY_TAB, true); source.put(Tweaks.KEY_NOTIFICATION_HISTORY_RETENTION, 250); + source.put(Tweaks.KEY_BACKUP_FREQUENT_PUSH, true); + source.put(Tweaks.KEY_BACKUP_PUSH_INTERVAL, 3); String json = SettingsBackup.encodeSettings(source, 1234L, "test"); Map decoded = SettingsBackup.decodeSettings(json); @@ -19,6 +21,8 @@ public void testPortableSettingsRoundTrip() throws Exception { assertFalse(decoded.containsKey(Tweaks.KEY_CALL_RECORDING_PROBE)); assertFalse(decoded.containsKey(Tweaks.KEY_AUTO_RECORD_CALLS)); assertFalse(decoded.containsKey(Tweaks.KEY_CALL_RECORDING_NOTIFICATIONS)); + assertEquals(true, decoded.get(Tweaks.KEY_BACKUP_FREQUENT_PUSH)); + assertEquals(3, decoded.get(Tweaks.KEY_BACKUP_PUSH_INTERVAL)); } public void testUnsupportedRetentionFallsBackToDefault() 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 fedec67..27ec97d 100644 --- a/app/src/androidTest/java/com/ez/zalopatch/SettingsUiSmokeTest.java +++ b/app/src/androidTest/java/com/ez/zalopatch/SettingsUiSmokeTest.java @@ -24,9 +24,18 @@ public void testDependencyPagesOpenInSingleHost() { open(activity, SectionActivity.SettingsFragment.forSection(Tweaks.SECTION_CALLS)); assertTrue(current(activity) instanceof SectionActivity.SettingsFragment); + SectionActivity.SettingsFragment backup = + SectionActivity.SettingsFragment.forSection(Tweaks.SECTION_BACKUP); + open(activity, backup); + assertNotNull(backup.findPreference(Tweaks.KEY_BACKUP_FREQUENT_PUSH)); + assertNotNull(backup.findPreference(Tweaks.KEY_BACKUP_PUSH_INTERVAL)); + SectionActivity.SettingsFragment developer = SectionActivity.SettingsFragment.forSection(Tweaks.SECTION_DEVELOPER); open(activity, developer); + assertNotNull(developer.findPreference(StatusActivity.INTERNAL_ROOT_ACCESS)); + assertNotNull(developer.findPreference( + SectionActivity.SettingsFragment.DEVELOPER_RUNTIME_ENVIRONMENT)); assertNotNull(developer.findPreference("internal.developer_runtime_status")); assertNotNull(developer.findPreference("internal.developer_compatibility_catalog")); assertNotNull(developer.findPreference(Tweaks.KEY_CALL_RECORDING_PROBE)); @@ -122,7 +131,7 @@ public void testDiagnosticDescriptionUsesConnectedNonPersistentRow() { assertFalse(row.isFocusable()); } - public void testMainRestartRemainsVisibleWithPendingChanges() { + public void testMainRestartFallsBackToAppInfoWithoutRoot() { StatusActivity activity = getActivity(); SharedPreferences ui = SettingsChanges.preferences(activity); Map originalUi = new HashMap<>(ui.getAll()); @@ -136,22 +145,20 @@ public void testMainRestartRemainsVisibleWithPendingChanges() { 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.", + assertTrue(restart.isEnabled()); + assertEquals("Without root, opens Zalo app info for manual force stop.", String.valueOf(restart.getSummary())); - assertNotNull(appInfo); - assertNotNull(root); + assertNull(root); assertEquals(android.view.View.VISIBLE, activity.findViewById(R.id.zp_apply_bar).getVisibility()); - assertEquals("Root required; force stop Zalo to apply.", + assertEquals("1 change pending", String.valueOf(((android.widget.TextView) activity.findViewById( R.id.zp_apply_message)).getText())); - assertFalse(activity.findViewById(R.id.zp_apply_button).isEnabled()); + assertTrue(activity.findViewById(R.id.zp_apply_button).isEnabled()); } finally { RootAccess.restoreForTest(activity, originalRoot); restorePreferences(ui, originalUi); diff --git a/app/src/androidTest/java/com/ez/zalopatch/SymbolSchemaProfileTest.java b/app/src/androidTest/java/com/ez/zalopatch/SymbolSchemaProfileTest.java index 9908e2c..dea0075 100644 --- a/app/src/androidTest/java/com/ez/zalopatch/SymbolSchemaProfileTest.java +++ b/app/src/androidTest/java/com/ez/zalopatch/SymbolSchemaProfileTest.java @@ -310,12 +310,21 @@ private static void restoreString(SharedPreferences.Editor editor, String key, S } private void assertHookIdentities(SymbolSchema.Active active) { + boolean backupMapped = active.minCode == 260801903L; + assertEquals(backupMapped, + !active.string("symbols.backup.interval_reader_class", "").isEmpty()); + assertEquals(backupMapped, + !active.string("symbols.backup.interval_reader_method", "").isEmpty()); for (Tweaks.Item item : Tweaks.ITEMS) { TweakHookInfo.Info info = TweakHookInfo.forKey(item.key, active); assertNotNull("Missing hook metadata for " + item.key, info); assertFalse("Missing hook path for " + item.key, info.path.isEmpty()); assertFalse("Placeholder hook path for " + item.key, "No runtime hook".equals(info.path)); + if (Tweaks.KEY_BACKUP_FREQUENT_PUSH.equals(item.key) && !backupMapped) { + assertTrue(info.path.contains("")); + continue; + } for (String symbol : info.driftSymbols) { assertFalse("Fallback identity displayed for " + item.key + ": " + symbol, symbol.contains("<")); diff --git a/app/src/main/assets/symbol-schema.json b/app/src/main/assets/symbol-schema.json index f091cb1..295712f 100644 --- a/app/src/main/assets/symbol-schema.json +++ b/app/src/main/assets/symbol-schema.json @@ -1102,6 +1102,10 @@ "analytics_screen_accessor": "A", "analytics_session_accessor": "B", "analytics_view_accessor": "C" + }, + "backup": { + "interval_reader_class": "u40.p0", + "interval_reader_method": "Y" } } } diff --git a/app/src/main/java/com/ez/zalopatch/SectionActivity.java b/app/src/main/java/com/ez/zalopatch/SectionActivity.java index 0497bbe..18ff661 100644 --- a/app/src/main/java/com/ez/zalopatch/SectionActivity.java +++ b/app/src/main/java/com/ez/zalopatch/SectionActivity.java @@ -29,10 +29,14 @@ public static final class SettingsFragment extends ZpPreferenceFragment { private static final String ARG_SECTION = "section"; private static final String MASTER_TELEMETRY = "internal.telemetry_master"; static final String DEVELOPER_REPORT = "internal.developer_report"; + static final String DEVELOPER_RUNTIME_ENVIRONMENT = + "internal.developer_runtime_environment"; private Map selfCheckRows = new HashMap<>(); private final Map switches = new HashMap<>(); private final Map pageStatuses = new HashMap<>(); private ZpRowPreference developerRuntimeStatus; + private ZpRowPreference developerRootAccess; + private ZpRowPreference developerRuntimeEnvironment; private ZpRowPreference recordingsRow; private ZpMainSwitchPreference telemetryMaster; private boolean recordingCountLoading; @@ -91,6 +95,7 @@ public void onResume() { } refreshRecordingCount(); refreshDeveloperRuntimeStatus(); + refreshDeveloperEnvironment(); } private void buildScreen() { @@ -98,6 +103,8 @@ private void buildScreen() { switches.clear(); pageStatuses.clear(); developerRuntimeStatus = null; + developerRootAccess = null; + developerRuntimeEnvironment = null; recordingsRow = null; telemetryMaster = null; resourceRequirementMet = RequirementGate.isMet( @@ -149,6 +156,21 @@ private void addDeveloperTools(ZpSection section, Context context) { }); section.add(displayHookDetail); + developerRootAccess = PreferenceUi.action(context, + getString(R.string.zp_root_access_title), null); + developerRootAccess.setKey(StatusActivity.INTERNAL_ROOT_ACCESS); + developerRootAccess.setOnPreferenceClickListener(preference -> { + ((StatusActivity) requireActivity()).recheckRootAccess(); + return true; + }); + section.add(developerRootAccess); + + developerRuntimeEnvironment = PreferenceUi.info(context, + getString(R.string.zp_runtime_environment_title), null); + developerRuntimeEnvironment.setKey(DEVELOPER_RUNTIME_ENVIRONMENT); + section.add(developerRuntimeEnvironment); + refreshDeveloperEnvironment(); + SelfCheckData.Counts counts = SelfCheckData.counts(SelfCheckData.load(context)); developerRuntimeStatus = PreferenceUi.nav(context, getString(R.string.zp_runtime_status_title), runtimeSummary(counts)); @@ -197,6 +219,49 @@ private void refreshDeveloperRuntimeStatus() { developerRuntimeStatus.refreshStyle(); } + void refreshDeveloperEnvironment() { + if (!isAdded()) return; + Context context = requireContext(); + if (developerRootAccess != null) { + RootAccess.State state = RootAccess.cached(context); + int summary = state == RootAccess.State.GRANTED + ? R.string.zp_root_access_granted + : state == RootAccess.State.DENIED + ? R.string.zp_root_access_denied + : R.string.zp_root_access_absent; + developerRootAccess.value(getString(summary)); + developerRootAccess.refreshStyle(); + } + if (developerRuntimeEnvironment != null) { + developerRuntimeEnvironment.value(runtimeEnvironmentSummary(context)); + developerRuntimeEnvironment.refreshStyle(); + } + } + + private String runtimeEnvironmentSummary(Context context) { + RuntimeEnvironment.Snapshot snapshot = RuntimeEnvironment.current(context); + if (!snapshot.reported) { + return getString(R.string.zp_runtime_environment_pending); + } + int framework = 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(framework), 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; + } + private String runtimeSummary(SelfCheckData.Counts counts) { if (counts.total() == 0) return getString(R.string.zp_runtime_status_empty); return getString(R.string.zp_runtime_status_counts, @@ -228,6 +293,14 @@ private void addRowsForKey(ZpSection section, SymbolSchema.Active schema, String Tweaks.KEY_FILTER_POPOVER_CATEGORIES); return; } + if (Tweaks.KEY_BACKUP_PUSH_INTERVAL.equals(key)) { + section.addDependent(backupIntPreference(context, key, + R.string.zp_backup_interval_title, + R.array.zp_backup_interval_entries, + R.array.zp_backup_interval_values), + Tweaks.KEY_BACKUP_FREQUENT_PUSH); + return; + } Tweaks.Item item = itemFor(key); if (item == null) { return; @@ -289,6 +362,29 @@ private Preference defaultInboxFilterPreference(Context context) { return preference; } + private Preference backupIntPreference(Context context, String key, int title, + int entries, int values) { + ZpListPreference preference = new ZpListPreference(context); + preference.setKey(key); + preference.setTitle(title); + preference.setEntries(entries); + preference.setEntryValues(values); + preference.setValue(String.valueOf(SettingsStore.getInt(context, key))); + preference.setSummaryProvider(androidx.preference.ListPreference + .SimpleSummaryProvider.getInstance()); + preference.setOnPreferenceChangeListener((changedPreference, newValue) -> { + try { + SettingsStore.putInt(context, key, + Integer.parseInt(String.valueOf(newValue))); + SettingsChanges.markChanged(context, key); + return true; + } catch (NumberFormatException ignored) { + return false; + } + }); + return preference; + } + private static Tweaks.Item itemFor(String key) { for (Tweaks.Item item : Tweaks.ITEMS) { if (item.key.equals(key)) { @@ -524,6 +620,9 @@ private String[] relatedFeatures(String key) { if (Tweaks.KEY_CALL_RECORDING_NOTIFICATIONS.equals(key)) { return new String[]{"calls.auto_record.notifications"}; } + if (Tweaks.KEY_BACKUP_FREQUENT_PUSH.equals(key)) { + return new String[]{"backup.scheduled"}; + } if (Tweaks.KEY_HIDE_MEDIA_BOX.equals(key)) { return new String[]{"inbox.media_box"}; } diff --git a/app/src/main/java/com/ez/zalopatch/Settings.java b/app/src/main/java/com/ez/zalopatch/Settings.java index 454440c..1ed9d88 100644 --- a/app/src/main/java/com/ez/zalopatch/Settings.java +++ b/app/src/main/java/com/ez/zalopatch/Settings.java @@ -47,6 +47,8 @@ private Setting( settings.add(intSetting(Tweaks.KEY_DEFAULT_INBOX_FILTER, Tweaks.SECTION_INBOX, 0, java.util.Arrays.asList(0, 1, 2, 3, 4), true, true)); + settings.add(intSetting(Tweaks.KEY_BACKUP_PUSH_INTERVAL, Tweaks.SECTION_BACKUP, + 6, java.util.Arrays.asList(1, 3, 6, 12), true, true)); for (Tweaks.Item item : Tweaks.ITEMS) { settings.add(boolSetting(item.key, item.section, item.defaultEnabled, item.implemented, true)); } diff --git a/app/src/main/java/com/ez/zalopatch/StatusActivity.java b/app/src/main/java/com/ez/zalopatch/StatusActivity.java index 2c6a0cf..e97e69a 100644 --- a/app/src/main/java/com/ez/zalopatch/StatusActivity.java +++ b/app/src/main/java/com/ez/zalopatch/StatusActivity.java @@ -40,7 +40,6 @@ public final class StatusActivity extends ZpSettingsActivity { 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; @@ -142,6 +141,8 @@ protected void onRestartResult(ZaloRestart.Result result) { .findFragmentById(R.id.zp_settings_content); if (fragment instanceof DashboardFragment) { ((DashboardFragment) fragment).refresh(); + } else if (fragment instanceof SectionActivity.SettingsFragment) { + ((SectionActivity.SettingsFragment) fragment).refreshDeveloperEnvironment(); } } @@ -160,6 +161,8 @@ protected void onRootAccessChanged(RootAccess.State state) { .findFragmentById(R.id.zp_settings_content); if (fragment instanceof DashboardFragment) { ((DashboardFragment) fragment).refresh(); + } else if (fragment instanceof SectionActivity.SettingsFragment) { + ((SectionActivity.SettingsFragment) fragment).refreshDeveloperEnvironment(); } } @@ -328,9 +331,6 @@ 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 @@ -369,30 +369,10 @@ private void addPrimaryActions(PreferenceScreen screen) { getString(R.string.zp_restart_title), null); restart.setKey(INTERNAL_RESTART); restart.setOnPreferenceClickListener(preference -> { - host().restartZalo(); + host().restartOrOpenZaloAppInfo(); 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) { @@ -435,6 +415,8 @@ private void addPrivacy(PreferenceScreen screen) { section.add(navigationRow(Tweaks.SECTION_TELEMETRY, getString(R.string.zp_telemetry_title), null)); addSection(section, Tweaks.SECTION_CALLS, getString(R.string.zp_calls_title), null); + addSection(section, Tweaks.SECTION_BACKUP, + getString(R.string.zp_backup_push_title), null); } private void addModule(PreferenceScreen screen) { @@ -489,23 +471,10 @@ private void refresh() { if (context == null || restart == null) return; RootAccess.State rootState = RootAccess.cached(context); - restart.setEnabled(restartAvailable && rootState == RootAccess.State.GRANTED); + restart.setEnabled(restartAvailable); restart.setSummary(rootState == RootAccess.State.GRANTED - ? null : getString(R.string.zp_restart_root_required_summary)); + ? null : getString(R.string.zp_restart_manual_fallback_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(); @@ -515,34 +484,8 @@ private void refresh() { private void setRestartEnabled(boolean enabled) { restartAvailable = enabled; if (restart != null) { - 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; + restart.setEnabled(enabled); } - return R.string.zp_capability_pending; } } } diff --git a/app/src/main/java/com/ez/zalopatch/TweakHookInfo.java b/app/src/main/java/com/ez/zalopatch/TweakHookInfo.java index c2f81cf..e5b3e73 100644 --- a/app/src/main/java/com/ez/zalopatch/TweakHookInfo.java +++ b/app/src/main/java/com/ez/zalopatch/TweakHookInfo.java @@ -119,6 +119,11 @@ static Info forKey(String key, SymbolSchema.Active schema) { case Tweaks.KEY_CALL_RECORDING_PROBE: path = "PeerJNI/CallCallback callbacks + ZmInCallActivity lifecycle"; break; + case Tweaks.KEY_BACKUP_FREQUENT_PUSH: + path = method(schema, "symbols.backup.interval_reader_class", + "", + "symbols.backup.interval_reader_method", ""); + break; case Tweaks.KEY_HIDE_MESSAGE_ADS: path = method(schema, "symbols.zinstant.ad_item_view_class", "com.zing.zalo.ui.widget.ZinstantAdItemView", @@ -256,6 +261,12 @@ private static List driftSymbols(String key, SymbolSchema.Active schema) return Arrays.asList( schema.string("symbols.chat.message_repository_class", ""), "#" + schema.string("symbols.chat.send_typing_method", "") + "()"); + case Tweaks.KEY_BACKUP_FREQUENT_PUSH: + return Arrays.asList( + schema.string("symbols.backup.interval_reader_class", + ""), + "#" + schema.string("symbols.backup.interval_reader_method", + "") + "()"); default: return Collections.emptyList(); } diff --git a/app/src/main/java/com/ez/zalopatch/Tweaks.java b/app/src/main/java/com/ez/zalopatch/Tweaks.java index 92d744a..1859c16 100644 --- a/app/src/main/java/com/ez/zalopatch/Tweaks.java +++ b/app/src/main/java/com/ez/zalopatch/Tweaks.java @@ -18,7 +18,7 @@ public enum Requirement { */ public static final String PREFS_NAME = "tweaks"; public static final String KEY_PREFS_SCHEMA_VERSION = "internal.prefs_schema_version"; - public static final int PREFS_SCHEMA_VERSION = 6; + public static final int PREFS_SCHEMA_VERSION = 7; // Stable identity keys. User-facing section names live in resources. public static final String SECTION_NAVIGATION = "navigation"; @@ -29,6 +29,7 @@ public enum Requirement { public static final String SECTION_CALLS = "calls"; public static final String SECTION_ADS = "ads"; public static final String SECTION_NOTIFICATIONS = "notifications"; + public static final String SECTION_BACKUP = "backup"; public static final String SECTION_DEVELOPER = "developer"; public static final String KEY_HIDE_DISCOVERY_TAB = "ui.hide_discovery_tab"; @@ -66,6 +67,8 @@ public enum Requirement { public static final String KEY_AUTO_RECORD_CALLS = "calls.auto_record"; public static final String KEY_CALL_RECORDING_NOTIFICATIONS = "calls.recording_notifications"; public static final String KEY_CALL_RECORDING_PROBE = "calls.recording_probe"; + public static final String KEY_BACKUP_FREQUENT_PUSH = "backup.frequent_push"; + public static final String KEY_BACKUP_PUSH_INTERVAL = "backup.push_interval"; public static final List TELEMETRY_KEYS = Collections.unmodifiableList(Arrays.asList( KEY_DISABLE_EVENT_ANALYTICS, @@ -148,6 +151,11 @@ public static List groups(String section) { new Group(R.string.zp_group_call_recording, KEY_AUTO_RECORD_CALLS, KEY_CALL_RECORDING_NOTIFICATIONS)); } + if (SECTION_BACKUP.equals(section)) { + return Collections.singletonList( + new Group(R.string.zp_group_backup_schedule, + KEY_BACKUP_FREQUENT_PUSH, KEY_BACKUP_PUSH_INTERVAL)); + } if (SECTION_DEVELOPER.equals(section)) { return Arrays.asList( new Group(R.string.zp_group_call_diagnostics, KEY_CALL_RECORDING_PROBE), @@ -268,6 +276,9 @@ public Item(String section, String key, int titleRes, int summaryRes, new Item(SECTION_CALLS, KEY_CALL_RECORDING_NOTIFICATIONS, R.string.zp_tweak_recording_notifications, R.string.zp_tweak_recording_notifications_summary, true, false), + new Item(SECTION_BACKUP, KEY_BACKUP_FREQUENT_PUSH, + R.string.zp_tweak_backup_frequent, R.string.zp_tweak_backup_frequent_summary, + true, false), new Item(SECTION_DEVELOPER, KEY_CALL_RECORDING_PROBE, R.string.zp_tweak_call_recording_probe, R.string.zp_tweak_call_recording_probe_summary, true, false), diff --git a/app/src/main/java/com/ez/zalopatch/ZpSettingsActivity.java b/app/src/main/java/com/ez/zalopatch/ZpSettingsActivity.java index aef14b7..5a10c0c 100644 --- a/app/src/main/java/com/ez/zalopatch/ZpSettingsActivity.java +++ b/app/src/main/java/com/ez/zalopatch/ZpSettingsActivity.java @@ -38,7 +38,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { applyBar = findViewById(R.id.zp_apply_bar); applyMessage = findViewById(R.id.zp_apply_message); applyButton = findViewById(R.id.zp_apply_button); - applyButton.setOnClickListener(view -> restartZalo()); + applyButton.setOnClickListener(view -> restartOrOpenZaloAppInfo()); settingsContent = findViewById(R.id.zp_settings_content); FrameLayout settingsStack = findViewById(R.id.zp_settings_stack); restartBlocker = new View(this); @@ -87,11 +87,9 @@ final void refreshApplyBar(boolean animate) { return; } int count = SettingsChanges.pendingCount(this); - 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); + applyMessage.setText(getResources().getQuantityString( + R.plurals.zp_pending_changes, count, count)); + applyButton.setEnabled(!restartInFlight); boolean show = count > 0; if (show == (applyBar.getVisibility() == View.VISIBLE)) { return; @@ -132,6 +130,14 @@ protected final void restartZalo() { } } + protected final void restartOrOpenZaloAppInfo() { + if (RootAccess.cached(this) == RootAccess.State.GRANTED) { + restartZalo(); + } else { + openZaloAppInfo(); + } + } + private void finishRestart(ZaloRestart.Result result) { restartInFlight = false; setRestartBlockerVisible(false); 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 4809792..31184fe 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 @@ -9,6 +9,7 @@ import com.ez.zalopatch.Tweaks; import com.ez.zalopatch.ZaloArtifactState; import com.ez.zalopatch.xposed.features.BottomTabsFeature; +import com.ez.zalopatch.xposed.features.BackupPushFeature; import com.ez.zalopatch.xposed.features.CallRecordingProbeFeature; import com.ez.zalopatch.xposed.features.CallRecordingFeature; import com.ez.zalopatch.xposed.features.ChatFeature; @@ -90,6 +91,11 @@ public static void start( preflight.zinstantFeed, preflight.reason(preflight.zinstantFeedErrors))); features.add(new ChatFeature(classLoader)); addStatusPrivacy(features, classLoader, preflight); + if (mainProcess) { + features.add(new BackupPushFeature(classLoader, + preflight.backupScheduled, + preflight.reason(preflight.backupScheduledErrors))); + } features.add(new CallRecordingFeature(classLoader)); features.add(new CallRecordingProbeFeature(classLoader)); } else { diff --git a/app/src/main/java/com/ez/zalopatch/xposed/core/SymbolPreflight.java b/app/src/main/java/com/ez/zalopatch/xposed/core/SymbolPreflight.java index 337349b..e7f9492 100644 --- a/app/src/main/java/com/ez/zalopatch/xposed/core/SymbolPreflight.java +++ b/app/src/main/java/com/ez/zalopatch/xposed/core/SymbolPreflight.java @@ -7,6 +7,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.List; @@ -27,6 +28,8 @@ static Result inspect(SymbolSchema.Active schema, ClassLoader classLoader) { schema, classLoader, result.zinstantFeedErrors); result.statusPrivacy = checkStatusPrivacy( schema, classLoader, result.statusPrivacyErrors); + result.backupScheduled = checkBackupScheduled( + schema, classLoader, result.backupScheduledErrors); return result; } @@ -188,6 +191,25 @@ private static boolean checkStatusPrivacy(SymbolSchema.Active schema, ClassLoade return errors.isEmpty(); } + private static boolean checkBackupScheduled(SymbolSchema.Active schema, ClassLoader loader, + List errors) { + Class owner = load(schema.string("symbols.backup.interval_reader_class", ""), + loader, errors); + if (owner != null) { + String name = schema.string("symbols.backup.interval_reader_method", ""); + try { + Method method = owner.getDeclaredMethod(name, + Long.TYPE, Boolean.TYPE, String.class); + if (method.getReturnType() != Long.TYPE || !Modifier.isStatic(method.getModifiers())) { + errors.add(owner.getName() + "#" + name + " shape changed"); + } + } catch (Throwable throwable) { + errors.add(owner.getName() + "#" + name + " signature changed"); + } + } + return errors.isEmpty(); + } + private static Class load(String name, ClassLoader loader, List errors) { if (name.isEmpty()) { errors.add("class name missing"); @@ -273,6 +295,7 @@ static final class Result { boolean zinstantMessage; boolean zinstantFeed; boolean statusPrivacy; + boolean backupScheduled; final List inboxMediaErrors = new ArrayList<>(); final List inboxCategoryErrors = new ArrayList<>(); final List meErrors = new ArrayList<>(); @@ -280,6 +303,7 @@ static final class Result { final List zinstantMessageErrors = new ArrayList<>(); final List zinstantFeedErrors = new ArrayList<>(); final List statusPrivacyErrors = new ArrayList<>(); + final List backupScheduledErrors = new ArrayList<>(); String reason(List errors) { return errors.isEmpty() ? "structural preflight failed" : String.join("; ", errors); @@ -295,11 +319,12 @@ int resolved() { if (zinstantMessage) count++; if (zinstantFeed) count++; if (statusPrivacy) count++; + if (backupScheduled) count++; return count; } int total() { - return 7; + return 8; } /** Per-family outcome, for a probe row that has to be read without the source at hand. */ @@ -312,6 +337,7 @@ String breakdown() { append(value, "zinstant_message", zinstantMessage); append(value, "zinstant_feed", zinstantFeed); append(value, "status_privacy", statusPrivacy); + append(value, "backup_scheduled", backupScheduled); return value.toString(); } diff --git a/app/src/main/java/com/ez/zalopatch/xposed/features/BackupPushDecision.java b/app/src/main/java/com/ez/zalopatch/xposed/features/BackupPushDecision.java new file mode 100644 index 0000000..bb7333f --- /dev/null +++ b/app/src/main/java/com/ez/zalopatch/xposed/features/BackupPushDecision.java @@ -0,0 +1,21 @@ +package com.ez.zalopatch.xposed.features; + +/** Dependency-free decisions for the scheduled interval and explicit manual trigger. */ +public final class BackupPushDecision { + private static final long HOUR_MS = 60L * 60L * 1000L; + private static final int DEFAULT_INTERVAL_HOURS = 6; + + private BackupPushDecision() { + } + + public static long scheduledIntervalMillis(boolean enabled, int intervalHours, + long nativeIntervalMillis) { + if (!enabled) { + return nativeIntervalMillis; + } + int hours = intervalHours == 1 || intervalHours == 3 + || intervalHours == 6 || intervalHours == 12 + ? intervalHours : DEFAULT_INTERVAL_HOURS; + return hours * HOUR_MS; + } +} diff --git a/app/src/main/java/com/ez/zalopatch/xposed/features/BackupPushFeature.java b/app/src/main/java/com/ez/zalopatch/xposed/features/BackupPushFeature.java new file mode 100644 index 0000000..664b5a5 --- /dev/null +++ b/app/src/main/java/com/ez/zalopatch/xposed/features/BackupPushFeature.java @@ -0,0 +1,110 @@ +package com.ez.zalopatch.xposed.features; + +import android.content.Context; + +import com.ez.zalopatch.HookConfig; +import com.ez.zalopatch.SymbolSchema; +import com.ez.zalopatch.Tweaks; +import com.ez.zalopatch.xposed.core.Feature; +import com.ez.zalopatch.xposed.core.SelfCheckRegistry; + +import java.lang.reflect.Method; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; + +/** Shortens only Zalo's native scheduled message-backup interval. */ +public final class BackupPushFeature extends Feature { + private static final String FEATURE = "backup.scheduled"; + private static final String INTERVAL_KEY_PREFIX = "SERVER_CONFIG_SYNC_MESSAGE_INTERVAL_"; + + private final boolean preflight; + private final String preflightError; + + public BackupPushFeature(ClassLoader classLoader, boolean preflight, String preflightError) { + super(classLoader); + this.preflight = preflight; + this.preflightError = preflightError; + } + + @Override + public String getFeatureName() { + return "BackupPush"; + } + + @Override + public void doHook() { + boolean enabled = HookConfig.isEnabled(Tweaks.KEY_BACKUP_FREQUENT_PUSH); + if (!preflight) { + if (enabled) { + SelfCheckRegistry.markStale(FEATURE, "structural preflight", preflightError); + } else { + SelfCheckRegistry.markDisabled(FEATURE, + "scheduled backup interval override"); + } + return; + } + SymbolSchema.Active schema = SymbolSchema.activeForHooks( + HookConfig.resolveModuleContextForHooks()); + String ownerName = schema.string("symbols.backup.interval_reader_class", ""); + String methodName = schema.string("symbols.backup.interval_reader_method", ""); + String target = "source=" + schema.source + " " + ownerName + "#" + methodName; + try { + Class owner = XposedHelpers.findClass(ownerName, classLoader); + Method method = owner.getDeclaredMethod(methodName, + long.class, boolean.class, String.class); + method.setAccessible(true); + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!HookConfig.isEnabled(Tweaks.KEY_BACKUP_FREQUENT_PUSH) + || param.hasThrowable() + || param.args.length < 3 + || !(param.args[2] instanceof String) + || !(param.getResult() instanceof Number)) { + return; + } + String key = (String) param.args[2]; + if (!key.startsWith(INTERVAL_KEY_PREFIX) + || key.length() == INTERVAL_KEY_PREFIX.length()) { + return; + } + long nativeInterval = ((Number) param.getResult()).longValue(); + int hours = HookConfig.getLevel(Tweaks.KEY_BACKUP_PUSH_INTERVAL); + param.setResult(BackupPushDecision.scheduledIntervalMillis( + true, hours, nativeInterval)); + SelfCheckRegistry.incrementHit(FEATURE, + ownerName + "#" + methodName, + "effective interval " + hours + "h"); + } + }); + markInstalled(enabled, target); + } catch (Throwable throwable) { + SelfCheckRegistry.markStale(FEATURE, target, + throwable.getClass().getSimpleName() + ": " + throwable.getMessage()); + } + } + + private static void markInstalled(boolean enabled, String target) { + if (!enabled) { + SelfCheckRegistry.markDisabled(FEATURE, target); + return; + } + Context context = HookConfig.resolveFallbackContextForHooks(); + if (context != null) { + ZaloPrefsReader.AutoBackup state = ZaloPrefsReader.autoBackup(context); + if (state == ZaloPrefsReader.AutoBackup.DISABLED) { + SelfCheckRegistry.markStatus(FEATURE, "disabled", target, + "Enable Zalo automatic backup to use the scheduled interval", ""); + return; + } + if (state == ZaloPrefsReader.AutoBackup.AMBIGUOUS) { + SelfCheckRegistry.markStatus(FEATURE, "installed_no_hits", target, + "Multiple account preference rows; current account not inferred", ""); + return; + } + } + SelfCheckRegistry.markInstalled(FEATURE, target, 1); + } +} diff --git a/app/src/main/java/com/ez/zalopatch/xposed/features/ZaloPrefsReader.java b/app/src/main/java/com/ez/zalopatch/xposed/features/ZaloPrefsReader.java new file mode 100644 index 0000000..9c5c4b5 --- /dev/null +++ b/app/src/main/java/com/ez/zalopatch/xposed/features/ZaloPrefsReader.java @@ -0,0 +1,62 @@ +package com.ez.zalopatch.xposed.features; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Narrow, ambiguity-safe reads from Zalo's own preference provider. */ +final class ZaloPrefsReader { + private static final Uri ROOT = + Uri.parse("content://com.zing.zalo.db.preferencesprovider"); + private static final String AUTO_BACKUP_PREFIX = "config_auto_backup_v3_"; + + enum AutoBackup { + ENABLED, + DISABLED, + UNKNOWN, + AMBIGUOUS + } + + private ZaloPrefsReader() { + } + + static AutoBackup autoBackup(Context context) { + Map values = new LinkedHashMap<>(); + try (Cursor cursor = context.getContentResolver().query(ROOT, + new String[]{"key", "value"}, "key LIKE ?", + new String[]{AUTO_BACKUP_PREFIX + "%"}, null)) { + if (cursor == null) { + return AutoBackup.UNKNOWN; + } + int keyIndex = cursor.getColumnIndex("key"); + int valueIndex = cursor.getColumnIndex("value"); + while (keyIndex >= 0 && valueIndex >= 0 && cursor.moveToNext()) { + String key = cursor.getString(keyIndex); + if (key == null || !key.startsWith(AUTO_BACKUP_PREFIX)) { + continue; + } + String uid = key.substring(AUTO_BACKUP_PREFIX.length()); + if (!uid.isEmpty()) { + values.put(uid, cursor.getString(valueIndex)); + } + } + } catch (Throwable ignored) { + return AutoBackup.UNKNOWN; + } + if (values.isEmpty()) { + return AutoBackup.UNKNOWN; + } + if (values.size() != 1) { + return AutoBackup.AMBIGUOUS; + } + try { + return Integer.parseInt(values.values().iterator().next()) > 0 + ? AutoBackup.ENABLED : AutoBackup.DISABLED; + } catch (NumberFormatException ignored) { + return AutoBackup.UNKNOWN; + } + } +} diff --git a/app/src/main/res/values-vi/arrays.xml b/app/src/main/res/values-vi/arrays.xml index cd55667..e1ce0f9 100644 --- a/app/src/main/res/values-vi/arrays.xml +++ b/app/src/main/res/values-vi/arrays.xml @@ -8,4 +8,10 @@ 5000 thông báo Không giới hạn + + Mỗi giờ + Mỗi 3 giờ + Mỗi 6 giờ + Mỗi 12 giờ + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index c1c5ed6..26d2c14 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -12,6 +12,7 @@ Khởi động lại Zalo Đã gửi lệnh khởi động lại Quyền root bị từ chối + Khi không có root, mở thông tin ứng dụng Zalo để buộc dừng thủ công. 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. @@ -38,6 +39,7 @@ Cá nhân Quảng cáo và khuyến mãi Cuộc gọi + Sao lưu tin nhắn Ngôn ngữ English Tiếng Việt @@ -74,6 +76,9 @@ Lưu bản ghi âm của cuộc gọi Zalo đã kết thúc thành file M4A. Thông báo ghi âm Hiển thị trạng thái ghi và lưu âm thanh. + Sao lưu định kỳ thường xuyên + Rút ngắn chu kỳ sao lưu tin nhắn của Zalo. Phải bật sao lưu tự động trong Zalo. Mỗi lần chạy xuất toàn bộ cơ sở dữ liệu tin nhắn và hiển thị thông báo bình thường của Zalo; media trên Drive vẫn theo chu kỳ gốc. + Chu kỳ định kỳ Ẩn quảng cáo trong Tin nhắn Ẩn quảng cáo Zinstant trong Tin nhắn. Ẩn quảng cáo trong Nhật ký @@ -94,6 +99,7 @@ Cá nhân Quyền riêng tư trạng thái Ghi âm cuộc gọi + Sao lưu tin nhắn định kỳ Sao lưu cài đặt Chỉ lọc thông báo Android, không ẩn hoặc xóa tin nhắn trong Zalo. diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 31a424b..71fc978 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -41,4 +41,16 @@ vi en + + Every hour + Every 3 hours + Every 6 hours + Every 12 hours + + + 1 + 3 + 6 + 12 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9cc9fcb..d4fd195 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,7 +7,7 @@ Restart command sent Root access denied Restart command failed - Root required; force stop Zalo to apply. + Without root, opens Zalo app info for manual force stop. Root access Granted · tap to check again Denied · tap to check again @@ -49,6 +49,7 @@ Me screen Ads and promotions Calls + Message backup Developer tools Runtime status, version maps, diagnostics, and compatibility remap. Telemetry @@ -118,6 +119,9 @@ Saves completed Zalo calls as M4A files. Recording notifications Shows recording and save status. + Frequent scheduled backup + Shortens Zalo’s message-backup interval. Zalo automatic backup must be enabled. Each run exports the full message database and shows Zalo’s normal notification; Drive media keeps its native schedule. + Scheduled interval Call recording probe Collects call state only. No audio is stored. Hide message ads @@ -144,6 +148,7 @@ Message rendering Status privacy Call recording + Scheduled message backup Call diagnostics Tools diff --git a/app/src/test/java/com/ez/zalopatch/SettingsModelTest.java b/app/src/test/java/com/ez/zalopatch/SettingsModelTest.java index 5735d17..901fe84 100644 --- a/app/src/test/java/com/ez/zalopatch/SettingsModelTest.java +++ b/app/src/test/java/com/ez/zalopatch/SettingsModelTest.java @@ -97,6 +97,7 @@ public void everyVisibleToggleHasExactlyOneOwner() { Tweaks.SECTION_NOTIFICATIONS, Tweaks.SECTION_TELEMETRY, Tweaks.SECTION_CALLS, + Tweaks.SECTION_BACKUP, Tweaks.SECTION_DEVELOPER); for (String section : sections) { for (Tweaks.Group group : Tweaks.groups(section)) { @@ -110,6 +111,7 @@ public void everyVisibleToggleHasExactlyOneOwner() { expected.add(item.key); } assertTrue(rendered.remove(Tweaks.KEY_DEFAULT_INBOX_FILTER)); + assertTrue(rendered.remove(Tweaks.KEY_BACKUP_PUSH_INTERVAL)); assertEquals(expected, rendered); } @@ -119,6 +121,7 @@ public void sectionIdentityIsNotDisplayCopy() { Tweaks.SECTION_NAVIGATION, Tweaks.SECTION_INBOX, Tweaks.SECTION_CHAT, Tweaks.SECTION_ME, Tweaks.SECTION_ADS, Tweaks.SECTION_NOTIFICATIONS, Tweaks.SECTION_TELEMETRY, Tweaks.SECTION_CALLS, + Tweaks.SECTION_BACKUP, Tweaks.SECTION_DEVELOPER)) { assertEquals(section, section.toLowerCase(java.util.Locale.US)); assertFalse(section.contains(" ")); diff --git a/app/src/test/java/com/ez/zalopatch/xposed/features/BackupPushDecisionTest.java b/app/src/test/java/com/ez/zalopatch/xposed/features/BackupPushDecisionTest.java new file mode 100644 index 0000000..2bae1ac --- /dev/null +++ b/app/src/test/java/com/ez/zalopatch/xposed/features/BackupPushDecisionTest.java @@ -0,0 +1,33 @@ +package com.ez.zalopatch.xposed.features; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public final class BackupPushDecisionTest { + @Test + public void disabledKeepsNativeInterval() { + assertEquals(86_400_000L, + BackupPushDecision.scheduledIntervalMillis(false, 1, 86_400_000L)); + } + + @Test + public void allowedIntervalsConvertToMilliseconds() { + assertEquals(3_600_000L, + BackupPushDecision.scheduledIntervalMillis(true, 1, 86_400_000L)); + assertEquals(10_800_000L, + BackupPushDecision.scheduledIntervalMillis(true, 3, 86_400_000L)); + assertEquals(21_600_000L, + BackupPushDecision.scheduledIntervalMillis(true, 6, 86_400_000L)); + assertEquals(43_200_000L, + BackupPushDecision.scheduledIntervalMillis(true, 12, 86_400_000L)); + } + + @Test + public void invalidIntervalUsesSixHourDefault() { + assertEquals(21_600_000L, + BackupPushDecision.scheduledIntervalMillis(true, 0, 86_400_000L)); + assertEquals(21_600_000L, + BackupPushDecision.scheduledIntervalMillis(true, 24, 86_400_000L)); + } +}