diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/Links.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/Links.java index f4ee22ed9..c69902a9d 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/Links.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/Links.java @@ -20,6 +20,7 @@ import app.morphe.extension.instagram.entity.MediaData; import app.morphe.extension.instagram.settings.SettingsStatus; import app.morphe.extension.instagram.utils.Pref; +import android.util.Log; import app.morphe.extension.shared.Logger; import app.morphe.extension.shared.ShareLinkSanitizer; import app.morphe.extension.shared.Utils; @@ -28,6 +29,7 @@ import app.morphe.extension.instagram.patches.story.StorySeenRequestScope; import app.morphe.extension.crimera.PikoUtils; +import app.morphe.extension.instagram.patches.focusLock.FocusLock; import app.morphe.extension.instagram.settings.ActivityHook; @SuppressWarnings("unused") @@ -139,7 +141,9 @@ public static void interceptUri(URI uri) throws IOException{ || path.contains("/discover/topical_explore_stream") || (host.contains("i.instagram.com") && path.contains("/fbsearch/recent_searches/")) || (host.contains("i.instagram.com") && path.contains("/fbsearch/top_serp/"))) { - shouldBlockUri = DISABLE_EXPLORE; + // Focus Lock is read per request, not from the cached field: a lock that + // expires while the process is alive must release explore without a restart. + shouldBlockUri = DISABLE_EXPLORE || FocusLock.blocksExplore(); } else if (path.contains("/api/v1/media/") && path.contains("comments/")) { shouldBlockUri = DISABLE_COMMENTS; } else if (path.contains("/discover/ayml") || path.contains("/discover/chaining")) { // Thanks to @brosssh @@ -159,6 +163,9 @@ public static void interceptUri(URI uri) throws IOException{ Logger.printException(() -> "intercept URI failed: ", ex); } // Exception is hanndled at call. + if (shouldBlockUri && Pref.pikoDebug()) { + Log.d("piko", "blocked uri: " + uri); + } if(shouldBlockUri) { throw new IOException("Block uri"); } diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/focusLock/FocusLock.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/focusLock/FocusLock.java new file mode 100644 index 000000000..89bf5e13a --- /dev/null +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/focusLock/FocusLock.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2026 piko + * + * See the included NOTICE file for GPLv3 §7(b) terms that apply to this code. + */ + +package app.morphe.extension.instagram.patches.focusLock; + +import static app.morphe.extension.instagram.utils.IgStr.str; + +import android.text.format.DateFormat; + +import java.util.Date; + +import app.morphe.extension.instagram.utils.Pref; + +/** + * Commitment mode: once locked, the selected distraction-free protections are forced on + * and cannot be turned off until the lock expires. Unlocking early requires a cooling-off + * period, so the decision to bring Reels back cannot be made on impulse. + */ +@SuppressWarnings("unused") +public class FocusLock { + public static final long COOLING_OFF_MS = 24L * 60 * 60 * 1000; + private static final long DAY_MS = 24L * 60 * 60 * 1000; + + private static long parseLong(String value) { + try { + return Long.parseLong(value); + } catch (Exception ignored) { + return 0L; + } + } + + public static long lockedUntil() { + return parseLong(Pref.focusLockUntil()); + } + + public static long unlockRequestedAt() { + return parseLong(Pref.focusLockUnlockRequestedAt()); + } + + // Intentionally does not consult SettingsStatus: the lock timestamp is only ever written by + // this class, and hooks like HideNavigationButtonsPatch may read preferences at class-load time. + public static boolean isLocked() { + return System.currentTimeMillis() < lockedUntil(); + } + + /** True while an unlock request is pending its cooling-off period. */ + public static boolean isUnlockPending() { + return isLocked() && unlockRequestedAt() > 0; + } + + public static long unlockAvailableAt() { + return unlockRequestedAt() + COOLING_OFF_MS; + } + + public static boolean canUnlockNow() { + return isUnlockPending() && System.currentTimeMillis() >= unlockAvailableAt(); + } + + // Enforcement helpers. These are OR-ed into the regular preference getters in Pref, + // so the underlying switches keep their stored value and simply cannot take effect. + public static boolean blocksReels() { + return isLocked() && Pref.focusLockBlockReels(); + } + + public static boolean blocksExplore() { + return isLocked() && Pref.focusLockBlockExplore(); + } + + public static boolean lock() { + long days = parseLong(Pref.focusLockDurationDays()); + if (days <= 0) days = 7; + long until = System.currentTimeMillis() + days * DAY_MS; + return Pref.setFocusLockUntil(String.valueOf(until)) + && Pref.setFocusLockUnlockRequestedAt("0"); + } + + public static boolean requestUnlock() { + return Pref.setFocusLockUnlockRequestedAt(String.valueOf(System.currentTimeMillis())); + } + + public static boolean cancelUnlockRequest() { + return Pref.setFocusLockUnlockRequestedAt("0"); + } + + public static boolean unlock() { + if (!canUnlockNow()) return false; + return Pref.setFocusLockUntil("0") + && Pref.setFocusLockUnlockRequestedAt("0"); + } + + public static String formatDate(long millis) { + return DateFormat.format("yyyy-MM-dd HH:mm", new Date(millis)).toString(); + } + + /** Summary shown under the lock/unlock button. */ + public static String statusSummary() { + if (!isLocked()) { + return str("piko_focus_lock_status_unlocked"); + } + String until = formatDate(lockedUntil()); + if (canUnlockNow()) { + return str("piko_focus_lock_status_unlock_ready"); + } + if (isUnlockPending()) { + return String.format(str("piko_focus_lock_status_unlock_pending"), formatDate(unlockAvailableAt()), until); + } + return String.format(str("piko_focus_lock_status_locked"), until); + } + + public static String buttonTitle() { + if (!isLocked()) return str("piko_focus_lock_button_lock"); + if (canUnlockNow()) return str("piko_focus_lock_button_unlock"); + if (isUnlockPending()) return str("piko_focus_lock_button_cancel_unlock"); + return str("piko_focus_lock_button_request_unlock"); + } +} diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/focusLock/FocusLockDialogs.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/focusLock/FocusLockDialogs.java new file mode 100644 index 000000000..f0ee7b29e --- /dev/null +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/focusLock/FocusLockDialogs.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2026 piko + * + * See the included NOTICE file for GPLv3 §7(b) terms that apply to this code. + */ + +package app.morphe.extension.instagram.patches.focusLock; + +import static app.morphe.extension.instagram.utils.IgStr.str; + +import android.app.AlertDialog; +import android.content.Context; +import android.preference.Preference; + +import app.morphe.extension.crimera.PikoUtils; +import app.morphe.extension.instagram.utils.Pref; +import app.morphe.extension.instagram.settings.preference.widgets.InstagramPreferenceStyle; +import app.morphe.extension.shared.Utils; + +/** Confirmation flows behind the Focus Lock button. */ +public class FocusLockDialogs { + + public static void onActionPressed(Context context, Preference button) { + if (!FocusLock.isLocked()) { + confirmLock(context); + } else if (FocusLock.canUnlockNow()) { + confirmUnlock(context); + } else if (FocusLock.isUnlockPending()) { + FocusLock.cancelUnlockRequest(); + refresh(button); + } else { + confirmRequestUnlock(context, button); + } + } + + private static void refresh(Preference button) { + button.setTitle(FocusLock.buttonTitle()); + button.setSummary(FocusLock.statusSummary()); + } + + private static void confirmLock(Context context) { + boolean reels = Pref.focusLockBlockReels(); + boolean explore = Pref.focusLockBlockExplore(); + if (!reels && !explore) { + PikoUtils.toast(str("piko_focus_lock_nothing_selected")); + return; + } + String days = Pref.focusLockDurationDays(); + String message = String.format( + str("piko_focus_lock_confirm_lock_desc"), + days, + FocusLock.COOLING_OFF_MS / (60 * 60 * 1000) + ); + new AlertDialog.Builder(InstagramPreferenceStyle.dialogContext(context)) + .setTitle(str("piko_focus_lock_confirm_lock")) + .setMessage(message) + .setNegativeButton(str("piko_cancel"), null) + .setPositiveButton(str("piko_ok"), (d, w) -> { + if (FocusLock.lock()) { + PikoUtils.toast(str("piko_focus_lock_locked_toast")); + // Some hooks read preferences once at class-load time, so restart. + Utils.restartApp(Utils.getContext()); + } + }) + .show(); + } + + private static void confirmRequestUnlock(Context context, Preference button) { + String message = String.format( + str("piko_focus_lock_confirm_request_unlock_desc"), + FocusLock.COOLING_OFF_MS / (60 * 60 * 1000) + ); + new AlertDialog.Builder(InstagramPreferenceStyle.dialogContext(context)) + .setTitle(str("piko_focus_lock_confirm_request_unlock")) + .setMessage(message) + .setNegativeButton(str("piko_cancel"), null) + .setPositiveButton(str("piko_ok"), (d, w) -> { + FocusLock.requestUnlock(); + refresh(button); + }) + .show(); + } + + private static void confirmUnlock(Context context) { + new AlertDialog.Builder(InstagramPreferenceStyle.dialogContext(context)) + .setTitle(str("piko_focus_lock_confirm_unlock")) + .setMessage(str("piko_focus_lock_confirm_unlock_desc")) + .setNegativeButton(str("piko_cancel"), null) + .setPositiveButton(str("piko_ok"), (d, w) -> { + if (FocusLock.unlock()) { + Utils.restartApp(Utils.getContext()); + } + }) + .show(); + } +} diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/navigation/NavigationBarPatch.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/navigation/NavigationBarPatch.java index 11e5aa71e..dffed85c4 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/navigation/NavigationBarPatch.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/patches/navigation/NavigationBarPatch.java @@ -8,6 +8,7 @@ import android.content.Intent; +import app.morphe.extension.instagram.patches.focusLock.FocusLock; import app.morphe.extension.instagram.settings.SettingsRestart; import app.morphe.extension.instagram.utils.Pref; @@ -205,6 +206,20 @@ private static Config loadConfig(Boolean nativeCreateVisible) { return config; } + /** + * Focus Lock hides the Reels tab regardless of the stored config. + * + * Applied only where tabs are rendered, never inside {@link #loadConfig()}: the navigation + * editor loads the config too, so filtering there would let pressing OK save the filtered + * layout permanently. The user's own layout stays stored and returns once the lock ends. + */ + private static Config applyFocusLock(Config config) { + if (!FocusLock.blocksReels() || !config.visible().contains(Tab.REELS)) return config; + EnumSet visible = EnumSet.copyOf(config.visible()); + visible.remove(Tab.REELS); + return normalize(config.order(), visible, config.startup()); + } + public static boolean saveConfig(List order, Set visible, Tab startup) { Config normalized = normalize(order, visible, startup); String previous = Pref.navigationTabs(); @@ -245,7 +260,8 @@ public static List transformNavigationTabs( List candidates = classify(allCandidates); List output = new ArrayList<>(); int knownCapacity = Math.max(0, MAX_TABS - unknown.size()); - Config config = loadConfig(find(current, Tab.CREATE, new IdentityHashMap<>()) != null); + Config config = applyFocusLock( + loadConfig(find(current, Tab.CREATE, new IdentityHashMap<>()) != null)); for (Tab tab : config.order()) { if (output.size() == knownCapacity) break; if (!config.visible().contains(tab)) continue; @@ -295,7 +311,7 @@ public static void applyStartupTab( try { if (intent.hasExtra(STARTUP_TAB_EXTRA)) return; List candidates = classify(allCandidates); - Config config = loadConfig(); + Config config = applyFocusLock(loadConfig()); Tab[] choices = startupChoices(config); for (Tab tab : choices) { Candidate candidate = find(candidates, tab, new IdentityHashMap<>()); diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/Settings.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/Settings.java index 6ffb68da6..699f6d3ca 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/Settings.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/Settings.java @@ -59,6 +59,13 @@ public class Settings { public static final BooleanSetting HIDE_GROUP_CREATION_BUTTON_ON_SHARESHEET = new BooleanSetting("hide_group_creation_button_on_sharesheet", true); public static final BooleanSetting DISABLE_REELS_SCROLLING = new BooleanSetting("disable_reels_scrolling", false); public static final BooleanSetting DISABLE_SWIPE_TO_CREATE = new BooleanSetting("disable_swipe_to_create", false); + public static final BooleanSetting FOCUS_LOCK_BLOCK_REELS = new BooleanSetting("focus_lock_block_reels", true); + public static final BooleanSetting FOCUS_LOCK_BLOCK_EXPLORE = new BooleanSetting("focus_lock_block_explore", false); + public static final StringSetting FOCUS_LOCK_DURATION_DAYS = new StringSetting("focus_lock_duration_days", "7"); + // Epoch millis. "0" means not locked. + public static final StringSetting FOCUS_LOCK_UNTIL = new StringSetting("focus_lock_until", "0"); + // Epoch millis of the pending unlock request. "0" means none. + public static final StringSetting FOCUS_LOCK_UNLOCK_REQUESTED_AT = new StringSetting("focus_lock_unlock_requested_at", "0"); public static final BooleanSetting REMOVE_EMPTY_BOTTOM_SPACE = new BooleanSetting("remove_empty_bottom_space", true); public static final BooleanSetting DISABLE_TYPING_STATUS = new BooleanSetting("disable_typing_status", false); public static final BooleanSetting HIDE_NOTES_TRAY = new BooleanSetting("hide_notes_tray", false); diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/SettingsStatus.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/SettingsStatus.java index b7d0fec37..05691a395 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/SettingsStatus.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/SettingsStatus.java @@ -131,6 +131,10 @@ public static void disableReelsScrolling() { public static void disableSwipeToCreate() { disableSwipeToCreate = true; } + public static boolean focusLock = false; + public static void focusLock() { + focusLock = true; + } public static boolean disableDoubleTapLike = false; public static void disableDoubleTapLike() { disableDoubleTapLike = true; @@ -140,7 +144,7 @@ public static void hideReelsFollowButton() { hideReelsFollowButton = true; } public static boolean distractionFreeSection() { - return (hideReelsFollowButton || disableDoubleTapLike || hideNotesTray || disableHighlights || disableStories || disableExplore || disableComments || hideStoriesTray || limitFollowingFeed || hideGroupCreationOnSharesheet || disableReelsScrolling || disableSwipeToCreate); + return (hideReelsFollowButton || disableDoubleTapLike || hideNotesTray || disableHighlights || disableStories || disableExplore || disableComments || hideStoriesTray || limitFollowingFeed || hideGroupCreationOnSharesheet || disableReelsScrolling || disableSwipeToCreate || focusLock); } //Misc section. @@ -269,6 +273,7 @@ public static void loadStatusMap(){ FLAGS.put(str("piko_disable_highlights"),SettingsStatus.disableHighlights); FLAGS.put(str("piko_disable_stories"),SettingsStatus.disableStories); FLAGS.put(str("piko_disable_swipe_to_create"), SettingsStatus.disableSwipeToCreate); + FLAGS.put(str("piko_focus_lock"), SettingsStatus.focusLock); FLAGS.put(str("piko_view_dm_anonymously"),SettingsStatus.viewDmAnonymously); FLAGS.put(str("piko_save_deleted_messages"),SettingsStatus.saveDeletedMessages); diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/Helper.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/Helper.java index 630635a1c..21aad861b 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/Helper.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/Helper.java @@ -52,6 +52,34 @@ public Preference switchPreference(String title, String summary, BooleanSetting return preference; } + /** + * A switch whose value is currently forced on by another feature. + * + * When {@code forced} the switch is shown checked and non-interactive with + * {@code forcedSummary}, without writing to the stored value, so the user's own choice + * returns once the feature releases it. + * Not specific to any patch: any feature that overrides a setting can use it. + */ + public Preference forcedSwitchPreference( + String title, + String summary, + BooleanSetting setting, + boolean forced, + String forcedSummary + ) { + Preference preference = switchPreference(title, forced ? forcedSummary : summary, setting); + if (forced && preference instanceof SwitchPref) { + SwitchPref switchPreference = (SwitchPref) preference; + switchPreference.setPersistent(false); + // Attaching the preference initializes it from the default value, which would undo + // setChecked(true), so the default has to be true as well while it is forced. + switchPreference.setDefaultValue(Boolean.TRUE); + switchPreference.setChecked(true); + switchPreference.setSwitchInteractionEnabled(false); + } + return preference; + } + public Preference listPreference(String title, String summary, StringSetting setting) { ListPref preference = new ListPref(context); String key = setting.key; diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/ScreenBuilder.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/ScreenBuilder.java index 1ff707a10..e1a26bce9 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/ScreenBuilder.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/ScreenBuilder.java @@ -26,6 +26,7 @@ import app.morphe.extension.crimera.downloader.StorageUtils; import app.morphe.extension.instagram.patches.Links; +import app.morphe.extension.instagram.patches.focusLock.FocusLock; import app.morphe.extension.instagram.settings.SettingsStatus; import app.morphe.extension.instagram.settings.Settings; import app.morphe.extension.instagram.settings.preference.widgets.*; @@ -337,6 +338,10 @@ public void distractionFreeSection() { // PreferenceCategory category= addCategory(str("piko_category_distraction_free")); + if (SettingsStatus.focusLock) { + buildFocusLockPreferences(); + } + if (SettingsStatus.disableStories) { addPreference( helper.switchPreference( @@ -375,10 +380,12 @@ public void distractionFreeSection() { } if (SettingsStatus.disableExplore) { addPreference( - helper.switchPreference( + helper.forcedSwitchPreference( str("piko_disable_explore"), "", - Settings.DISABLE_EXPLORE + Settings.DISABLE_EXPLORE, + FocusLock.blocksExplore(), + str("piko_focus_lock_enforced") ) ); } @@ -402,11 +409,13 @@ public void distractionFreeSection() { } if (SettingsStatus.disableReelsScrolling) { addPreference( - helper.switchPreference( + helper.forcedSwitchPreference( str("piko_disable_reels_scrolling"), str("piko_disable_reels_scrolling_desc"), - Settings.DISABLE_REELS_SCROLLING - ) + Settings.DISABLE_REELS_SCROLLING, + FocusLock.blocksReels(), + str("piko_focus_lock_enforced") + ) ); } if (SettingsStatus.disableSwipeToCreate) { @@ -472,6 +481,46 @@ public void distractionFreeSection() { } } + /** + * Focus Lock. The switches that the lock enforces are greyed out while it is active so the + * commitment cannot be undone from the settings screen. + */ + private void buildFocusLockPreferences() { + boolean locked = FocusLock.isLocked(); + + addPreference( + helper.buttonPreference( + FocusLock.buttonTitle(), + FocusLock.statusSummary(), + "piko_focus_lock_action" + ) + ); + + Preference blockReels = helper.switchPreference( + str("piko_focus_lock_block_reels"), + str("piko_focus_lock_block_reels_desc"), + Settings.FOCUS_LOCK_BLOCK_REELS + ); + Preference blockExplore = helper.switchPreference( + str("piko_focus_lock_block_explore"), + str("piko_focus_lock_block_explore_desc"), + Settings.FOCUS_LOCK_BLOCK_EXPLORE + ); + Preference duration = helper.listPreference( + str("piko_focus_lock_duration"), + str("piko_focus_lock_duration_desc"), + Settings.FOCUS_LOCK_DURATION_DAYS + ); + if (locked) { + ((SwitchPref) blockReels).setSwitchInteractionEnabled(false); + ((SwitchPref) blockExplore).setSwitchInteractionEnabled(false); + duration.setEnabled(false); + } + addPreference(blockReels); + addPreference(blockExplore); + addPreference(duration); + } + public void buildMiscSection() { if (!(SettingsStatus.miscSection())) return; diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/fragments/RestorePrefActivity.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/fragments/RestorePrefActivity.java index 7685545a9..3057e3006 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/fragments/RestorePrefActivity.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/fragments/RestorePrefActivity.java @@ -25,6 +25,7 @@ import app.morphe.extension.instagram.constants.Constants; import app.morphe.extension.shared.Logger; import app.morphe.extension.shared.Utils; +import app.morphe.extension.instagram.patches.focusLock.FocusLock; public class RestorePrefActivity extends AppCompatActivity { @@ -47,6 +48,12 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } else if (args.containsKey("piko_import_id_mapping")) { destinationFile = new File(context.getFilesDir()+ "/mobileconfig","id_name_mapping.json"); } else if (args.containsKey("piko_import_pref")) { + // Importing an older settings file would drop an active Focus Lock. + if (FocusLock.isLocked()) { + toast(str("piko_focus_lock_blocked_action")); + finish(); + return; + } destinationFile = new File(context.getApplicationInfo().dataDir + "/shared_prefs",Constants.PIKO_SETTINGS+".xml"); } if (destinationFile != null) { diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/widgets/ButtonPref.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/widgets/ButtonPref.java index 91921daad..ce7334db0 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/widgets/ButtonPref.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/widgets/ButtonPref.java @@ -26,6 +26,7 @@ import app.morphe.extension.instagram.constants.UI; import app.morphe.extension.instagram.constants.Constants; import app.morphe.extension.instagram.utils.InstaUtils; +import app.morphe.extension.instagram.patches.focusLock.FocusLockDialogs; import app.morphe.extension.instagram.patches.dm.SavedMessagesHook; public class ButtonPref extends Preference { @@ -70,6 +71,9 @@ public boolean onPreferenceClick(Preference preference) { } else if (key.equals("piko_reset_pref")) { InstaUtils.showResetSettingsDialog(context); + } else if (key.equals("piko_focus_lock_action")) { + FocusLockDialogs.onActionPressed(context, ButtonPref.this); + } else if (key.equals("piko_delete_analytics_cache")) { Block.deleteAnalyticsCacheFolder(); @@ -145,6 +149,7 @@ private static boolean hasVisibleTrail(String key) { || key.equals("piko_export_pref") || key.equals("piko_import_pref") || key.equals("piko_reset_pref") + || key.equals("piko_focus_lock_action") || key.equals("piko_download_set_path") || key.equals("piko_delete_analytics_cache") || key.equals("piko_export_experiment_list") diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/widgets/ListPref.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/widgets/ListPref.java index fef574cc8..b988f04ea 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/widgets/ListPref.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/settings/preference/widgets/ListPref.java @@ -63,6 +63,9 @@ protected void onSetInitialValue(boolean restoreValue, Object defaultValue) { } else if (key == Settings.CHANGE_LIKE_ANIMATION.key) { entries = ResourceUtils.getStringArray("piko_array_change_like_animation"); entriesValues = ResourceUtils.getStringArray("piko_array_change_like_animation_val"); + } else if (key == Settings.FOCUS_LOCK_DURATION_DAYS.key) { + entries = ResourceUtils.getStringArray("piko_array_focus_lock_duration"); + entriesValues = ResourceUtils.getStringArray("piko_array_focus_lock_duration_val"); } } else { entries = ResourceUtils.getStringArray("piko_array_recmd_flag_states"); diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/utils/InstaUtils.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/utils/InstaUtils.java index 47046a24f..33b0b034d 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/utils/InstaUtils.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/utils/InstaUtils.java @@ -24,6 +24,7 @@ import app.morphe.extension.instagram.entity.DeveloperOptions; import app.morphe.extension.instagram.settings.preference.widgets.InstagramPreferenceStyle; import app.morphe.extension.crimera.PikoUtils; +import app.morphe.extension.instagram.patches.focusLock.FocusLock; import app.morphe.extension.shared.Utils; import app.morphe.extension.shared.requests.Requester; @@ -77,6 +78,10 @@ public static void decompileExperiments(boolean asJson) { } public static void showResetSettingsDialog(Context context) { + if (FocusLock.isLocked()) { + PikoUtils.toast(str("piko_focus_lock_blocked_action")); + return; + } new AlertDialog.Builder(InstagramPreferenceStyle.dialogContext(context)) .setTitle(str("piko_reset_pref_confirm")) .setNegativeButton(str("piko_cancel"), null) diff --git a/extensions/instagram/src/main/java/app/morphe/extension/instagram/utils/Pref.java b/extensions/instagram/src/main/java/app/morphe/extension/instagram/utils/Pref.java index 4bc475bdb..ab9945236 100644 --- a/extensions/instagram/src/main/java/app/morphe/extension/instagram/utils/Pref.java +++ b/extensions/instagram/src/main/java/app/morphe/extension/instagram/utils/Pref.java @@ -16,6 +16,8 @@ import app.morphe.extension.instagram.settings.Settings; import app.morphe.extension.instagram.settings.SettingsStatus; import app.morphe.extension.instagram.constants.Constants; +import app.morphe.extension.instagram.patches.focusLock.FocusLock; + import app.morphe.extension.crimera.sharedPreference.SharedPref; import app.morphe.extension.shared.MarkChatAsReadScope; @@ -28,6 +30,8 @@ private static String removeLineBreaks(String value) { } public static boolean clearAllPreferences() { + // Resetting settings would silently drop an active Focus Lock. + if (FocusLock.isLocked()) return false; return SharedPref.clearAll(); } @@ -157,13 +161,41 @@ public static boolean hideNotesTray() { } public static boolean disableReelsScrolling() { - return SharedPref.getBooleanPref(Settings.DISABLE_REELS_SCROLLING) && SettingsStatus.disableReelsScrolling; + return (SharedPref.getBooleanPref(Settings.DISABLE_REELS_SCROLLING) || FocusLock.blocksReels()) && SettingsStatus.disableReelsScrolling; } public static boolean disableSwipeToCreate() { return SharedPref.getBooleanPref(Settings.DISABLE_SWIPE_TO_CREATE) && SettingsStatus.disableSwipeToCreate; } + public static boolean focusLockBlockReels() { + return SharedPref.getBooleanPref(Settings.FOCUS_LOCK_BLOCK_REELS); + } + + public static boolean focusLockBlockExplore() { + return SharedPref.getBooleanPref(Settings.FOCUS_LOCK_BLOCK_EXPLORE); + } + + public static String focusLockDurationDays() { + return SharedPref.getStringPref(Settings.FOCUS_LOCK_DURATION_DAYS); + } + + public static String focusLockUntil() { + return SharedPref.getStringPref(Settings.FOCUS_LOCK_UNTIL); + } + + public static boolean setFocusLockUntil(String value) { + return SharedPref.setStringPref(Settings.FOCUS_LOCK_UNTIL.key, value); + } + + public static String focusLockUnlockRequestedAt() { + return SharedPref.getStringPref(Settings.FOCUS_LOCK_UNLOCK_REQUESTED_AT); + } + + public static boolean setFocusLockUnlockRequestedAt(String value) { + return SharedPref.setStringPref(Settings.FOCUS_LOCK_UNLOCK_REQUESTED_AT.key, value); + } + public static boolean makeEphemeralMediaPermanent() { return SharedPref.getBooleanPref(Settings.UNLIMITED_REPLAYS) && SettingsStatus.unlimitedReplaysOnEphemeralMedia; } diff --git a/patches/src/main/kotlin/app/crimera/patches/instagram/misc/distractionFree/focusLock/FocusLockPatch.kt b/patches/src/main/kotlin/app/crimera/patches/instagram/misc/distractionFree/focusLock/FocusLockPatch.kt new file mode 100644 index 000000000..2dffc3fa3 --- /dev/null +++ b/patches/src/main/kotlin/app/crimera/patches/instagram/misc/distractionFree/focusLock/FocusLockPatch.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 piko + * + * See the included NOTICE file for GPLv3 §7(b) terms that apply to this code. + */ + +package app.crimera.patches.instagram.misc.distractionFree.focusLock + +import app.crimera.patches.instagram.links.distractionFree.disableExplorePatch +import app.crimera.patches.instagram.links.interceptUriPatch +import app.crimera.patches.instagram.misc.navigation.navigationBarPatch +import app.crimera.patches.instagram.misc.reels.disableReelsScrollingPatch +import app.crimera.patches.instagram.misc.settings.settingsPatch +import app.crimera.patches.instagram.utils.Constants.COMPATIBILITY_INSTAGRAM +import app.crimera.patches.instagram.utils.enableSettings +import app.morphe.patcher.patch.bytecodePatch + +@Suppress("unused") +val focusLockPatch = + bytecodePatch( + name = "Focus Lock", + description = "Commitment mode for cutting down on Instagram. Pick what to block (Reels, Explore) and a " + + "duration; once locked those protections are forced on and cannot be switched off. Reels shared with " + + "you still open. Unlocking early requires a 24 hour cooling-off period, and resetting or importing " + + "settings is blocked while locked.", + ) { + dependsOn( + settingsPatch, + interceptUriPatch, + navigationBarPatch, + disableReelsScrollingPatch, + disableExplorePatch, + ) + compatibleWith(COMPATIBILITY_INSTAGRAM) + + execute { + enableSettings("focusLock") + } + } diff --git a/patches/src/main/resources/addresources/values/instagram/arrays.xml b/patches/src/main/resources/addresources/values/instagram/arrays.xml index e1999babc..81aebfd48 100644 --- a/patches/src/main/resources/addresources/values/instagram/arrays.xml +++ b/patches/src/main/resources/addresources/values/instagram/arrays.xml @@ -15,6 +15,22 @@ timeleft + + 1 day + 3 days + 1 week + 2 weeks + 1 month + 3 months + + + 1 + 3 + 7 + 14 + 30 + 90 + @string/piko_array_default Anyway like activation diff --git a/patches/src/main/resources/addresources/values/instagram/strings.xml b/patches/src/main/resources/addresources/values/instagram/strings.xml index 82de8ca36..144d438fc 100644 --- a/patches/src/main/resources/addresources/values/instagram/strings.xml +++ b/patches/src/main/resources/addresources/values/instagram/strings.xml @@ -55,6 +55,31 @@ Disable comments Limit feed to following profiles Filters the home feed to display only content from profiles you follow. + Focus Lock + Focus Lock: block Reels + While locked, removes the Reels tab and disables Reels scrolling. Reels shared with you still open. + Focus Lock: block Explore + While locked, disables the Explore page. + Focus Lock duration + How long the lock stays active once enabled. + Forced on by Focus Lock. + Lock now + Request unlock + Cancel unlock request + Unlock + Not locked. Choose what to block and a duration, then lock. The selected switches cannot be turned off until the lock expires. + Locked until %1$s. Unlocking early requires a 24 hour cooling-off period. + Unlock available at %1$s (lock expires %2$s). Tap to cancel the request. + Cooling-off period is over. Tap to unlock. + Select at least one thing to block first. + Enable Focus Lock? + The selected protections will be forced on for %1$s day(s). To unlock early you must request an unlock and wait %2$d hours. Reset and import of settings are disabled while locked. The app will restart. + Request unlock? + The lock stays active for another %1$d hours. After that, come back here and confirm the unlock. + Unlock now? + This ends Focus Lock. The app will restart. + Focus Lock enabled. + Not available while Focus Lock is active. Disable Reels scrolling Disables the endless scrolling behavior in Instagram Reels, preventing swiping to the next Reel. Note: On a clean install, the \'Tip\' animation may appear but will stop on its own after a few seconds. Disable swipe to create