RELEASE: moved from beta to First stable release.

Check CHANGELOG.md for full changelog
This commit is contained in:
Ujwal
2026-02-27 04:14:40 +05:45
parent eecb823e62
commit 7992d65bc8
64 changed files with 6208 additions and 2752 deletions
+47 -741
View File
@@ -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}
''';
}
}
+505
View File
@@ -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
}
}
}
}
+37 -5
View File
@@ -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({
+107
View File
@@ -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();
}
}
+217 -72
View File
@@ -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) {