mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-05-26 17:07:47 +02:00
RELEASE: moved from beta to First stable release.
Check CHANGELOG.md for full changelog
This commit is contained in:
@@ -1,245 +1,21 @@
|
||||
// ============================================================================
|
||||
// FocusGram — InjectionController
|
||||
// ============================================================================
|
||||
//
|
||||
// Builds all JavaScript and CSS payloads injected into the Instagram WebView.
|
||||
//
|
||||
// ── Ghost Mode Design ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Instead of blocking exact URLs (brittle — Instagram renames paths constantly),
|
||||
// we block by SEMANTIC KEYWORD GROUPS. A request is silenced if its URL contains
|
||||
// ANY keyword from the relevant group.
|
||||
//
|
||||
// Ghost Mode Semantic Groups (last verified: 2025-02)
|
||||
// ────────────────────────────────────────────────────
|
||||
// seenKeywords — story/DM seen receipts (any endpoint Instagram uses to
|
||||
// tell others you read/watched something)
|
||||
// typingKeywords — typing indicator REST calls + WS text frames
|
||||
// liveKeywords — live viewer heartbeat / join_request (presence on streams)
|
||||
// photoKeywords — disappearing / view-once DM photo seen receipts
|
||||
//
|
||||
// Adding new endpoints in the future: just append a keyword to the right group
|
||||
// in _ghostGroups below — no other code needs to change.
|
||||
//
|
||||
// ── Confirmed endpoint map ───────────────────────────────────────────────────
|
||||
// /api/v1/media/seen/ — story seen v1 (covered by "media/seen")
|
||||
// /api/v2/media/seen/ — story seen v2 (covered by "media/seen")
|
||||
// /stories/reel/seen — web story seen (covered by "reel/seen")
|
||||
// /api/v1/stories/reel/mark_seen/ — story mark (covered by "mark_seen")
|
||||
// /direct_v2/threads/…/seen/ — DM message read (covered by "/seen")
|
||||
// /api/v1/direct_v2/set_reel_seen/ — DM story (covered by "reel_seen")
|
||||
// /api/v1/direct_v2/mark_visual_item_seen/ — disappearing photos
|
||||
// /api/v1/live/…/heartbeat_and_get_viewer_count/ — live presence
|
||||
// /api/v1/live/…/join_request/ — live join
|
||||
// WS text frames with "typing", "direct_v2/typing", "activity_status"
|
||||
//
|
||||
// ============================================================================
|
||||
|
||||
/// Central hub for all JavaScript and CSS injected into the Instagram WebView.
|
||||
import '../scripts/core_injection.dart' as scripts;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
|
||||
class InjectionController {
|
||||
// ── User Agent ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// iOS UA ensures Instagram serves the full mobile UI (Reels, Stories, DMs).
|
||||
/// Without spoofing, instagram.com returns a stripped desktop-lite shell.
|
||||
static const String iOSUserAgent =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
|
||||
'Mobile/22G86 [FBAN/FBIOS;FBAV/531.0.0.35.77;FBBV/792629356;'
|
||||
'FBDV/iPhone17,2;FBMD/iPhone;FBSN/iOS;FBSV/18.6;FBSS/3;'
|
||||
'FBID/phone;FBLC/en_US;FBOP/5;FBRV/0;IABMV/1]';
|
||||
'Version/26.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
// ── Ghost Mode keyword groups ────────────────────────────────────────────────
|
||||
static const String reelsMutationObserverJS =
|
||||
scripts.kReelsMutationObserverJS;
|
||||
static const String linkSanitizationJS = scripts.kLinkSanitizationJS;
|
||||
static String get notificationBridgeJS => scripts.kNotificationBridgeJS;
|
||||
|
||||
/// Semantic groups used by [buildGhostModeJS].
|
||||
///
|
||||
/// Each group is a list of URL substrings. A network request is suppressed
|
||||
/// if its URL contains ANY substring in the enabled groups.
|
||||
///
|
||||
/// To add future endpoints: append keywords here — nothing else changes.
|
||||
static const Map<String, List<String>> _ghostGroups = {
|
||||
// Any URL that records you having seen/read something
|
||||
'seen': ['/seen', '/mark_seen', 'reel_seen', 'reel/seen', 'media/seen'],
|
||||
// Typing indicator (REST + WebSocket text frames)
|
||||
'typing': ['set_typing_status', '/typing', 'activity_status'],
|
||||
// Live stream viewer join / heartbeat (you appear in viewer list)
|
||||
'live': ['/live/'],
|
||||
// Disappearing / view-once DM photos
|
||||
'dmPhotos': ['visual_item_seen'],
|
||||
};
|
||||
|
||||
// ── CSS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
|
||||
/// Important: we must NOT hide [role="tablist"] inside dialogs/modals,
|
||||
/// because Instagram's comment input sheet also uses that role and the
|
||||
/// CSS would paint a grey overlay on top of the typing area.
|
||||
static const String _globalUIFixesCSS = '''
|
||||
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
|
||||
* {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
}
|
||||
/* Only hide the PRIMARY nav tablist (bottom bar), not tablist inside dialogs */
|
||||
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
|
||||
[aria-label="Direct"] header {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Blurs images/videos in the home feed AND on Explore.
|
||||
/// Activated via the body[path] attribute written by [_trackPathJS].
|
||||
static const String _blurHomeFeedAndExploreCSS = '''
|
||||
body[path="/"] article img,
|
||||
body[path="/"] article video,
|
||||
body[path^="/explore"] img,
|
||||
body[path^="/explore"] video,
|
||||
body[path="/explore/"] img,
|
||||
body[path="/explore/"] video {
|
||||
filter: blur(20px) !important;
|
||||
transition: filter 0.15s ease !important;
|
||||
}
|
||||
body[path="/"] article img:hover,
|
||||
body[path="/"] article video:hover,
|
||||
body[path^="/explore"] img:hover,
|
||||
body[path^="/explore"] video:hover {
|
||||
filter: blur(20px) !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Prevents text selection to keep the app feeling native.
|
||||
static const String _disableSelectionCSS = '''
|
||||
* { -webkit-user-select: none !important; user-select: none !important; }
|
||||
''';
|
||||
|
||||
/// Hides reel posts in the home feed when no Reel Session is active.
|
||||
/// The Reels nav tab is NOT hidden — Flutter intercepts that navigation.
|
||||
static const String _hideReelsFeedContentCSS = '''
|
||||
a[href*="/reel/"],
|
||||
div[data-media-type="2"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
''';
|
||||
|
||||
// _blurExploreCSS removed — replaced by _blurHomeFeedAndExploreCSS above.
|
||||
|
||||
/// Blurs reel thumbnail images shown in the feed.
|
||||
static const String _blurReelsCSS = '''
|
||||
a[href*="/reel/"] img { filter: blur(12px) !important; }
|
||||
''';
|
||||
|
||||
// ── JavaScript helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Removes the "Open in App" nag banner.
|
||||
static const String _dismissAppBannerJS = '''
|
||||
(function fgDismissBanner() {
|
||||
['[id*="app-banner"]','[class*="app-banner"]',
|
||||
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
|
||||
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Replaces ONLY the Instagram wordmark SVG with "FocusGram" brand text.
|
||||
/// Specifically targets the top-bar logo SVG (aria-label="Instagram") while
|
||||
/// explicitly excluding SVG icons inside nav/tablist (home, notifications,
|
||||
/// create, reels, profile icons).
|
||||
static const String _brandingJS = r'''
|
||||
(function fgBranding() {
|
||||
// Only the wordmark: SVG with aria-label="Instagram" that is NOT inside
|
||||
// a [role="tablist"] (bottom nav) or a [role="navigation"] (nav bar).
|
||||
// Also targets the ._ac83 class which Instagram uses for its top wordmark.
|
||||
const WORDMARK_SEL = [
|
||||
'svg[aria-label="Instagram"]',
|
||||
'._ac83 svg[aria-label="Instagram"]',
|
||||
'h1[role="presentation"] svg',
|
||||
];
|
||||
const STYLE =
|
||||
'font-family:"Grand Hotel",cursive;font-size:26px;color:#fff;' +
|
||||
'vertical-align:middle;cursor:default;letter-spacing:.5px;display:inline-block;';
|
||||
|
||||
function isNavIcon(el) {
|
||||
// Exclude any SVG that lives inside a tablist, nav, or link with
|
||||
// non-home/non-root href (these are functional icons, not the wordmark).
|
||||
if (el.closest('[role="tablist"]')) return true;
|
||||
if (el.closest('[role="navigation"]')) return true;
|
||||
// The wordmark is always at the TOP of the page in a header/banner
|
||||
const header = el.closest('header, [role="banner"], [role="main"]');
|
||||
if (!header && el.closest('[role="button"]')) return true;
|
||||
// If the SVG has a meaningful role (img presenting an action icon), skip it
|
||||
const role = el.getAttribute('role');
|
||||
if (role && role !== 'img') return true;
|
||||
// If the parent <a> goes somewhere other than "/" it is a nav link
|
||||
const anchor = el.closest('a');
|
||||
if (anchor) {
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
if (href && href !== '/' && !href.startsWith('/?')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function apply() {
|
||||
WORDMARK_SEL.forEach(sel => document.querySelectorAll(sel).forEach(logo => {
|
||||
if (logo.dataset.fgBranded) return;
|
||||
if (isNavIcon(logo)) return;
|
||||
logo.dataset.fgBranded = 'true';
|
||||
const span = Object.assign(document.createElement('span'),
|
||||
{ textContent: 'FocusGram' });
|
||||
span.style.cssText = STYLE;
|
||||
logo.style.display = 'none';
|
||||
logo.parentNode.insertBefore(span, logo.nextSibling);
|
||||
}));
|
||||
}
|
||||
apply();
|
||||
new MutationObserver(apply)
|
||||
.observe(document.documentElement, { childList: true, subtree: true });
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Intercepts clicks on /reels/ links when no session is active and redirects
|
||||
/// to a recognisable URL so Flutter's NavigationDelegate can catch and block it.
|
||||
///
|
||||
/// Without this, fast SPA clicks bypass the NavigationDelegate entirely.
|
||||
static const String _strictReelsBlockJS = r'''
|
||||
(function fgReelsBlock() {
|
||||
if (window.__fgReelsBlockPatched) return;
|
||||
window.__fgReelsBlockPatched = true;
|
||||
document.addEventListener('click', e => {
|
||||
if (window.__focusgramSessionActive) return;
|
||||
const a = e.target && e.target.closest('a[href*="/reels/"]');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.href = '/reels/?fg=blocked';
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
/// SPA route tracker: writes `body[path]` and notifies Flutter of path changes
|
||||
/// via `FocusGramPathChannel` so reels can be blocked on SPA navigation.
|
||||
static const String _trackPathJS = '''
|
||||
(function fgTrackPath() {
|
||||
if (window.__fgPathTrackerRunning) return;
|
||||
window.__fgPathTrackerRunning = true;
|
||||
let last = window.location.pathname;
|
||||
function check() {
|
||||
const p = window.location.pathname;
|
||||
if (p !== last) {
|
||||
last = p;
|
||||
if (document.body) document.body.setAttribute('path', p);
|
||||
if (window.FocusGramPathChannel) window.FocusGramPathChannel.postMessage(p);
|
||||
}
|
||||
}
|
||||
if (document.body) document.body.setAttribute('path', last);
|
||||
setInterval(check, 500);
|
||||
})();
|
||||
''';
|
||||
|
||||
/// Injects a persistent `style` element and keeps it alive across SPA route
|
||||
/// changes by watching for it being removed from `head`.
|
||||
static String _buildMutationObserver(String cssContent) =>
|
||||
'''
|
||||
(function fgApplyStyles() {
|
||||
@@ -264,9 +40,6 @@ class InjectionController {
|
||||
return '`$escaped`';
|
||||
}
|
||||
|
||||
// ── Navigation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Returns JS that navigates to [path] only when not already on it.
|
||||
static String softNavigateJS(String path) =>
|
||||
'''
|
||||
(function() {
|
||||
@@ -275,526 +48,59 @@ class InjectionController {
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Session state ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Writes the current session-active flag into the WebView global scope.
|
||||
/// All injected scripts (Ghost Mode, scroll lock) read this flag.
|
||||
static String buildSessionStateJS(bool active) =>
|
||||
'window.__focusgramSessionActive = $active;';
|
||||
|
||||
// ── Ghost Mode ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns all URL keywords that should be blocked for the given feature flags.
|
||||
///
|
||||
/// Exposed as a separate method so unit tests can verify keyword selection
|
||||
/// independently of the full JS string.
|
||||
static List<String> resolveBlockedKeywords({
|
||||
required bool typingIndicator,
|
||||
required bool seenStatus,
|
||||
required bool stories,
|
||||
required bool dmPhotos,
|
||||
}) {
|
||||
final out = <String>[];
|
||||
if (seenStatus) out.addAll(_ghostGroups['seen']!);
|
||||
if (typingIndicator) out.addAll(_ghostGroups['typing']!);
|
||||
if (stories) out.addAll(_ghostGroups['live']!);
|
||||
if (dmPhotos) out.addAll(_ghostGroups['dmPhotos']!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Returns all WebSocket text-frame keywords to drop for the given flags.
|
||||
static List<String> resolveWsBlockedKeywords({
|
||||
required bool typingIndicator,
|
||||
}) {
|
||||
if (!typingIndicator) return const [];
|
||||
return List.unmodifiable(_ghostGroups['typing']!);
|
||||
}
|
||||
|
||||
/// Builds JavaScript that intercepts fetch, XHR, WebSocket, and sendBeacon
|
||||
/// traffic to suppress ALL activity receipts (seen, typing, live, DM photos).
|
||||
///
|
||||
/// All blocked requests return `{"status":"ok"}` with HTTP 200 so Instagram
|
||||
/// does not retry or display an error.
|
||||
///
|
||||
/// See [resolveBlockedKeywords] for the URL-keyword logic.
|
||||
static String buildGhostModeJS({
|
||||
required bool typingIndicator,
|
||||
required bool seenStatus,
|
||||
required bool stories,
|
||||
required bool dmPhotos,
|
||||
}) {
|
||||
if (!typingIndicator && !seenStatus && !stories && !dmPhotos) return '';
|
||||
|
||||
final blocked = resolveBlockedKeywords(
|
||||
typingIndicator: typingIndicator,
|
||||
seenStatus: seenStatus,
|
||||
stories: stories,
|
||||
dmPhotos: dmPhotos,
|
||||
);
|
||||
final wsBlocked = resolveWsBlockedKeywords(
|
||||
typingIndicator: typingIndicator,
|
||||
);
|
||||
|
||||
final urlsJson = blocked.map((u) => '"$u"').join(', ');
|
||||
final wsJson = wsBlocked.map((u) => '"$u"').join(', ');
|
||||
|
||||
return '''
|
||||
(function fgGhostMode() {
|
||||
if (window.__fgGhostModeDone) return;
|
||||
window.__fgGhostModeDone = true;
|
||||
|
||||
// URL substrings — any request whose URL contains one of these is silenced.
|
||||
const BLOCKED = [$urlsJson];
|
||||
// WebSocket text-frame keywords to drop (MQTT typing/presence).
|
||||
const WS_KEYS = [$wsJson];
|
||||
|
||||
function shouldBlock(url) {
|
||||
return typeof url === 'string' && BLOCKED.some(k => url.includes(k));
|
||||
}
|
||||
|
||||
function isDmVideoLocked(url) {
|
||||
if (typeof url !== 'string') return false;
|
||||
if (!url.includes('.mp4') && !url.includes('/v/t') && !url.includes('cdninstagram') && !url.includes('.dash')) return false;
|
||||
return window.__fgDmReelAlreadyLoaded === true;
|
||||
}
|
||||
|
||||
// ── fetch ──────────────────────────────────────────────────────────────
|
||||
const _oFetch = window.__fgOrigFetch || window.fetch;
|
||||
window.__fgOrigFetch = _oFetch;
|
||||
window.__fgGhostFetch = function(resource, init) {
|
||||
const url = typeof resource === 'string' ? resource : (resource && resource.url) || '';
|
||||
// Ghost mode: block seen/typing receipts
|
||||
if (shouldBlock(url))
|
||||
return Promise.resolve(new Response('{"status":"ok"}',
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
// DM isolation: block additional video segments after first reel loaded
|
||||
if (isDmVideoLocked(url))
|
||||
return Promise.resolve(new Response('', { status: 200 }));
|
||||
return _oFetch.apply(this, arguments);
|
||||
};
|
||||
window.fetch = window.__fgGhostFetch;
|
||||
|
||||
// ── sendBeacon ─────────────────────────────────────────────────────────
|
||||
if (navigator.sendBeacon && !window.__fgBeaconPatched) {
|
||||
window.__fgBeaconPatched = true;
|
||||
const _oBeacon = navigator.sendBeacon.bind(navigator);
|
||||
navigator.sendBeacon = function(url, data) {
|
||||
if (shouldBlock(url)) return true;
|
||||
return _oBeacon(url, data);
|
||||
};
|
||||
}
|
||||
|
||||
// ── XHR ────────────────────────────────────────────────────────────────
|
||||
const _oOpen = window.__fgOrigXhrOpen || XMLHttpRequest.prototype.open;
|
||||
const _oSend = window.__fgOrigXhrSend || XMLHttpRequest.prototype.send;
|
||||
window.__fgOrigXhrOpen = _oOpen;
|
||||
window.__fgOrigXhrSend = _oSend;
|
||||
XMLHttpRequest.prototype.open = function(m, url) {
|
||||
this._fgUrl = url;
|
||||
this._fgBlock = shouldBlock(url);
|
||||
return _oOpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function() {
|
||||
if (this._fgBlock) {
|
||||
Object.defineProperty(this, 'readyState', { get: () => 4, configurable: true });
|
||||
Object.defineProperty(this, 'status', { get: () => 200, configurable: true });
|
||||
Object.defineProperty(this, 'responseText', { get: () => '{"status":"ok"}', configurable: true });
|
||||
Object.defineProperty(this, 'response', { get: () => '{"status":"ok"}', configurable: true });
|
||||
setTimeout(() => {
|
||||
try { if (this.onreadystatechange) this.onreadystatechange(); } catch(_) {}
|
||||
try { if (this.onload) this.onload(); } catch(_) {}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
// DM isolation: block additional video XHR fetches after first reel loaded
|
||||
if (this._fgUrl && isDmVideoLocked(this._fgUrl)) {
|
||||
setTimeout(() => { try { this.onload?.(); } catch(_) {} }, 0);
|
||||
return;
|
||||
}
|
||||
return _oSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ── WebSocket — block text AND binary frames ───────────────────────────
|
||||
if (!window.__fgWsGhostDone) {
|
||||
window.__fgWsGhostDone = true;
|
||||
const _OWS = window.WebSocket;
|
||||
const ALL_SEEN = [$urlsJson];
|
||||
function containsKeyword(data) {
|
||||
if (typeof data === 'string') return ALL_SEEN.some(k => data.includes(k));
|
||||
try {
|
||||
let bytes;
|
||||
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
|
||||
else if (data instanceof Uint8Array) bytes = data;
|
||||
else return false;
|
||||
const text = String.fromCharCode.apply(null, bytes);
|
||||
return ALL_SEEN.some(k => text.includes(k));
|
||||
} catch(_) { return false; }
|
||||
}
|
||||
function FgWS(url, proto) {
|
||||
const ws = proto != null ? new _OWS(url, proto) : new _OWS(url);
|
||||
const _send = ws.send.bind(ws);
|
||||
ws.send = function(data) {
|
||||
if (containsKeyword(data)) return;
|
||||
return _send(data);
|
||||
};
|
||||
return ws;
|
||||
}
|
||||
FgWS.prototype = _OWS.prototype;
|
||||
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(k => FgWS[k] = _OWS[k]);
|
||||
window.WebSocket = FgWS;
|
||||
}
|
||||
|
||||
// Reapply every 3 s in case Instagram replaces window.fetch
|
||||
if (!window.__fgGhostReapplyInterval) {
|
||||
window.__fgGhostReapplyInterval = setInterval(() => {
|
||||
if (window.fetch !== window.__fgGhostFetch && window.__fgOrigFetch)
|
||||
window.fetch = window.__fgGhostFetch;
|
||||
}, 3000);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
}
|
||||
|
||||
// ── Theme Detector ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Detects Instagram's current theme (dark/light) and notifies Flutter.
|
||||
static const String _themeDetectorJS = r'''
|
||||
(function fgThemeSync() {
|
||||
if (window.__fgThemeSyncRunning) return;
|
||||
window.__fgThemeSyncRunning = true;
|
||||
|
||||
function getTheme() {
|
||||
try {
|
||||
// 1. Check Instagram's specific classes
|
||||
const h = document.documentElement;
|
||||
if (h.classList.contains('style-dark')) return 'dark';
|
||||
if (h.classList.contains('style-light')) return 'light';
|
||||
|
||||
// 2. Check body background color
|
||||
const bg = window.getComputedStyle(document.body).backgroundColor;
|
||||
const rgb = bg.match(/\d+/g);
|
||||
if (rgb && rgb.length >= 3) {
|
||||
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
||||
return luminance < 0.5 ? 'dark' : 'light';
|
||||
}
|
||||
} catch(_) {}
|
||||
return 'dark'; // Fallback
|
||||
}
|
||||
|
||||
let last = '';
|
||||
function check() {
|
||||
const current = getTheme();
|
||||
if (current !== last) {
|
||||
last = current;
|
||||
if (window.FocusGramThemeChannel) {
|
||||
window.FocusGramThemeChannel.postMessage(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(check, 1500);
|
||||
check();
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Reel scroll lock ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Prevents swipe-to-next-reel in the isolated DM reel player.
|
||||
///
|
||||
/// Lock is active when:
|
||||
/// `window.__focusgramIsolatedPlayer === true` (DM overlay)
|
||||
/// OR `window.__focusgramSessionActive === false` (no session)
|
||||
///
|
||||
/// Allow-list (these are never blocked):
|
||||
/// • buttons, anchors, [role=button], aria elements
|
||||
/// • dialogs, menus, modals, sheets (comment box, emoji picker, share sheet)
|
||||
/// • keyboard input inside comment / text fields
|
||||
/// Prevents swipe-to-next-reel in the isolated DM reel player.
|
||||
///
|
||||
/// Uses a document-level capture-phase touchmove listener so it fires BEFORE
|
||||
/// Instagram's scroll container can steal the gesture. The lock is active when
|
||||
/// `window.__focusgramIsolatedPlayer === true` (single reel from DM),
|
||||
/// OR `window.__focusgramSessionActive === false` (reels feed, no session).
|
||||
///
|
||||
/// The isolated player flag is also maintained here from the path tracker
|
||||
/// so it works for SPA navigations that don't trigger onPageFinished.
|
||||
static const String reelsMutationObserverJS = r'''
|
||||
(function fgReelLock() {
|
||||
if (window.__fgReelLockRunning) return;
|
||||
window.__fgReelLockRunning = true;
|
||||
|
||||
const ALLOW_SEL = 'button,a,[role="button"],[aria-label],[aria-haspopup],input,textarea,span,h1,h2,h3';
|
||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||
|
||||
function isLocked() {
|
||||
const isDmReel = window.location.pathname.includes('/direct/') &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||
return window.__focusgramIsolatedPlayer === true ||
|
||||
window.__focusgramSessionActive === false ||
|
||||
isDmReel;
|
||||
}
|
||||
|
||||
let sy = 0;
|
||||
document.addEventListener('touchstart', e => {
|
||||
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!isLocked()) return;
|
||||
// Allow vertical swipe if in a session and not on a DM/isolated path
|
||||
if (window.__focusgramSessionActive === true && !window.location.pathname.includes('/direct/')) return;
|
||||
|
||||
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
|
||||
if (Math.abs(dy) > 2) {
|
||||
if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return;
|
||||
// Mark the first DM reel as loaded on first swipe attempt
|
||||
if (window.location.pathname.includes('/direct/')) {
|
||||
window.__fgDmReelAlreadyLoaded = true;
|
||||
}
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
function block(e) {
|
||||
if (!isLocked()) return;
|
||||
if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
document.addEventListener('wheel', block, { capture: true, passive: false });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
|
||||
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
|
||||
block(e);
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
|
||||
|
||||
function sync() {
|
||||
const reels = document.querySelectorAll(REEL_SEL);
|
||||
|
||||
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
|
||||
// Give the first reel 3.5 s to buffer before activating the DM lock
|
||||
if (!window.__fgDmReelTimer) {
|
||||
window.__fgDmReelTimer = setTimeout(() => {
|
||||
if (document.querySelector(REEL_SEL)) {
|
||||
window.__fgDmReelAlreadyLoaded = true;
|
||||
}
|
||||
window.__fgDmReelTimer = null;
|
||||
}, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
if (reels.length === 0) {
|
||||
if (window.__fgDmReelTimer) {
|
||||
clearTimeout(window.__fgDmReelTimer);
|
||||
window.__fgDmReelTimer = null;
|
||||
}
|
||||
window.__fgDmReelAlreadyLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
sync();
|
||||
new MutationObserver(ms => {
|
||||
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Keep __focusgramIsolatedPlayer in sync with SPA navigations
|
||||
if (!window.__fgIsolatedPlayerSync) {
|
||||
window.__fgIsolatedPlayerSync = true;
|
||||
let _lastPath = window.location.pathname;
|
||||
setInterval(() => {
|
||||
const p = window.location.pathname;
|
||||
if (p === _lastPath) return;
|
||||
_lastPath = p;
|
||||
window.__focusgramIsolatedPlayer =
|
||||
p.includes('/reel/') && !p.startsWith('/reels/');
|
||||
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
|
||||
}, 400);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Badge Monitor ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Periodically checks Instagram's UI for unread counts (badges) on the Direct
|
||||
/// and Notifications icons, as well as the page title. Sends an event to
|
||||
/// Flutter whenever a new notification is detected.
|
||||
static const String _badgeMonitorJS = r'''
|
||||
(function fgBadgeMonitor() {
|
||||
if (window.__fgBadgeMonitorRunning) return;
|
||||
window.__fgBadgeMonitorRunning = true;
|
||||
|
||||
let lastDmCount = 0;
|
||||
let lastNotifCount = 0;
|
||||
let lastTitleUnread = 0;
|
||||
|
||||
function check() {
|
||||
try {
|
||||
// 1. Check Title for (N) indicator
|
||||
const titleMatch = document.title.match(/\((\d+)\)/);
|
||||
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
|
||||
|
||||
// 2. Scan for DM unread badge
|
||||
const dmBadge = document.querySelector([
|
||||
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/direct/inbox/"] [style*="255, 48, 64"]',
|
||||
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
|
||||
'div[role="button"][aria-label*="Direct"] [style*="255, 48, 64"]',
|
||||
'a[href*="/direct/inbox/"] svg[aria-label*="Direct"] + div', // New red dot sibling
|
||||
'a[href*="/direct/inbox/"] ._a9-v', // Modern common red badge class
|
||||
].join(','));
|
||||
const currentDmCount = dmBadge ? (parseInt(dmBadge.innerText) || 1) : 0;
|
||||
|
||||
// 3. Scan for Notifications unread badge
|
||||
const notifBadge = document.querySelector([
|
||||
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/notifications"] [style*="255, 48, 64"]',
|
||||
'a[href*="/notifications"] [aria-label*="unread"]'
|
||||
].join(','));
|
||||
const currentNotifCount = notifBadge ? (parseInt(notifBadge.innerText) || 1) : 0;
|
||||
|
||||
if (currentDmCount > lastDmCount) {
|
||||
window.FocusGramNotificationChannel?.postMessage('DM');
|
||||
} else if (currentNotifCount > lastNotifCount) {
|
||||
window.FocusGramNotificationChannel?.postMessage('Activity');
|
||||
} else if (currentTitleUnread > lastTitleUnread && currentTitleUnread > (currentDmCount + currentNotifCount)) {
|
||||
window.FocusGramNotificationChannel?.postMessage('Activity');
|
||||
}
|
||||
|
||||
lastDmCount = currentDmCount;
|
||||
lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread;
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
// Initial check after some delay to let page settle
|
||||
setTimeout(check, 2000);
|
||||
setInterval(check, 3000);
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Notification bridge ──────────────────────────────────────────────────────
|
||||
|
||||
/// Forwards Web Notification events to the native Flutter channel.
|
||||
static String get notificationBridgeJS => '''
|
||||
(function fgNotifBridge() {
|
||||
if (!window.Notification || window.__fgNotifBridged) return;
|
||||
window.__fgNotifBridged = true;
|
||||
const _N = window.Notification;
|
||||
window.Notification = function(title, opts) {
|
||||
try {
|
||||
if (window.FocusGramNotificationChannel)
|
||||
window.FocusGramNotificationChannel
|
||||
.postMessage(title + (opts && opts.body ? ': ' + opts.body : ''));
|
||||
} catch(_) {}
|
||||
return new _N(title, opts);
|
||||
};
|
||||
window.Notification.permission = 'granted';
|
||||
window.Notification.requestPermission = () => Promise.resolve('granted');
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Link sanitization ────────────────────────────────────────────────────────
|
||||
|
||||
/// Strips tracking query params (igsh, utm_*, fbclid…) from all links and the
|
||||
/// native Web Share API. Sanitised share URLs are routed to Flutter's share
|
||||
/// channel instead.
|
||||
static const String linkSanitizationJS = r'''
|
||||
(function fgSanitize() {
|
||||
if (window.__fgSanitizePatched) return;
|
||||
window.__fgSanitizePatched = true;
|
||||
const STRIP = [
|
||||
'igsh','igshid','fbclid',
|
||||
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
|
||||
'ref','s','_branch_match_id','_branch_referrer',
|
||||
];
|
||||
function clean(raw) {
|
||||
try {
|
||||
const u = new URL(raw, location.origin);
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
return u.toString();
|
||||
} catch(_) { return raw; }
|
||||
}
|
||||
if (navigator.share) {
|
||||
const _s = navigator.share.bind(navigator);
|
||||
navigator.share = function(d) {
|
||||
const u = d && d.url ? clean(d.url) : null;
|
||||
if (window.FocusGramShareChannel && u) {
|
||||
window.FocusGramShareChannel.postMessage(
|
||||
JSON.stringify({ url: u, title: (d && d.title) || '' }));
|
||||
return Promise.resolve();
|
||||
}
|
||||
return _s({ ...d, url: u || (d && d.url) });
|
||||
};
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const a = e.target && e.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
const href = a.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
|
||||
try {
|
||||
const u = new URL(href, location.origin);
|
||||
if (STRIP.some(p => u.searchParams.has(p))) {
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
a.href = u.toString();
|
||||
}
|
||||
} catch(_) {}
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── Main injection builder ───────────────────────────────────────────────────
|
||||
|
||||
/// Builds the complete JS payload for a page load or session-state change.
|
||||
///
|
||||
/// Injection order matters (later scripts can depend on earlier ones):
|
||||
/// 1. Session flag — other scripts read `__focusgramSessionActive`
|
||||
/// 2. Path tracker — writes `body[path]` for CSS page targeting
|
||||
/// 3. CSS observer — keeps `<style>` alive across SPA navigations
|
||||
/// 4. Banner dismiss — removes "Open in App" nag
|
||||
/// 5. Branding — replaces Instagram logo with FocusGram
|
||||
/// 6. Reels JS blocker — click-interceptor (only when no session)
|
||||
/// 7. Ghost Mode — network interceptors (fetch / XHR / WS)
|
||||
/// 8. Link sanitizer — tracking param stripping
|
||||
static String buildInjectionJS({
|
||||
required bool sessionActive,
|
||||
required bool blurExplore,
|
||||
required bool blurReels,
|
||||
required bool ghostTyping,
|
||||
required bool ghostSeen,
|
||||
required bool ghostStories,
|
||||
required bool ghostDmPhotos,
|
||||
required bool enableTextSelection,
|
||||
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideLikeCounts,
|
||||
required bool hideFollowerCounts,
|
||||
required bool hideStoriesBar,
|
||||
required bool hideExploreTab,
|
||||
required bool hideReelsTab,
|
||||
required bool hideShopTab,
|
||||
required bool disableReelsEntirely,
|
||||
}) {
|
||||
final css = StringBuffer()..writeln(_globalUIFixesCSS);
|
||||
if (!enableTextSelection) css.writeln(_disableSelectionCSS);
|
||||
if (!sessionActive) {
|
||||
css.writeln(_hideReelsFeedContentCSS);
|
||||
if (blurReels) css.writeln(_blurReelsCSS);
|
||||
}
|
||||
// blurExplore now also blurs home-feed posts ("Blur Posts and Explore")
|
||||
if (blurExplore) css.writeln(_blurHomeFeedAndExploreCSS);
|
||||
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
||||
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
||||
|
||||
final ghost = buildGhostModeJS(
|
||||
typingIndicator: ghostTyping,
|
||||
seenStatus: ghostSeen,
|
||||
stories: ghostStories,
|
||||
dmPhotos: ghostDmPhotos,
|
||||
);
|
||||
if (!sessionActive) {
|
||||
// Hide reel feed content when no session active
|
||||
css.writeln(scripts.kHideReelsFeedContentCSS);
|
||||
}
|
||||
|
||||
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
|
||||
// Previously it was inside that block alongside display:none on the parent —
|
||||
// you cannot blur children of a display:none element, making it dead code.
|
||||
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
|
||||
// when sessionActive=false, reels are hidden anyway (blur harmless).
|
||||
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
|
||||
|
||||
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
||||
|
||||
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
||||
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
||||
if (hideStoriesBar) css.writeln(ui_hider.kHideStoriesBarCSS);
|
||||
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
||||
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
||||
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
||||
|
||||
return '''
|
||||
${buildSessionStateJS(sessionActive)}
|
||||
$_trackPathJS
|
||||
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
||||
${scripts.kTrackPathJS}
|
||||
${_buildMutationObserver(css.toString())}
|
||||
$_dismissAppBannerJS
|
||||
$_brandingJS
|
||||
${!sessionActive ? _strictReelsBlockJS : ''}
|
||||
$reelsMutationObserverJS
|
||||
$ghost
|
||||
$linkSanitizationJS
|
||||
$_themeDetectorJS
|
||||
$_badgeMonitorJS
|
||||
${scripts.kDismissAppBannerJS}
|
||||
${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
|
||||
${scripts.kReelsMutationObserverJS}
|
||||
${scripts.kLinkSanitizationJS}
|
||||
${scripts.kThemeDetectorJS}
|
||||
${scripts.kBadgeMonitorJS}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'session_manager.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'injection_controller.dart';
|
||||
import '../scripts/grayscale.dart' as grayscale;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
import '../scripts/content_disabling.dart' as content_disabling;
|
||||
|
||||
// Core JS and CSS payloads injected into the Instagram WebView.
|
||||
//
|
||||
// WARNING: Do not add any network interception logic ("ghost mode") here.
|
||||
// All scripts in this file must be limited to UI behaviour, navigation helpers,
|
||||
// and local-only features that do not modify data sent to Meta's servers.
|
||||
|
||||
// ── CSS payloads ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
|
||||
const String kGlobalUIFixesCSS = '''
|
||||
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
|
||||
* {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
}
|
||||
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
|
||||
[aria-label="Direct"] header {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Blurs images/videos in the home feed AND on Explore.
|
||||
const String kBlurHomeFeedAndExploreCSS = '''
|
||||
body[path="/"] article img,
|
||||
body[path="/"] article video,
|
||||
body[path^="/explore"] img,
|
||||
body[path^="/explore"] video,
|
||||
body[path="/explore/"] img,
|
||||
body[path="/explore/"] video {
|
||||
filter: blur(20px) !important;
|
||||
transition: filter 0.15s ease !important;
|
||||
}
|
||||
body[path="/"] article img:hover,
|
||||
body[path="/"] article video:hover,
|
||||
body[path^="/explore"] img:hover,
|
||||
body[path^="/explore"] video:hover {
|
||||
filter: blur(20px) !important;
|
||||
}
|
||||
''';
|
||||
|
||||
/// Prevents text selection to keep the app feeling native (only when disabled).
|
||||
const String kDisableSelectionCSS = '''
|
||||
* { -webkit-user-select: none !important; user-select: none !important; }
|
||||
''';
|
||||
|
||||
/// Hides reel posts in the home feed when no Reel Session is active.
|
||||
const String kHideReelsFeedContentCSS = '''
|
||||
a[href*="/reel/"],
|
||||
div[data-media-type="2"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
''';
|
||||
|
||||
// ── JavaScript helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const String kDismissAppBannerJS = '''
|
||||
(function fgDismissBanner() {
|
||||
['[id*="app-banner"]','[class*="app-banner"]',
|
||||
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
|
||||
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kStrictReelsBlockJS = r'''
|
||||
(function fgReelsBlock() {
|
||||
if (window.__fgReelsBlockPatched) return;
|
||||
window.__fgReelsBlockPatched = true;
|
||||
document.addEventListener('click', e => {
|
||||
if (window.__focusgramSessionActive) return;
|
||||
const a = e.target && e.target.closest('a[href*="/reels/"]');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.location.href = '/reels/?fg=blocked';
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kTrackPathJS = '''
|
||||
(function fgTrackPath() {
|
||||
if (window.__fgPathTrackerRunning) return;
|
||||
window.__fgPathTrackerRunning = true;
|
||||
let last = window.location.pathname;
|
||||
function check() {
|
||||
const p = window.location.pathname;
|
||||
if (p !== last) {
|
||||
last = p;
|
||||
if (document.body) document.body.setAttribute('path', p);
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('UrlChange', p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (document.body) document.body.setAttribute('path', last);
|
||||
setInterval(check, 500);
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kThemeDetectorJS = r'''
|
||||
(function fgThemeSync() {
|
||||
if (window.__fgThemeSyncRunning) return;
|
||||
window.__fgThemeSyncRunning = true;
|
||||
function getTheme() {
|
||||
try {
|
||||
const h = document.documentElement;
|
||||
if (h.classList.contains('style-dark')) return 'dark';
|
||||
if (h.classList.contains('style-light')) return 'light';
|
||||
const bg = window.getComputedStyle(document.body).backgroundColor;
|
||||
const rgb = bg.match(/\d+/g);
|
||||
if (rgb && rgb.length >= 3) {
|
||||
const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
||||
return luminance < 0.5 ? 'dark' : 'light';
|
||||
}
|
||||
} catch(_) {}
|
||||
return 'dark';
|
||||
}
|
||||
let last = '';
|
||||
function check() {
|
||||
const current = getTheme();
|
||||
if (current !== last) {
|
||||
last = current;
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramThemeChannel', current);
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(check, 1500);
|
||||
check();
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kReelsMutationObserverJS = r'''
|
||||
(function fgReelLock() {
|
||||
if (window.__fgReelLockRunning) return;
|
||||
window.__fgReelLockRunning = true;
|
||||
|
||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||
|
||||
function lockMode() {
|
||||
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled
|
||||
const isDmReel = window.location.pathname.includes('/direct/') &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||
if (isDmReel) return 'dm_reel';
|
||||
if (window.__fgDisableReelsEntirely === true) return 'disabled';
|
||||
return null;
|
||||
}
|
||||
|
||||
function isLocked() { return lockMode() !== null; }
|
||||
|
||||
function allowInteractionTarget(t) {
|
||||
if (!t || !t.closest) return false;
|
||||
if (t.closest('input,textarea,[contenteditable="true"]')) return true;
|
||||
if (t.closest(MODAL_SEL)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
let sy = 0;
|
||||
document.addEventListener('touchstart', e => {
|
||||
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('touchmove', e => {
|
||||
if (!isLocked()) return;
|
||||
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
|
||||
if (Math.abs(dy) > 2) {
|
||||
if (window.location.pathname.includes('/direct/')) {
|
||||
window.__fgDmReelAlreadyLoaded = true;
|
||||
}
|
||||
if (allowInteractionTarget(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
function block(e) {
|
||||
if (!isLocked()) return;
|
||||
if (allowInteractionTarget(e.target)) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
document.addEventListener('wheel', block, { capture: true, passive: false });
|
||||
document.addEventListener('keydown', e => {
|
||||
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
|
||||
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
|
||||
block(e);
|
||||
}, { capture: true, passive: false });
|
||||
|
||||
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
|
||||
let __fgOrigHtmlOverflow = null;
|
||||
let __fgOrigBodyOverflow = null;
|
||||
|
||||
function applyOverflowLock() {
|
||||
try {
|
||||
const mode = lockMode();
|
||||
const hasReel = !!document.querySelector(REEL_SEL);
|
||||
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
|
||||
if (__fgOrigHtmlOverflow === null) {
|
||||
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
||||
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
||||
}
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
if (document.body) document.body.style.overflow = 'hidden';
|
||||
} else if (__fgOrigHtmlOverflow !== null) {
|
||||
document.documentElement.style.overflow = __fgOrigHtmlOverflow;
|
||||
if (document.body) document.body.style.overflow = __fgOrigBodyOverflow || '';
|
||||
__fgOrigHtmlOverflow = null;
|
||||
__fgOrigBodyOverflow = null;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function sync() {
|
||||
const reels = document.querySelectorAll(REEL_SEL);
|
||||
applyOverflowLock();
|
||||
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
|
||||
if (!window.__fgDmReelTimer) {
|
||||
window.__fgDmReelTimer = setTimeout(() => {
|
||||
if (document.querySelector(REEL_SEL)) window.__fgDmReelAlreadyLoaded = true;
|
||||
window.__fgDmReelTimer = null;
|
||||
}, 3500);
|
||||
}
|
||||
}
|
||||
if (reels.length === 0) {
|
||||
if (window.__fgDmReelTimer) { clearTimeout(window.__fgDmReelTimer); window.__fgDmReelTimer = null; }
|
||||
window.__fgDmReelAlreadyLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
sync();
|
||||
new MutationObserver(ms => {
|
||||
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
if (!window.__fgIsolatedPlayerSync) {
|
||||
window.__fgIsolatedPlayerSync = true;
|
||||
let _lastPath = window.location.pathname;
|
||||
setInterval(() => {
|
||||
const p = window.location.pathname;
|
||||
if (p === _lastPath) return;
|
||||
_lastPath = p;
|
||||
window.__focusgramIsolatedPlayer = p.includes('/reel/') && !p.startsWith('/reels');
|
||||
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
|
||||
applyOverflowLock();
|
||||
}, 400);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kBadgeMonitorJS = r'''
|
||||
(function fgBadgeMonitor() {
|
||||
if (window.__fgBadgeMonitorRunning) return;
|
||||
window.__fgBadgeMonitorRunning = true;
|
||||
const startedAt = Date.now();
|
||||
let initialised = false;
|
||||
let lastDmCount = 0, lastNotifCount = 0, lastTitleUnread = 0;
|
||||
|
||||
function parseBadgeCount(el) {
|
||||
if (!el) return 0;
|
||||
try {
|
||||
const raw = (el.innerText || el.textContent || '').trim();
|
||||
const n = parseInt(raw, 10);
|
||||
return isNaN(n) ? 1 : n;
|
||||
} catch (_) { return 1; }
|
||||
}
|
||||
|
||||
function check() {
|
||||
try {
|
||||
const titleMatch = document.title.match(/\((\d+)\)/);
|
||||
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
|
||||
const dmBadge = document.querySelector([
|
||||
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
|
||||
'a[href*="/direct/inbox/"] ._a9-v',
|
||||
].join(','));
|
||||
const currentDmCount = parseBadgeCount(dmBadge);
|
||||
const notifBadge = document.querySelector([
|
||||
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
|
||||
'a[href*="/notifications"] [aria-label*="unread"]',
|
||||
].join(','));
|
||||
const currentNotifCount = parseBadgeCount(notifBadge);
|
||||
|
||||
if (!initialised) {
|
||||
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread; initialised = true; return;
|
||||
}
|
||||
if (Date.now() - startedAt < 6000) {
|
||||
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread; return;
|
||||
}
|
||||
if (currentDmCount > lastDmCount && window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'DM');
|
||||
} else if (currentNotifCount > lastNotifCount && window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
|
||||
}
|
||||
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
|
||||
lastTitleUnread = currentTitleUnread;
|
||||
} catch(_) {}
|
||||
}
|
||||
setTimeout(check, 2000);
|
||||
setInterval(check, 1000);
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kNotificationBridgeJS = '''
|
||||
(function fgNotifBridge() {
|
||||
if (!window.Notification || window.__fgNotifBridged) return;
|
||||
window.__fgNotifBridged = true;
|
||||
const startedAt = Date.now();
|
||||
const _N = window.Notification;
|
||||
window.Notification = function(title, opts) {
|
||||
try {
|
||||
if (Date.now() - startedAt < 6000) return new _N(title, opts);
|
||||
if (window.flutter_inappwebview) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'FocusGramNotificationChannel',
|
||||
title + (opts && opts.body ? ': ' + opts.body : ''),
|
||||
);
|
||||
}
|
||||
} catch(_) {}
|
||||
return new _N(title, opts);
|
||||
};
|
||||
window.Notification.permission = 'granted';
|
||||
window.Notification.requestPermission = () => Promise.resolve('granted');
|
||||
})();
|
||||
''';
|
||||
|
||||
const String kLinkSanitizationJS = r'''
|
||||
(function fgSanitize() {
|
||||
if (window.__fgSanitizePatched) return;
|
||||
window.__fgSanitizePatched = true;
|
||||
const STRIP = [
|
||||
'igsh','igshid','fbclid',
|
||||
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
|
||||
'ref','s','_branch_match_id','_branch_referrer',
|
||||
];
|
||||
function clean(raw) {
|
||||
try {
|
||||
const u = new URL(raw, location.origin);
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
return u.toString();
|
||||
} catch(_) { return raw; }
|
||||
}
|
||||
if (navigator.share) {
|
||||
const _s = navigator.share.bind(navigator);
|
||||
navigator.share = function(d) {
|
||||
const u = d && d.url ? clean(d.url) : null;
|
||||
if (window.flutter_inappwebview && u) {
|
||||
window.flutter_inappwebview.callHandler(
|
||||
'FocusGramShareChannel',
|
||||
JSON.stringify({ url: u, title: (d && d.title) || '' }),
|
||||
);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return _s({ ...d, url: u || (d && d.url) });
|
||||
};
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
const a = e.target && e.target.closest('a[href]');
|
||||
if (!a) return;
|
||||
const href = a.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
|
||||
try {
|
||||
const u = new URL(href, location.origin);
|
||||
if (STRIP.some(p => u.searchParams.has(p))) {
|
||||
STRIP.forEach(p => u.searchParams.delete(p));
|
||||
a.href = u.toString();
|
||||
}
|
||||
} catch(_) {}
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
// ── InjectionManager class ─────────────────────────────────────────────────
|
||||
|
||||
class InjectionManager {
|
||||
final InAppWebViewController controller;
|
||||
final SharedPreferences prefs;
|
||||
final SessionManager sessionManager;
|
||||
|
||||
SettingsService? _settingsService;
|
||||
|
||||
InjectionManager({
|
||||
required this.controller,
|
||||
required this.prefs,
|
||||
required this.sessionManager,
|
||||
});
|
||||
|
||||
void setSettingsService(SettingsService settingsService) {
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
/// Runs all post-load JavaScript injections based on current settings.
|
||||
Future<void> runAllPostLoadInjections(String url) async {
|
||||
if (_settingsService == null) return;
|
||||
|
||||
final settings = _settingsService!;
|
||||
final sessionActive = sessionManager.isSessionActive;
|
||||
|
||||
// Get settings values
|
||||
final blurExplore = settings.blurExplore;
|
||||
final enableTextSelection = settings.enableTextSelection;
|
||||
final hideSuggestedPosts = settings.hideSuggestedPosts;
|
||||
final hideSponsoredPosts = settings.hideSponsoredPosts;
|
||||
final hideLikeCounts = settings.hideLikeCounts;
|
||||
final hideFollowerCounts = settings.hideFollowerCounts;
|
||||
final hideExploreTab = settings.hideExploreTab;
|
||||
final hideReelsTab = settings.hideReelsTab;
|
||||
final hideShopTab = settings.hideShopTab;
|
||||
final disableReelsEntirely = settings.disableReelsEntirely;
|
||||
final isGrayscaleActive = settings.isGrayscaleActiveNow;
|
||||
|
||||
final injectionJS = InjectionController.buildInjectionJS(
|
||||
sessionActive: sessionActive,
|
||||
blurExplore: blurExplore,
|
||||
blurReels: false, // Blur reels feature removed
|
||||
enableTextSelection: enableTextSelection,
|
||||
hideSuggestedPosts: hideSuggestedPosts,
|
||||
hideSponsoredPosts: hideSponsoredPosts,
|
||||
hideLikeCounts: hideLikeCounts,
|
||||
hideFollowerCounts: hideFollowerCounts,
|
||||
hideStoriesBar: false, // Story blocking removed
|
||||
hideExploreTab: hideExploreTab,
|
||||
hideReelsTab: hideReelsTab,
|
||||
hideShopTab: hideShopTab,
|
||||
disableReelsEntirely: disableReelsEntirely,
|
||||
);
|
||||
|
||||
try {
|
||||
await controller.evaluateJavascript(source: injectionJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
|
||||
// Inject grayscale when active, remove when not active
|
||||
if (isGrayscaleActive) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject hide like counts JS when enabled
|
||||
if (hideLikeCounts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject hide suggested posts JS when enabled
|
||||
if (hideSuggestedPosts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: ui_hider.kHideSuggestedPostsJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject hide sponsored posts JS when enabled
|
||||
if (hideSponsoredPosts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: ui_hider.kHideSponsoredPostsJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
|
||||
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
||||
if (disableReelsEntirely) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: content_disabling.kDmReelBlockerJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,18 @@ class NotificationService {
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const DarwinInitializationSettings initializationSettingsIOS =
|
||||
// Request permissions for iOS
|
||||
final DarwinInitializationSettings initializationSettingsIOS =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
defaultPresentAlert: true,
|
||||
defaultPresentBadge: true,
|
||||
defaultPresentSound: true,
|
||||
);
|
||||
|
||||
const InitializationSettings initializationSettings =
|
||||
final InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
android: initializationSettingsAndroid,
|
||||
iOS: initializationSettingsIOS,
|
||||
@@ -32,6 +36,34 @@ class NotificationService {
|
||||
// Handle notification tap
|
||||
},
|
||||
);
|
||||
|
||||
// Request permissions after initialization
|
||||
await _requestIOSPermissions();
|
||||
await _requestAndroidPermissions();
|
||||
}
|
||||
|
||||
Future<void> _requestIOSPermissions() async {
|
||||
try {
|
||||
await _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
} catch (e) {
|
||||
debugPrint('iOS permission request error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _requestAndroidPermissions() async {
|
||||
try {
|
||||
await _notificationsPlugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.requestNotificationsPermission();
|
||||
} catch (e) {
|
||||
debugPrint('Android permission request error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showNotification({
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Tracks total in-app screen time per day.
|
||||
///
|
||||
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
||||
/// {
|
||||
/// "2026-02-26": 3420, // seconds
|
||||
/// "2026-02-25": 1800
|
||||
/// }
|
||||
///
|
||||
/// All data stays on-device only.
|
||||
class ScreenTimeService extends ChangeNotifier {
|
||||
static const String prefKey = 'screen_time_data';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
Map<String, int> _secondsByDate = {};
|
||||
Timer? _ticker;
|
||||
bool _tracking = false;
|
||||
|
||||
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
final raw = _prefs?.getString(prefKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
_secondsByDate = {};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
_secondsByDate = decoded.map(
|
||||
(k, v) => MapEntry(k, (v as num).toInt()),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
_secondsByDate = {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
// Prune entries older than 30 days
|
||||
final now = DateTime.now();
|
||||
final cutoff = now.subtract(const Duration(days: 30));
|
||||
_secondsByDate.removeWhere((key, value) {
|
||||
try {
|
||||
final d = DateTime.parse(key);
|
||||
return d.isBefore(DateTime(cutoff.year, cutoff.month, cutoff.day));
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await _prefs?.setString(prefKey, jsonEncode(_secondsByDate));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _todayKey() {
|
||||
final now = DateTime.now();
|
||||
return '${now.year.toString().padLeft(4, '0')}-'
|
||||
'${now.month.toString().padLeft(2, '0')}-'
|
||||
'${now.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
void startTracking() {
|
||||
if (_tracking) return;
|
||||
_tracking = true;
|
||||
_ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!_tracking) return;
|
||||
final key = _todayKey();
|
||||
_secondsByDate[key] = (_secondsByDate[key] ?? 0) + 1;
|
||||
// Persist every 10 seconds to reduce writes.
|
||||
if (_secondsByDate[key]! % 10 == 0) {
|
||||
_save();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stopTracking() {
|
||||
if (!_tracking) return;
|
||||
_tracking = false;
|
||||
_save();
|
||||
}
|
||||
|
||||
Future<void> resetAll() async {
|
||||
_secondsByDate.clear();
|
||||
await _prefs?.remove(prefKey);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,19 +13,37 @@ class SettingsService extends ChangeNotifier {
|
||||
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
||||
static const _keyIsFirstRun = 'set_is_first_run';
|
||||
|
||||
// Granular Ghost Mode keys
|
||||
static const _keyGhostTyping = 'set_ghost_typing';
|
||||
static const _keyGhostSeen = 'set_ghost_seen';
|
||||
static const _keyGhostStories = 'set_ghost_stories';
|
||||
static const _keyGhostDmPhotos = 'set_ghost_dm_photos';
|
||||
// Focus / playback
|
||||
static const _keyBlockAutoplay = 'block_autoplay';
|
||||
|
||||
// Grayscale mode
|
||||
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
||||
static const _keyGrayscaleScheduleEnabled = 'grayscale_schedule_enabled';
|
||||
static const _keyGrayscaleScheduleTime = 'grayscale_schedule_time';
|
||||
|
||||
// Content filtering / UI hiding
|
||||
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
|
||||
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
|
||||
static const _keyHideLikeCounts = 'hide_like_counts';
|
||||
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
||||
static const _keyHideStoriesBar = 'hide_stories_bar';
|
||||
static const _keyHideExploreTab = 'hide_explore_tab';
|
||||
static const _keyHideReelsTab = 'hide_reels_tab';
|
||||
static const _keyHideShopTab = 'hide_shop_tab';
|
||||
|
||||
// Complete section disabling / Minimal mode
|
||||
static const _keyDisableReelsEntirely = 'disable_reels_entirely';
|
||||
static const _keyDisableExploreEntirely = 'disable_explore_entirely';
|
||||
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
||||
|
||||
// Reels History
|
||||
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
||||
|
||||
// Privacy keys
|
||||
static const _keySanitizeLinks = 'set_sanitize_links';
|
||||
static const _keyNotifyDMs = 'set_notify_dms';
|
||||
static const _keyNotifyActivity = 'set_notify_activity';
|
||||
|
||||
// Legacy key for migration
|
||||
static const _keyGhostModeLegacy = 'set_ghost_mode';
|
||||
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
@@ -38,16 +56,32 @@ class SettingsService extends ChangeNotifier {
|
||||
bool _showInstaSettings = true;
|
||||
bool _isDarkMode = true; // Default to dark as per existing app theme
|
||||
|
||||
// Granular Ghost Mode defaults (all on)
|
||||
bool _ghostTyping = true;
|
||||
bool _ghostSeen = true;
|
||||
bool _ghostStories = true;
|
||||
bool _ghostDmPhotos = true;
|
||||
bool _blockAutoplay = true;
|
||||
|
||||
// Privacy defaults
|
||||
bool _grayscaleEnabled = false;
|
||||
bool _grayscaleScheduleEnabled = false;
|
||||
String _grayscaleScheduleTime = '21:00'; // 9:00 PM default
|
||||
|
||||
bool _hideSuggestedPosts = false;
|
||||
bool _hideSponsoredPosts = false;
|
||||
bool _hideLikeCounts = false;
|
||||
bool _hideFollowerCounts = false;
|
||||
bool _hideStoriesBar = false;
|
||||
bool _hideExploreTab = false;
|
||||
bool _hideReelsTab = false;
|
||||
bool _hideShopTab = false;
|
||||
|
||||
bool _disableReelsEntirely = false;
|
||||
bool _disableExploreEntirely = false;
|
||||
bool _minimalModeEnabled = false;
|
||||
|
||||
bool _reelsHistoryEnabled = true;
|
||||
|
||||
// Privacy defaults - notifications OFF by default
|
||||
bool _sanitizeLinks = true;
|
||||
bool _notifyDMs = true;
|
||||
bool _notifyActivity = true;
|
||||
bool _notifyDMs = false;
|
||||
bool _notifyActivity = false;
|
||||
bool _notifySessionEnd = false;
|
||||
|
||||
List<String> _enabledTabs = [
|
||||
'Home',
|
||||
@@ -68,18 +102,49 @@ class SettingsService extends ChangeNotifier {
|
||||
List<String> get enabledTabs => _enabledTabs;
|
||||
bool get isFirstRun => _isFirstRun;
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
|
||||
// Granular Ghost Mode getters
|
||||
bool get ghostTyping => _ghostTyping;
|
||||
bool get ghostSeen => _ghostSeen;
|
||||
bool get ghostStories => _ghostStories;
|
||||
bool get ghostDmPhotos => _ghostDmPhotos;
|
||||
bool get blockAutoplay => _blockAutoplay;
|
||||
bool get notifyDMs => _notifyDMs;
|
||||
bool get notifyActivity => _notifyActivity;
|
||||
bool get notifySessionEnd => _notifySessionEnd;
|
||||
|
||||
/// True if ANY ghost mode setting is enabled (for injection logic).
|
||||
bool get anyGhostModeEnabled =>
|
||||
_ghostTyping || _ghostSeen || _ghostStories || _ghostDmPhotos;
|
||||
bool get grayscaleEnabled => _grayscaleEnabled;
|
||||
bool get grayscaleScheduleEnabled => _grayscaleScheduleEnabled;
|
||||
String get grayscaleScheduleTime => _grayscaleScheduleTime;
|
||||
|
||||
bool get hideSuggestedPosts => _hideSuggestedPosts;
|
||||
bool get hideSponsoredPosts => _hideSponsoredPosts;
|
||||
bool get hideLikeCounts => _hideLikeCounts;
|
||||
bool get hideFollowerCounts => _hideFollowerCounts;
|
||||
bool get hideStoriesBar => _hideStoriesBar;
|
||||
bool get hideExploreTab => _hideExploreTab;
|
||||
bool get hideReelsTab => _hideReelsTab;
|
||||
bool get hideShopTab => _hideShopTab;
|
||||
|
||||
bool get disableReelsEntirely => _disableReelsEntirely;
|
||||
bool get disableExploreEntirely => _disableExploreEntirely;
|
||||
bool get minimalModeEnabled => _minimalModeEnabled;
|
||||
|
||||
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
||||
|
||||
/// True if grayscale should currently be applied, considering the manual
|
||||
/// toggle and the optional schedule.
|
||||
bool get isGrayscaleActiveNow {
|
||||
if (_grayscaleEnabled) return true;
|
||||
if (!_grayscaleScheduleEnabled) return false;
|
||||
try {
|
||||
final parts = _grayscaleScheduleTime.split(':');
|
||||
if (parts.length != 2) return false;
|
||||
final h = int.parse(parts[0]);
|
||||
final m = int.parse(parts[1]);
|
||||
final now = DateTime.now();
|
||||
final currentMinutes = now.hour * 60 + now.minute;
|
||||
final startMinutes = h * 60 + m;
|
||||
// Active from the configured time until midnight.
|
||||
return currentMinutes >= startMinutes;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Privacy getters
|
||||
bool get sanitizeLinks => _sanitizeLinks;
|
||||
@@ -93,31 +158,34 @@ class SettingsService extends ChangeNotifier {
|
||||
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
||||
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
||||
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
|
||||
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
|
||||
|
||||
// Migrate legacy ghostMode key -> all granular keys
|
||||
final legacyGhostMode = _prefs!.getBool(_keyGhostModeLegacy);
|
||||
if (legacyGhostMode != null) {
|
||||
// Seed all four granular keys with the legacy value
|
||||
_ghostTyping = legacyGhostMode;
|
||||
_ghostSeen = legacyGhostMode;
|
||||
_ghostStories = legacyGhostMode;
|
||||
_ghostDmPhotos = legacyGhostMode;
|
||||
// Save granular keys and remove legacy key
|
||||
await _prefs!.setBool(_keyGhostTyping, legacyGhostMode);
|
||||
await _prefs!.setBool(_keyGhostSeen, legacyGhostMode);
|
||||
await _prefs!.setBool(_keyGhostStories, legacyGhostMode);
|
||||
await _prefs!.setBool(_keyGhostDmPhotos, legacyGhostMode);
|
||||
await _prefs!.remove(_keyGhostModeLegacy);
|
||||
} else {
|
||||
_ghostTyping = _prefs!.getBool(_keyGhostTyping) ?? true;
|
||||
_ghostSeen = _prefs!.getBool(_keyGhostSeen) ?? true;
|
||||
_ghostStories = _prefs!.getBool(_keyGhostStories) ?? true;
|
||||
_ghostDmPhotos = _prefs!.getBool(_keyGhostDmPhotos) ?? true;
|
||||
}
|
||||
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
|
||||
_grayscaleScheduleEnabled =
|
||||
_prefs!.getBool(_keyGrayscaleScheduleEnabled) ?? false;
|
||||
_grayscaleScheduleTime =
|
||||
_prefs!.getString(_keyGrayscaleScheduleTime) ?? '21:00';
|
||||
|
||||
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
||||
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
|
||||
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
||||
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
||||
_hideStoriesBar = _prefs!.getBool(_keyHideStoriesBar) ?? false;
|
||||
_hideExploreTab = _prefs!.getBool(_keyHideExploreTab) ?? false;
|
||||
_hideReelsTab = _prefs!.getBool(_keyHideReelsTab) ?? false;
|
||||
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
||||
|
||||
_disableReelsEntirely = _prefs!.getBool(_keyDisableReelsEntirely) ?? false;
|
||||
_disableExploreEntirely =
|
||||
_prefs!.getBool(_keyDisableExploreEntirely) ?? false;
|
||||
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
||||
|
||||
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
|
||||
|
||||
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
|
||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? true;
|
||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? true;
|
||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
||||
_notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false;
|
||||
|
||||
_enabledTabs =
|
||||
(_prefs!.getStringList(_keyEnabledTabs) ??
|
||||
@@ -179,6 +247,102 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBlockAutoplay(bool v) async {
|
||||
_blockAutoplay = v;
|
||||
await _prefs?.setBool(_keyBlockAutoplay, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleEnabled(bool v) async {
|
||||
_grayscaleEnabled = v;
|
||||
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleScheduleEnabled(bool v) async {
|
||||
_grayscaleScheduleEnabled = v;
|
||||
await _prefs?.setBool(_keyGrayscaleScheduleEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleScheduleTime(String hhmm) async {
|
||||
_grayscaleScheduleTime = hhmm;
|
||||
await _prefs?.setString(_keyGrayscaleScheduleTime, hhmm);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideSuggestedPosts(bool v) async {
|
||||
_hideSuggestedPosts = v;
|
||||
await _prefs?.setBool(_keyHideSuggestedPosts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideSponsoredPosts(bool v) async {
|
||||
_hideSponsoredPosts = v;
|
||||
await _prefs?.setBool(_keyHideSponsoredPosts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideLikeCounts(bool v) async {
|
||||
_hideLikeCounts = v;
|
||||
await _prefs?.setBool(_keyHideLikeCounts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideFollowerCounts(bool v) async {
|
||||
_hideFollowerCounts = v;
|
||||
await _prefs?.setBool(_keyHideFollowerCounts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideStoriesBar(bool v) async {
|
||||
_hideStoriesBar = v;
|
||||
await _prefs?.setBool(_keyHideStoriesBar, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideExploreTab(bool v) async {
|
||||
_hideExploreTab = v;
|
||||
await _prefs?.setBool(_keyHideExploreTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideReelsTab(bool v) async {
|
||||
_hideReelsTab = v;
|
||||
await _prefs?.setBool(_keyHideReelsTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideShopTab(bool v) async {
|
||||
_hideShopTab = v;
|
||||
await _prefs?.setBool(_keyHideShopTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setDisableReelsEntirely(bool v) async {
|
||||
_disableReelsEntirely = v;
|
||||
await _prefs?.setBool(_keyDisableReelsEntirely, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setDisableExploreEntirely(bool v) async {
|
||||
_disableExploreEntirely = v;
|
||||
await _prefs?.setBool(_keyDisableExploreEntirely, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setMinimalModeEnabled(bool v) async {
|
||||
_minimalModeEnabled = v;
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setReelsHistoryEnabled(bool v) async {
|
||||
_reelsHistoryEnabled = v;
|
||||
await _prefs?.setBool(_keyReelsHistoryEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setDarkMode(bool dark) {
|
||||
if (_isDarkMode != dark) {
|
||||
_isDarkMode = dark;
|
||||
@@ -186,31 +350,6 @@ class SettingsService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Granular Ghost Mode setters
|
||||
Future<void> setGhostTyping(bool v) async {
|
||||
_ghostTyping = v;
|
||||
await _prefs?.setBool(_keyGhostTyping, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGhostSeen(bool v) async {
|
||||
_ghostSeen = v;
|
||||
await _prefs?.setBool(_keyGhostSeen, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGhostStories(bool v) async {
|
||||
_ghostStories = v;
|
||||
await _prefs?.setBool(_keyGhostStories, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGhostDmPhotos(bool v) async {
|
||||
_ghostDmPhotos = v;
|
||||
await _prefs?.setBool(_keyGhostDmPhotos, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setSanitizeLinks(bool v) async {
|
||||
_sanitizeLinks = v;
|
||||
await _prefs?.setBool(_keySanitizeLinks, v);
|
||||
@@ -229,6 +368,12 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifySessionEnd(bool v) async {
|
||||
_notifySessionEnd = v;
|
||||
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> toggleTab(String tab) async {
|
||||
if (_enabledTabs.contains(tab)) {
|
||||
if (_enabledTabs.length > 1) {
|
||||
|
||||
Reference in New Issue
Block a user