From 4f63e784ac4ae3b979ef1ec1793247a5d0c1a546 Mon Sep 17 00:00:00 2001 From: Ujwal223 Date: Sat, 23 May 2026 11:56:23 +0545 Subject: [PATCH] JUst SAving Progress, i might fuck up --- v2/MainActivity.kt | 112 +++++++++++++++ v2/PHASE1_DEPS.yaml | 66 +++++++++ v2/ad_blocker_dom.js | 100 +++++++++++++ v2/autoplay_blocker.js | 83 +++++++++++ v2/channel_registry.dart | 63 +++++++++ v2/content_hider.js | 166 ++++++++++++++++++++++ v2/fetch_interceptor.js | 226 +++++++++++++++++++++++++++++ v2/ghost_mode.js | 179 +++++++++++++++++++++++ v2/ghost_mode_script.dart | 283 +++++++++++++++++++++++++++++++++++++ v2/ghost_mode_service.dart | 249 ++++++++++++++++++++++++++++++++ v2/instagram_webview.dart | 117 +++++++++++++++ v2/main.dart | 50 +++++++ v2/script_engine.dart | 138 ++++++++++++++++++ v2/script_registry.dart | 105 ++++++++++++++ v2/system_ui_manager.dart | 74 ++++++++++ v2/theme_detector.js | 89 ++++++++++++ v2/webview_config.dart | 120 ++++++++++++++++ 17 files changed, 2220 insertions(+) create mode 100644 v2/MainActivity.kt create mode 100644 v2/PHASE1_DEPS.yaml create mode 100644 v2/ad_blocker_dom.js create mode 100644 v2/autoplay_blocker.js create mode 100644 v2/channel_registry.dart create mode 100644 v2/content_hider.js create mode 100644 v2/fetch_interceptor.js create mode 100644 v2/ghost_mode.js create mode 100644 v2/ghost_mode_script.dart create mode 100644 v2/ghost_mode_service.dart create mode 100644 v2/instagram_webview.dart create mode 100644 v2/main.dart create mode 100644 v2/script_engine.dart create mode 100644 v2/script_registry.dart create mode 100644 v2/system_ui_manager.dart create mode 100644 v2/theme_detector.js create mode 100644 v2/webview_config.dart diff --git a/v2/MainActivity.kt b/v2/MainActivity.kt new file mode 100644 index 0000000..4e0a4c3 --- /dev/null +++ b/v2/MainActivity.kt @@ -0,0 +1,112 @@ +// android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt +// +// Adds: +// 1. Platform channel for FLAG_SECURE (anti-screenshot at OS level) +// 2. Ghost mode WebView integration notes + +package com.focusgram.focusgram + +import android.os.Bundle +import android.view.WindowManager +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + +class MainActivity : FlutterActivity() { + + private val CHANNEL = "com.focusgram/window_flags" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + CHANNEL + ).setMethodCallHandler { call, result -> + when (call.method) { + "setSecure" -> { + val secure = call.argument("secure") ?: false + if (secure) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + result.success(null) + } + else -> result.notImplemented() + } + } + } +} + + +// ───────────────────────────────────────────────────────────────────────────── +// WEBVIEW WIDGET INTEGRATION +// ───────────────────────────────────────────────────────────────────────────── +// +// In your WebView widget (wherever InAppWebView is constructed): +// +// class InstagramWebView extends StatefulWidget { ... } +// +// class _InstagramWebViewState extends State { +// late GhostModeService _ghost; +// +// @override +// void initState() { +// super.initState(); +// _ghost = GhostModeService(); +// _ghost.load().then((_) { +// WidgetsBinding.instance.addPostFrameCallback((_) { +// _ghost.applyWindowFlags(context); +// }); +// setState(() {}); +// }); +// } +// +// @override +// Widget build(BuildContext context) { +// return InAppWebView( +// initialUrlRequest: URLRequest( +// url: WebUri('https://www.instagram.com'), +// ), +// initialSettings: _ghost.buildWebViewSettings(), +// initialUserScripts: UnmodifiableListView(_ghost.buildUserScripts()), +// onWebViewCreated: (controller) { +// _ghost.onWebViewCreated(controller); +// }, +// onLoadStop: (controller, url) async { +// await _ghost.onPageLoaded(url?.uriValue); +// }, +// shouldInterceptRequest: (controller, request) { +// return _ghost.shouldInterceptRequest(controller, request); +// }, +// ); +// } +// } +// +// ───────────────────────────────────────────────────────────────────────────── +// PUBSPEC ADDITIONS +// ───────────────────────────────────────────────────────────────────────────── +// +// dependencies: +// flutter_inappwebview: ^6.1.5 # already present +// shared_preferences: ^2.3.0 +// +// ───────────────────────────────────────────────────────────────────────────── +// DEBUGGING: HOW TO VERIFY GHOST MODE WORKING +// ───────────────────────────────────────────────────────────────────────────── +// +// 1. Enable WebView remote debugging: +// In main.dart: if (kDebugMode) { InAppWebViewController.setWebContentsDebuggingEnabled(true); } +// +// 2. Open chrome://inspect in desktop Chrome while app runs on USB device. +// +// 3. In DevTools console, run: +// window.fetch('/api/v1/media/seen/test/', {method:'POST'}) +// .then(r => r.text()).then(console.log) +// → Should print: {"status":"ok"} (blocked, not sent) +// +// 4. Check Network tab — blocked requests should NOT appear (they resolve locally). +// +// 5. For story view test: open a Story, check Network tab for any request to +// /media/seen/ or /viewed_story/ — should be absent. diff --git a/v2/PHASE1_DEPS.yaml b/v2/PHASE1_DEPS.yaml new file mode 100644 index 0000000..20edbef --- /dev/null +++ b/v2/PHASE1_DEPS.yaml @@ -0,0 +1,66 @@ +# ── pubspec.yaml additions for FocusGram Phase 1 ────────────────────────── +# +# Merge these into your existing pubspec.yaml +# + +dependencies: + flutter: + sdk: flutter + + # WebView — already in project + flutter_inappwebview: ^6.1.5 + + # Persistence + shared_preferences: ^2.3.2 + sqflite: ^2.3.3+1 # Phase 2 history DB — add now, use later + path_provider: ^2.1.4 + + # Network (Phase 2 download manager — add now) + dio: ^5.7.0 + + # Gallery save (Phase 2) + gal: ^2.3.0 + + # Permissions (Phase 2) + permission_handler: ^11.3.1 + + +flutter: + assets: + - assets/scripts/ghost_mode.js + - assets/scripts/theme_detector.js + - assets/scripts/ad_blocker_dom.js + - assets/scripts/content_hider.js + - assets/scripts/media_detector.js # empty for now + - assets/scripts/history_tracker.js # empty for now + - assets/blocklists/easylist_mini.txt # Phase 1.5 — download and bundle + + +# ── AndroidManifest.xml additions ───────────────────────────────────────── +# +# In android/app/src/main/AndroidManifest.xml, inside : +# +# +# +# Also add permissions: +# +# +# +# + + +# ── android/app/src/main/res/values/styles.xml ──────────────────────────── +# +# Add to your launch theme for true edge-to-edge: +# +# shortEdges +# @android:color/transparent +# @android:color/transparent +# false +# false +# false ← Android 10+ diff --git a/v2/ad_blocker_dom.js b/v2/ad_blocker_dom.js new file mode 100644 index 0000000..611518c --- /dev/null +++ b/v2/ad_blocker_dom.js @@ -0,0 +1,100 @@ +/** + * FocusGram DOM Ad Blocker + * Removes sponsored posts, "Suggested for you" injections, and ad elements. + * Uses structure-based selectors — NOT class names (those change weekly). + * Injected at DOCUMENT_END. + */ +(function () { + 'use strict'; + + // ─── Sponsored text signals (Instagram localizes these) ─────────────────── + // We match the STRUCTURE not just English text. + // In IG mobile web, sponsored label appears as a or
+ // that is a direct sibling/child of the article header area. + const SPONSORED_TEXTS = new Set([ + 'sponsored', // en + 'gesponsert', // de + 'patrocinado', // es/pt + 'sponsorisé', // fr + 'sponsorizzato', // it + 'sponsrad', // sv + 'sponsoreret', // da + 'gesponsord', // nl + 'рекламa', // ru + 'विज्ञापन', // hi + '广告', // zh + 'ad', // en short + ]); + + const isSponsoredText = (text) => + SPONSORED_TEXTS.has(text.trim().toLowerCase()); + + // ─── Remove a single article element ────────────────────────────────────── + const removeArticle = (el) => { + // Walk up to find the article or main feed item container + const target = el.closest('article') ?? el.closest('div[data-media-id]') ?? el; + target.remove(); + }; + + // ─── Core ad scanner ────────────────────────────────────────────────────── + const scanAndRemove = () => { + // Strategy 1: inside feed + document.querySelectorAll('a[href*="/ads/"]').forEach((a) => { + a.closest('article')?.remove(); + }); + + // Strategy 2: Sponsored text in article spans + document.querySelectorAll('article').forEach((article) => { + const spans = article.querySelectorAll('span, div'); + for (const span of spans) { + if ( + span.children.length === 0 && // leaf node + isSponsoredText(span.textContent) + ) { + article.remove(); + return; + } + } + }); + + // Strategy 3: "Suggested for you" feed injections + document.querySelectorAll('article, section').forEach((el) => { + const firstText = el.querySelector('span, div, h4')?.textContent?.trim(); + if ( + firstText && + (firstText.toLowerCase().startsWith('suggested') || + firstText.toLowerCase().startsWith('you might') || + firstText.toLowerCase() === 'posts you might like') + ) { + el.remove(); + } + }); + + // Strategy 4: Instagram marks some ad containers with aria-label + document + .querySelectorAll('[aria-label*="Sponsored"], [aria-label*="Ad"]') + .forEach((el) => { + el.closest('article')?.remove(); + }); + + // Strategy 5: Tracking pixel iframes / hidden images + document.querySelectorAll('iframe[width="0"], iframe[height="0"]').forEach((el) => el.remove()); + document + .querySelectorAll('img[width="1"][height="1"], img[width="0"][height="0"]') + .forEach((el) => el.remove()); + }; + + // ─── Run on load + watch for new content ────────────────────────────────── + scanAndRemove(); + + const observer = new MutationObserver((mutations) => { + // Only scan if nodes were added (skip attribute/text changes) + const hasAdditions = mutations.some((m) => m.addedNodes.length > 0); + if (hasAdditions) scanAndRemove(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); +})(); diff --git a/v2/autoplay_blocker.js b/v2/autoplay_blocker.js new file mode 100644 index 0000000..6911383 --- /dev/null +++ b/v2/autoplay_blocker.js @@ -0,0 +1,83 @@ +/** + * FocusGram Autoplay Blocker + * Injected at DOCUMENT_START — before Instagram's JS loads. + * Prevents video autoplay by: + * 1. Blocking play() calls on video elements + * 2. Disabling autoplay attribute + * 3. Removing preload attributes + */ +(function () { + 'use strict'; + + window.__fgBlockAutoplay = false; + + // Override HTMLMediaElement.play() to check our flag + const _play = HTMLMediaElement.prototype.play; + HTMLMediaElement.prototype.play = function () { + if (window.__fgBlockAutoplay) { + // Return a resolved promise to avoid breaking Instagram's code + return Promise.resolve(); + } + return _play.call(this); + }; + + // Override autoplay property setter + const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {}; + const _originalAutoplaySetter = _videoDescriptor.set; + + Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', { + set: function (value) { + if (window.__fgBlockAutoplay && value) { + // Silently ignore autoplay attempts when blocking is enabled + return; + } + if (_originalAutoplaySetter) { + _originalAutoplaySetter.call(this, value); + } + }, + get: function () { + if (_videoDescriptor.get) { + return _videoDescriptor.get.call(this); + } + return this.getAttribute('autoplay') !== null; + }, + enumerable: _videoDescriptor.enumerable, + configurable: true, + }); + + // On page load and SPA navigation, scan for video elements and remove autoplay + const removeAutoplayFromVideos = () => { + document.querySelectorAll('video, [role="video"]').forEach(el => { + if (window.__fgBlockAutoplay) { + el.autoplay = false; + el.removeAttribute('autoplay'); + if (el.paused === false) { + el.pause(); + } + } + }); + }; + + // Run on load and when document changes + removeAutoplayFromVideos(); + + if (!window.__fgAutoplayObserver) { + let _timer = null; + window.__fgAutoplayObserver = new MutationObserver(() => { + clearTimeout(_timer); + _timer = setTimeout(removeAutoplayFromVideos, 500); + }); + window.__fgAutoplayObserver.observe(document.documentElement, { + childList: true, + subtree: true, + }); + } + + // Allow Flutter to toggle + window.__fgSetBlockAutoplay = function (enabled) { + window.__fgBlockAutoplay = !!enabled; + if (enabled) { + removeAutoplayFromVideos(); + } + }; +})(); diff --git a/v2/channel_registry.dart b/v2/channel_registry.dart new file mode 100644 index 0000000..df05b77 --- /dev/null +++ b/v2/channel_registry.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import '../theme/system_ui_manager.dart'; + +typedef ActivityCallback = void Function(Map event); + +class ChannelRegistry { + final ActivityCallback? onActivityEvent; + + const ChannelRegistry({this.onActivityEvent}); + + // ── Build all JavaScript channels ───────────────────────────────────────── + Set build() { + return { + _ghostChannel(), + _themeChannel(), + _contentChannel(), + _activityChannel(), + }; + } + + // ───────────────────────────────────────────────────────────────────────── + + JavaScriptChannel _ghostChannel() => JavaScriptChannel( + name: 'GhostChannel', + onMessageReceived: (msg) { + try { + final data = jsonDecode(msg.message) as Map; + if (kDebugMode) { + debugPrint('[Ghost] ${data['type']} — ${data['url'] ?? ''}'); + } + // In release: silent. Could surface to a debug overlay in dev builds. + } catch (_) {} + }, + ); + + JavaScriptChannel _themeChannel() => JavaScriptChannel( + name: 'ThemeChannel', + onMessageReceived: (msg) { + SystemUiManager.applyFromThemePayload(msg.message); + }, + ); + + JavaScriptChannel _contentChannel() => JavaScriptChannel( + name: 'ContentChannel', + onMessageReceived: (msg) { + // 'ready' signal — engine pushes flags back via evaluateJavascript + // handled in ScriptEngine.injectDocumentEndScripts() + if (kDebugMode) debugPrint('[Content] ${msg.message}'); + }, + ); + + JavaScriptChannel _activityChannel() => JavaScriptChannel( + name: 'ActivityChannel', + onMessageReceived: (msg) { + try { + final data = jsonDecode(msg.message) as Map; + onActivityEvent?.call(data); + } catch (_) {} + }, + ); +} diff --git a/v2/content_hider.js b/v2/content_hider.js new file mode 100644 index 0000000..6a698be --- /dev/null +++ b/v2/content_hider.js @@ -0,0 +1,166 @@ +/** + * FocusGram Content Hider + * Toggleable visibility for: stories tray, feed posts, suggested content. + * Flutter controls via window.__fgContent.* + * Injected at DOCUMENT_END. + * + * Improvements: + * - Better story tray detection using multiple strategies + * - Overlay for hidden feed content with loading indicator + * - Improved suggested posts detection + * - Fixed reels hiding to avoid blank feed issues + */ +(function () { + 'use strict'; + + const STYLE_ID = 'fg-content-hider'; + const OVERLAY_ID = 'fg-content-overlay'; + let hideStories = false; + let hidePosts = false; + let hideSuggested = false; + let hideReels = false; + + // ─── CSS rules ──────────────────────────────────────────────────────────── + + const buildCSS = () => { + let css = ''; + + if (hideStories) { + // Story tray: IG mobile web renders as a scrollable
    of circles + // near the top of the main feed. We target the outermost container + // by its scroll behaviour and presence of story-like items. + css += ` + /* Story tray */ + div[style*="overflow-x"] > ul, + div[role="menu"] > ul, + section > div > div:first-child ul[style*="scroll"] { + display: none !important; + } + `; + } + + if (hidePosts) { + // Feed articles — but NOT DM threads or profile pages + // Only apply on /, /reels/ — not /direct/ or /p/ or /@username/ + css += ` + /* Feed posts */ + main article { + display: none !important; + } + `; + } + + if (hideReels) { + css += ` + /* Reels in feed */ + article:has(video) { + display: none !important; + } + `; + } + + return css; + }; + + const applyCSS = () => { + let style = document.getElementById(STYLE_ID); + if (!style) { + style = document.createElement('style'); + style.id = STYLE_ID; + document.head.appendChild(style); + } + style.textContent = buildCSS(); + }; + + // ─── JS-based removal for suggested (CSS can't catch dynamic text) ──────── + + const removeSuggested = () => { + if (!hideSuggested) return; + document.querySelectorAll('article, section, div').forEach((el) => { + const firstLeaf = el.querySelector('span:not(:has(*)), h4'); + if (!firstLeaf) return; + const t = firstLeaf.textContent.trim().toLowerCase(); + if ( + t === 'suggested for you' || + t === 'you might like' || + t === 'suggested posts' || + t === 'posts you might like' + ) { + (el.closest('article') ?? el).remove(); + } + }); + }; + + // ─── Story tray JS fallback (for when CSS selector misses) ─────────────── + + const hideStoryTrayJS = () => { + if (!hideStories) return; + document.querySelectorAll('ul').forEach((ul) => { + const items = ul.querySelectorAll('li'); + if (items.length < 2) return; + // Story bubbles: li contains a button with a circular image + const first = items[0]; + const hasCircleImg = + first.querySelector('canvas') || + first.querySelector('img') || + first.querySelector('button'); + const isHorizontal = ul.scrollWidth > ul.clientWidth; + if (hasCircleImg && isHorizontal) { + ul.style.setProperty('display', 'none', 'important'); + } + }); + }; + + // ─── Public API — Flutter calls these via evaluateJavascript ───────────── + + window.__fgContent = { + setHideStories: (val) => { + hideStories = !!val; + applyCSS(); + hideStoryTrayJS(); + }, + setHidePosts: (val) => { + hidePosts = !!val; + applyCSS(); + }, + setHideReels: (val) => { + hideReels = !!val; + applyCSS(); + }, + setHideSuggested: (val) => { + hideSuggested = !!val; + if (val) removeSuggested(); + }, + applyAll: (flags) => { + hideStories = !!flags.stories; + hidePosts = !!flags.posts; + hideReels = !!flags.reels; + hideSuggested = !!flags.suggested; + applyCSS(); + if (hideSuggested) removeSuggested(); + if (hideStories) hideStoryTrayJS(); + }, + }; + + // ─── MutationObserver to re-apply on SPA navigation ────────────────────── + + let lastUrl = location.href; + const mo = new MutationObserver(() => { + if (location.href !== lastUrl) { + lastUrl = location.href; + setTimeout(() => { + applyCSS(); + if (hideSuggested) removeSuggested(); + if (hideStories) hideStoryTrayJS(); + }, 400); + } + if (hideSuggested) removeSuggested(); + }); + + mo.observe(document.body, { childList: true, subtree: true }); + + // Signal ready — Flutter will call applyAll() with stored prefs + if (window.ContentChannel) { + window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' })); + } +})(); diff --git a/v2/fetch_interceptor.js b/v2/fetch_interceptor.js new file mode 100644 index 0000000..3c30f80 --- /dev/null +++ b/v2/fetch_interceptor.js @@ -0,0 +1,226 @@ +/** + * FocusGram Unified Feed Filter via Fetch Interception + * Injected at DOCUMENT_START — before Instagram's JS loads. + * + * This script intercepts GraphQL fetch calls and filters feed content based on: + * - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style) + * - Sponsored posts (ad_action_link, ad_header_style) + * - Suggested posts (is_suggested, is_suggested_for_you, __typename) + * - Videos/Reels (is_video, media_type, clips_metadata) + * - Autoplay blocking (video autoplay prevention) + */ +(function () { + 'use strict'; + + // Configuration flags (set by Flutter via prefs) + window.__fgFilterConfig = { + blockAds: false, + blockSponsored: false, + blockSuggested: false, + blockVideos: false, + blockAutoplay: false, + }; + + // Helper: Check if a node is an ad + const isAdNode = (node) => { + if (!node || typeof node !== 'object') return false; + + return !!( + node.is_ad || + node.ad_action_link || + node.ad_id || + (node.product_type && node.product_type === 'ad') || + (node.ad_header_style && node.ad_header_style !== 'none') || + (node.__typename && node.__typename === 'GraphAdStory') + ); + }; + + // Helper: Check if a node is sponsored + const isSponsoredNode = (node) => { + if (!node || typeof node !== 'object') return false; + + return !!( + (node.ad_action_link && node.ad_action_link.href) || + (node.ad_header_style && node.ad_header_style !== 'none') + ); + }; + + // Helper: Check if a node is suggested content + const isSuggestedNode = (node) => { + if (!node || typeof node !== 'object') return false; + + return !!( + node.is_suggested || + node.is_suggested_for_you || + (node.__typename && node.__typename.includes('Suggested')) + ); + }; + + // Helper: Check if a node is a video/reel + const isVideoNode = (node) => { + if (!node || typeof node !== 'object') return false; + + return !!( + node.is_video || + (node.media_type === 2) || + node.clips_metadata || + (node.__typename && ( + node.__typename.includes('Clips') || + node.__typename.includes('Video') + )) + ); + }; + + // Helper: Check for media in carousel + const hasVideoInCarousel = (node) => { + if (!node || typeof node !== 'object') return false; + + if (node.media_type === 8) { + const edges = node.edge_sidecar_to_children?.edges || []; + return edges.some(edge => isVideoNode(edge.node)); + } + return false; + }; + + // Main filter function for feed nodes + const shouldFilterNode = (node) => { + const config = window.__fgFilterConfig; + + if (!node || typeof node !== 'object') return false; + + // Check ads + if (config.blockAds && isAdNode(node)) { + return true; + } + + // Check sponsored (separate from ads) + if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) { + return true; + } + + // Check suggested content + if (config.blockSuggested && isSuggestedNode(node)) { + return true; + } + + // Check videos/reels + if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) { + return true; + } + + return false; + }; + + // Recursively filter GraphQL response edges + const filterEdges = (edges, path = []) => { + if (!Array.isArray(edges)) return edges; + + return edges.filter(edge => { + if (!edge || !edge.node) return true; + const node = edge.node; + + // Keep the edge if it doesn't match any filter + if (!shouldFilterNode(node)) return true; + + // Log filtered content for debugging + if (window.__fgDebugFilter) { + const type = node.__typename || 'Unknown'; + console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`); + } + + return false; + }); + }; + + // Recursively walk GraphQL response and filter edges + const walkAndFilter = (obj, visited = new Set()) => { + if (!obj || typeof obj !== 'object' || visited.has(obj)) return; + visited.add(obj); + + // Handle arrays + if (Array.isArray(obj)) { + obj.forEach(item => walkAndFilter(item, visited)); + return; + } + + // Check for edges array (common GraphQL pattern) + if (obj.edges && Array.isArray(obj.edges)) { + obj.edges = filterEdges(obj.edges); + } + + // Recurse into children + for (const key in obj) { + if (obj.hasOwnProperty(key) && key !== '__typename') { + const val = obj[key]; + if (val && typeof val === 'object') { + walkAndFilter(val, visited); + } + } + } + }; + + // Override fetch + const _fetch = window.fetch.bind(window); + + window.fetch = async function (input, init) { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input?.url ?? ''; + + // Call original fetch + let response = await _fetch(input, init); + + // Only intercept GraphQL feed queries + if (!url.includes('/graphql/query') && !url.includes('/api/v1/feed')) { + return response; + } + + // Clone response to read body + const cloned = response.clone(); + + try { + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + return response; + } + + const data = await cloned.json(); + + // Filter the response data + walkAndFilter(data); + + // Return modified response + return new Response(JSON.stringify(data), { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch (e) { + // On error, return original response + return response; + } + }; + + // Preserve native function appearance + Object.defineProperty(window, 'fetch', { + value: window.fetch, + writable: true, + configurable: true, + }); + window.fetch.toString = () => 'function fetch() { [native code] }'; + + // Allow Flutter to update config flags + window.__fgSetFilterConfig = function (config) { + if (typeof config === 'object') { + Object.assign(window.__fgFilterConfig, config); + if (window.__fgDebugFilter) { + console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig); + } + } + }; + + // Enable debug logging + window.__fgDebugFilter = false; +})(); diff --git a/v2/ghost_mode.js b/v2/ghost_mode.js new file mode 100644 index 0000000..2b78da6 --- /dev/null +++ b/v2/ghost_mode.js @@ -0,0 +1,179 @@ +/** + * FocusGram Ghost Mode + * Injected at DOCUMENT_START — before Instagram's JS loads. + * Blocks story-seen, message-seen, and online-presence signals. + */ +(function () { + 'use strict'; + + // ─── Seen API patterns ──────────────────────────────────────────────────── + const SEEN_PATTERNS = [ + /\/api\/v1\/media\/[\w-]+\/seen\//, + /\/api\/v1\/stories\/reel\/seen\//, + /\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//, + /\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//, + /\/api\/v1\/live\/[\w-]+\/comment\/seen\//, + ]; + + // ─── Activity patterns (like, comment) — intercepted for local history ──── + const ACTIVITY_PATTERNS = [ + /\/api\/v1\/web\/likes\/[\w-]+\/like\//, + /\/api\/v1\/web\/comments\/add\//, + /\/api\/v1\/friendships\/[\w-]+\/follow\//, + ]; + + const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url)); + const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url)); + + const fakeOkResponse = () => + new Response(JSON.stringify({ status: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + // ─── Fetch override ─────────────────────────────────────────────────────── + const _fetch = window.fetch.bind(window); + + const patchedFetch = async function (input, init) { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input?.url ?? ''; + + // Block seen + if (isSeen(url)) { + if (window.GhostChannel) { + window.GhostChannel.postMessage( + JSON.stringify({ type: 'seen_blocked', url }) + ); + } + return fakeOkResponse(); + } + + // Intercept activity for local history + if (isActivity(url) && window.ActivityChannel) { + const body = init?.body; + const bodyText = + body instanceof URLSearchParams + ? body.toString() + : typeof body === 'string' + ? body + : ''; + window.ActivityChannel.postMessage( + JSON.stringify({ url, body: bodyText, timestamp: Date.now() }) + ); + } + + return _fetch(input, init); + }; + + // Disguise as native + Object.defineProperty(window, 'fetch', { + value: patchedFetch, + writable: true, + configurable: true, + enumerable: true, + }); + window.fetch.toString = () => 'function fetch() { [native code] }'; + window.fetch[Symbol.toStringTag] = 'fetch'; + + // ─── XMLHttpRequest override ────────────────────────────────────────────── + const _XHROpen = XMLHttpRequest.prototype.open; + const _XHRSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url, ...args) { + this._fg_url = url ?? ''; + this._fg_method = (method ?? '').toUpperCase(); + return _XHROpen.call(this, method, url, ...args); + }; + + XMLHttpRequest.prototype.send = function (body) { + if (this._fg_url && isSeen(this._fg_url)) { + // Fire readyState 4 with fake success without actually sending + const self = this; + setTimeout(() => { + Object.defineProperty(self, 'readyState', { get: () => 4 }); + Object.defineProperty(self, 'status', { get: () => 200 }); + Object.defineProperty(self, 'responseText', { + get: () => '{"status":"ok"}', + }); + Object.defineProperty(self, 'response', { + get: () => '{"status":"ok"}', + }); + self.dispatchEvent(new Event('readystatechange')); + self.dispatchEvent(new Event('load')); + }, 10); + return; + } + return _XHRSend.call(this, body); + }; + + // ─── WebSocket intercept (message-seen via WS) ──────────────────────────── + const _WS = window.WebSocket; + + function PatchedWebSocket(url, protocols) { + const ws = protocols ? new _WS(url, protocols) : new _WS(url); + const _send = ws.send.bind(ws); + + ws.send = function (data) { + if (typeof data === 'string') { + // IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version + try { + const parsed = JSON.parse(data); + if ( + parsed?.op === '4' || + parsed?.op === 'seen' || + (parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen') + ) { + return; // drop + } + } catch (_) {} + // Text-based seen signal check + if (data.includes('"seen"') && data.includes('"thread_id"')) { + return; + } + } + return _send(data); + }; + + return ws; + } + + // Preserve WebSocket prototype chain so IG's ws checks pass + PatchedWebSocket.prototype = _WS.prototype; + PatchedWebSocket.CONNECTING = _WS.CONNECTING; + PatchedWebSocket.OPEN = _WS.OPEN; + PatchedWebSocket.CLOSING = _WS.CLOSING; + PatchedWebSocket.CLOSED = _WS.CLOSED; + window.WebSocket = PatchedWebSocket; + + // ─── Visibility trick — hide "Active Now" ──────────────────────────────── + // Only applied if user enables online-status hiding + // Wrapped in a named fn so Flutter can call it: + // controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()') + window.__fgEnableOnlineHide = function () { + Object.defineProperty(document, 'visibilityState', { + get: () => 'hidden', + configurable: true, + }); + Object.defineProperty(document, 'hidden', { + get: () => true, + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }; + + window.__fgDisableOnlineHide = function () { + // Restore by deleting the overrides (falls back to native getter) + delete document.visibilityState; + delete document.hidden; + document.dispatchEvent(new Event('visibilitychange')); + }; + + // Signal to Flutter that ghost mode JS is active + if (window.GhostChannel) { + window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' })); + } +})(); diff --git a/v2/ghost_mode_script.dart b/v2/ghost_mode_script.dart new file mode 100644 index 0000000..3a25da2 --- /dev/null +++ b/v2/ghost_mode_script.dart @@ -0,0 +1,283 @@ +// lib/services/ghost_mode_script.dart +// Injected at AT_DOCUMENT_START — before Instagram's JS caches fetch/XHR refs + +const String kGhostModeJS = r""" +(function () { + 'use strict'; + + // ─── BLOCKED REST ENDPOINTS ─────────────────────────────────────────────── + // Patterns matched against full request URL + const URL_BLOCKLIST = [ + // Story viewed receipts + /\/api\/v1\/media\/seen\//, + /\/api\/v1\/feed\/viewed_story\//, + /\/api\/v1\/feed\/reels_tray\/seen\//, + + // DM read receipts (REST fallback path) + /\/api\/v1\/direct_v2\/threads\/[^/]+\/mark_item_seen\//, + /\/api\/v1\/direct_v2\/mark_item_seen\//, + + // Ephemeral photo/video reply viewed (Anti-Reply Image) + /\/api\/v1\/direct_v2\/threads\/[^/]+\/items\/[^/]+\/mark_visual_item_seen\//, + /\/api\/v1\/direct_v2\/visual_thread\/[^/]+\/seen\//, + + // Voice message listened receipt + /\/api\/v1\/direct_v2\/threads\/[^/]+\/items\/[^/]+\/mark_audio_seen\//, + + // Live join broadcast notification + /\/api\/v1\/live\/[^/]+\/join\//, + /\/api\/v1\/live\/[^/]+\/get_join_requests\//, + /\/api\/v1\/live\/[^/]+\/start_broadcast\//, + + // Analytics / tracking + /\/api\/v1\/qe\//, + /\/api\/v1\/launcher\/sync\//, + /\/api\/v1\/logging\//, + /\/api\/v1\/fb_onetap_logging\//, + /\/ajax\/bz/, + /\/ajax\/logging\//, + /\/api\/v1\/stats\//, + /\/api\/v1\/fbanalytics\//, + /\/api\/v1\/growth\/account_linked_now\//, + ]; + + // ─── BLOCKED GRAPHQL OPERATIONS ─────────────────────────────────────────── + // Instagram web uses GraphQL for many actions — match by operation name in body + const GRAPHQL_OP_BLOCKLIST = [ + // Story seen + 'MarkStorySeen', + 'markStorySeen', + 'ReelSeenMutation', + 'reel_seen', + 'IgFeedSeen', + + // DM read receipts + 'MarkDirectThreadItemSeen', + 'markDirectThreadItemSeen', + 'DirectMarkItemSeen', + 'DirectThreadMarkSeen', + + // Ephemeral media seen + 'MarkVisualMessageSeen', + 'DirectMarkVisualItemSeen', + + // Voice message listened + 'MarkAudioMessageSeen', + 'AudioSeenMutation', + + // Live join + 'LiveJoinBroadcast', + 'JoinLiveBroadcast', + 'MarkLiveViewer', + + // Analytics mutations + 'LogImpression', + 'LogClick', + 'FeedbackSeenMutation', + ]; + + // ─── HELPERS ────────────────────────────────────────────────────────────── + function shouldBlockUrl(url) { + if (!url) return false; + try { + const path = new URL(url, location.origin).pathname + new URL(url, location.origin).search; + return URL_BLOCKLIST.some(p => p.test(path)); + } catch { + return URL_BLOCKLIST.some(p => p.test(url)); + } + } + + function shouldBlockGraphQL(body) { + if (!body) return false; + let str = ''; + if (typeof body === 'string') { + str = body; + } else if (body instanceof URLSearchParams) { + str = body.toString(); + } + return GRAPHQL_OP_BLOCKLIST.some(op => str.includes(op)); + } + + function isGraphQLEndpoint(url) { + return url.includes('/graphql') || url.includes('/api/graphql'); + } + + function fakeOk(body) { + return new Response( + JSON.stringify(body || { status: 'ok', result: 'success' }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + // ─── FETCH INTERCEPT ────────────────────────────────────────────────────── + const _fetch = window.fetch; + window.fetch = async function (input, init) { + const url = + typeof input === 'string' + ? input + : input instanceof Request + ? input.url + : String(input); + + if (shouldBlockUrl(url)) { + return fakeOk(); + } + + // Clone body for GraphQL inspection without consuming it + if (isGraphQLEndpoint(url) && init) { + let bodyStr = ''; + if (typeof init.body === 'string') { + bodyStr = init.body; + } else if (init.body instanceof URLSearchParams) { + bodyStr = init.body.toString(); + } else if (init.body instanceof FormData) { + // FormData: iterate entries to build string + try { + init.body.forEach((v, k) => { bodyStr += k + '=' + v + '&'; }); + } catch {} + } + if (shouldBlockGraphQL(bodyStr)) { + return fakeOk(); + } + } + + return _fetch.apply(this, arguments); + }; + + // ─── XHR INTERCEPT ─────────────────────────────────────────────────────── + const _xhrOpen = XMLHttpRequest.prototype.open; + const _xhrSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this.__ghostUrl = url; + return _xhrOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + const url = this.__ghostUrl || ''; + + const blockByUrl = shouldBlockUrl(url); + const blockByOp = isGraphQLEndpoint(url) && shouldBlockGraphQL( + typeof body === 'string' ? body : '' + ); + + if (blockByUrl || blockByOp) { + const self = this; + // Must use defineProperty because readyState etc are read-only + Object.defineProperty(self, 'readyState', { get: () => 4, configurable: true }); + Object.defineProperty(self, 'status', { get: () => 200, configurable: true }); + Object.defineProperty(self, 'responseText', { + get: () => '{"status":"ok"}', + configurable: true, + }); + Object.defineProperty(self, 'response', { + get: () => '{"status":"ok"}', + configurable: true, + }); + setTimeout(() => { + try { self.onreadystatechange && self.onreadystatechange(); } catch {} + try { self.onload && self.onload(); } catch {} + // Fire events + ['readystatechange', 'load'].forEach(t => { + try { self.dispatchEvent(new Event(t)); } catch {} + }); + }, 10); + return; + } + + return _xhrSend.apply(this, arguments); + }; + + // ─── WEBSOCKET INTERCEPT (typing + live join) ───────────────────────────── + // Instagram uses MQTT over WebSocket for real-time events. + // Typing indicator = MQTT PUBLISH to topic containing typing/activity tokens. + // Live join viewer notification = MQTT PUBLISH with live topic. + const _OrigWS = window.WebSocket; + + function GhostWebSocket(url, protocols) { + const ws = protocols ? new _OrigWS(url, protocols) : new _OrigWS(url); + const _wsSend = ws.send.bind(ws); + + ws.send = function (data) { + if (data instanceof ArrayBuffer || data instanceof Uint8Array) { + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + + // MQTT packet type in top 4 bits of byte 0 + // PUBLISH = 0x3x (0x30 QoS0, 0x32 QoS1, 0x34 QoS2) + const packetType = bytes[0] & 0xF0; + if (packetType === 0x30) { + // Read remaining length (byte 1, simplified for short packets) + // MQTT topic starts at byte 4 (2 byte remaining-len + 2 byte topic-len) + try { + const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); + // Block typing / activity indicator publishes + if ( + decoded.includes('/t_fs') || // foreground state (typing) + decoded.includes('activity_indicator') || + decoded.includes('is_typing') || + decoded.includes('direct_typing') || + decoded.includes('/live/viewer') || // live join notification + decoded.includes('live_viewer_list') + ) { + return; // Drop packet silently + } + } catch {} + } + } else if (typeof data === 'string') { + // Some WS implementations send JSON + if ( + data.includes('typing') || + data.includes('live_viewer') || + data.includes('is_typing') + ) { + return; + } + } + + return _wsSend(data); + }; + + return ws; + } + + // Preserve static properties + GhostWebSocket.prototype = _OrigWS.prototype; + Object.assign(GhostWebSocket, { + CONNECTING: _OrigWS.CONNECTING, + OPEN: _OrigWS.OPEN, + CLOSING: _OrigWS.CLOSING, + CLOSED: _OrigWS.CLOSED, + }); + window.WebSocket = GhostWebSocket; + + // ─── KILL SERVICE WORKER ────────────────────────────────────────────────── + // SW runs in separate context — bypasses all JS intercepts above. + // Kill registration so our fetch/XHR overrides are the only intercept layer. + if ('serviceWorker' in navigator) { + // Block new registrations + navigator.serviceWorker.register = function () { + return Promise.reject(new Error('[GhostMode] SW blocked')); + }; + // Unregister any already registered + navigator.serviceWorker.getRegistrations().then(regs => { + regs.forEach(r => r.unregister()); + }).catch(() => {}); + } + + // ─── BEACON API BLOCK ──────────────────────────────────────────────────── + // Instagram uses sendBeacon for analytics on page unload + if (navigator.sendBeacon) { + navigator.sendBeacon = function (url) { + if (shouldBlockUrl(url)) return true; // Lie — say it succeeded + // Block all beacon calls to ig domains — analytics only + if (url.includes('instagram.com') || url.includes('facebook.com')) return true; + return false; + }; + } + + console.log('[FocusGram] GhostMode active'); +})(); +"""; diff --git a/v2/ghost_mode_service.dart b/v2/ghost_mode_service.dart new file mode 100644 index 0000000..802d257 --- /dev/null +++ b/v2/ghost_mode_service.dart @@ -0,0 +1,249 @@ +// lib/services/ghost_mode_service.dart +// +// Three-layer ghost mode: +// 1. AT_DOCUMENT_START JS injection — overrides fetch/XHR/WS before IG code runs +// 2. shouldInterceptRequest — native Android intercept (catches SW requests too) +// 3. FLAG_SECURE — anti-screenshot at OS level +// +// Usage: +// final service = GhostModeService(); +// await service.load(); // reads saved prefs +// +// InAppWebView( +// initialUserScripts: service.buildUserScripts(), +// onWebViewCreated: (c) => service.onWebViewCreated(c), +// shouldInterceptRequest: service.shouldInterceptRequest, +// ) +// +// // Anti-screenshot: call from initState after WidgetsBinding.instance.addPostFrameCallback +// service.applyWindowFlags(context); + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'ghost_mode_script.dart'; + +// ─── Feature flags ──────────────────────────────────────────────────────────── +class GhostFeatures { + bool hideStoryViews; + bool hideReadReceipts; + bool hideLiveJoin; + bool hideTypingIndicator; + bool hideVoiceListened; + bool hideReplyImageViewed; + bool disableAnalytics; + bool antiScreenshot; + + GhostFeatures({ + this.hideStoryViews = true, + this.hideReadReceipts = true, + this.hideLiveJoin = true, + this.hideTypingIndicator = true, + this.hideVoiceListened = true, + this.hideReplyImageViewed = true, + this.disableAnalytics = true, + this.antiScreenshot = false, // Off by default — user must opt in + }); + + static const _keys = { + 'hideStoryViews': 'gm_story', + 'hideReadReceipts': 'gm_read', + 'hideLiveJoin': 'gm_live', + 'hideTypingIndicator': 'gm_typing', + 'hideVoiceListened': 'gm_voice', + 'hideReplyImageViewed': 'gm_reply', + 'disableAnalytics': 'gm_analytics', + 'antiScreenshot': 'gm_screenshot', + }; + + Future save() async { + final p = await SharedPreferences.getInstance(); + await Future.wait([ + p.setBool(_keys['hideStoryViews']!, hideStoryViews), + p.setBool(_keys['hideReadReceipts']!, hideReadReceipts), + p.setBool(_keys['hideLiveJoin']!, hideLiveJoin), + p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator), + p.setBool(_keys['hideVoiceListened']!, hideVoiceListened), + p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed), + p.setBool(_keys['disableAnalytics']!, disableAnalytics), + p.setBool(_keys['antiScreenshot']!, antiScreenshot), + ]); + } + + static Future load() async { + final p = await SharedPreferences.getInstance(); + return GhostFeatures( + hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true, + hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true, + hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true, + hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true, + hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true, + hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true, + disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true, + antiScreenshot: p.getBool(_keys['antiScreenshot']!) ?? false, + ); + } +} + +// ─── Native URL blocklist (mirrors JS side — belt & suspenders) ─────────────── +final _nativeBlocklist = [ + RegExp(r'/api/v1/media/seen/'), + RegExp(r'/api/v1/feed/viewed_story/'), + RegExp(r'/api/v1/feed/reels_tray/seen/'), + RegExp(r'/api/v1/direct_v2/threads/[^/]+/mark_item_seen/'), + RegExp(r'/api/v1/direct_v2/mark_item_seen/'), + RegExp(r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_visual_item_seen/'), + RegExp(r'/api/v1/direct_v2/visual_thread/[^/]+/seen/'), + RegExp(r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_audio_seen/'), + RegExp(r'/api/v1/live/[^/]+/join/'), + RegExp(r'/api/v1/live/[^/]+/get_join_requests/'), + RegExp(r'/api/v1/qe/'), + RegExp(r'/api/v1/launcher/sync/'), + RegExp(r'/api/v1/logging/'), + RegExp(r'/api/v1/stats/'), + RegExp(r'/api/v1/fb_onetap_logging/'), + RegExp(r'/ajax/bz'), + RegExp(r'/ajax/logging/'), +]; + +final Uint8List _fakeOkBody = Uint8List.fromList( + '{"status":"ok"}'.codeUnits, +); + +// ─── Main service ───────────────────────────────────────────────────────────── +class GhostModeService { + GhostFeatures features = GhostFeatures(); + InAppWebViewController? _controller; + + // Platform channel for FLAG_SECURE (anti-screenshot) + static const _channel = MethodChannel('com.focusgram/window_flags'); + + Future load() async { + features = await GhostFeatures.load(); + } + + // ─── WebView setup ────────────────────────────────────────────────────────── + + /// Call from InAppWebView.onWebViewCreated + void onWebViewCreated(InAppWebViewController controller) { + _controller = controller; + } + + /// Pass to InAppWebView.initialUserScripts + /// AT_DOCUMENT_START = injected before ANY page script — critical for + /// overriding fetch/XHR before Instagram caches original refs. + List buildUserScripts() { + if (!_anyGhostEnabled()) return []; + return [ + UserScript( + source: _buildConfiguredScript(), + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + forMainFrameOnly: false, // Apply to iframes too + ), + ]; + } + + /// Pass to InAppWebView.shouldInterceptRequest + /// Works at native Android level — catches requests from service workers too. + Future shouldInterceptRequest( + InAppWebViewController controller, + WebResourceRequest request, + ) async { + if (!_anyGhostEnabled()) return null; + final path = request.url.path; + if (_nativeBlocklist.any((re) => re.hasMatch(path))) { + return WebResourceResponse( + statusCode: 200, + reasonPhrase: 'OK', + contentType: 'application/json', + headers: {'Content-Type': 'application/json'}, + data: _fakeOkBody, + ); + } + return null; // Let through + } + + /// InAppWebViewSettings required for shouldInterceptRequest to fire + InAppWebViewSettings buildWebViewSettings() { + return InAppWebViewSettings( + useShouldInterceptRequest: true, // Enable native intercept callback + useShouldOverrideUrlLoading: true, + javaScriptEnabled: true, + disableDefaultErrorPage: true, + useHybridComposition: true, // Needed for FLAG_SECURE to work + // Disable service worker cache that can replay seen-events offline + cacheEnabled: false, // Start clean — optional, tradeoff vs perf + ); + } + + // ─── Anti-screenshot ──────────────────────────────────────────────────────── + + /// Call from initState → addPostFrameCallback + Future applyWindowFlags(BuildContext context) async { + if (!features.antiScreenshot) return; + try { + await _channel.invokeMethod('setSecure', {'secure': true}); + } on MissingPluginException { + // Platform channel not registered — use plugin fallback below + debugPrint('[GhostMode] FLAG_SECURE: platform channel missing. ' + 'Add flutter_windowmanager or implement MainActivity channel.'); + } + } + + Future clearWindowFlags() async { + try { + await _channel.invokeMethod('setSecure', {'secure': false}); + } catch (_) {} + } + + // ─── Re-inject after page nav (SPA navigation doesn't re-run userScripts) ── + + /// Call from InAppWebView.onLoadStop + Future onPageLoaded(Uri? url) async { + if (_controller == null || !_anyGhostEnabled()) return; + // Re-inject on each navigation — SPA route changes don't re-fire userScripts + await _controller!.evaluateJavascript(source: _buildConfiguredScript()); + } + + // ─── Private helpers ──────────────────────────────────────────────────────── + + bool _anyGhostEnabled() => + features.hideStoryViews || + features.hideReadReceipts || + features.hideLiveJoin || + features.hideTypingIndicator || + features.hideVoiceListened || + features.hideReplyImageViewed || + features.disableAnalytics; + + /// Build JS with feature flags baked in — disabled features skip their blocks + String _buildConfiguredScript() { + // Prepend a config object that the script reads + // The kGhostModeJS already handles all features unconditionally. + // If you need per-feature toggles, swap the const for a builder function. + // + // For now: only inject if ghost mode is on at all. + // Per-feature granularity can be added by replacing URL_BLOCKLIST + // sections conditionally — left as extension point. + return ''' + window.__GHOST_CONFIG__ = ${_configJson()}; + $kGhostModeJS + '''; + } + + String _configJson() { + return '''{ + "hideStoryViews": ${features.hideStoryViews}, + "hideReadReceipts": ${features.hideReadReceipts}, + "hideLiveJoin": ${features.hideLiveJoin}, + "hideTypingIndicator": ${features.hideTypingIndicator}, + "hideVoiceListened": ${features.hideVoiceListened}, + "hideReplyImageViewed": ${features.hideReplyImageViewed}, + "disableAnalytics": ${features.disableAnalytics} + }'''; + } +} diff --git a/v2/instagram_webview.dart b/v2/instagram_webview.dart new file mode 100644 index 0000000..e186ff5 --- /dev/null +++ b/v2/instagram_webview.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../injection/script_engine.dart'; +import '../injection/script_registry.dart'; +import '../channels/channel_registry.dart'; +import '../webview/webview_config.dart'; + +class InstagramWebView extends StatefulWidget { + const InstagramWebView({super.key}); + + @override + State createState() => InstagramWebViewState(); +} + +class InstagramWebViewState extends State { + InAppWebViewController? _controller; + ScriptEngine? _engine; + bool _loading = true; + + // ── Public API — call from Settings screen ──────────────────────────────── + Future toggleScript(ScriptId id, bool enabled) async { + await _engine?.toggle(id, enabled); + } + + Future setContentFlag(String flag, bool value) async { + await _engine?.setContentFlag(flag, value); + } + + Future setOnlineHide(bool enabled) async { + await _engine?.setOnlineHide(enabled); + } + + // ───────────────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + InAppWebView( + initialUrlRequest: WebViewConfig.initialRequest, + initialSettings: WebViewConfig.settings, + + // ── ContentBlockers — merged base + EasyList rules ────────────── + contentBlockers: WebViewConfig.baseContentBlockers, + // TODO Phase 1.5: merge EasyListParser.load() here at startup + + // ── JavaScript channels ───────────────────────────────────────── + javascriptChannels: ChannelRegistry( + onActivityEvent: (event) { + // Forward to history DB in Phase 2 + debugPrint('[Activity] $event'); + }, + ).build(), + + onWebViewCreated: (controller) async { + _controller = controller; + final prefs = await SharedPreferences.getInstance(); + _engine = ScriptEngine(controller: controller, prefs: prefs); + + // Inject DOCUMENT_START scripts (ghost mode, etc.) + await _engine!.initDocumentStartScripts(); + }, + + onLoadStop: (controller, url) async { + // Inject DOCUMENT_END scripts + await _engine?.injectDocumentEndScripts(); + setState(() => _loading = false); + }, + + onLoadStart: (controller, url) { + setState(() => _loading = true); + }, + + onProgressChanged: (controller, progress) { + if (progress >= 80 && _loading) { + setState(() => _loading = false); + } + }, + + // ── Navigation policy ─────────────────────────────────────────── + shouldOverrideUrlLoading: (controller, navigationAction) async { + final url = navigationAction.request.url?.toString() ?? ''; + + // Block external redirects — keep user inside instagram.com + if (!url.contains('instagram.com') && + !url.contains('cdninstagram.com') && + !url.contains('fbcdn.net') && + url.startsWith('http')) { + // TODO: open in external browser via url_launcher + return NavigationActionPolicy.CANCEL; + } + + return NavigationActionPolicy.ALLOW; + }, + + // ── Re-inject on SPA navigation ───────────────────────────────── + // Instagram is a SPA — URL changes via pushState don't trigger + // onLoadStop. Re-inject DOM scripts on URL change. + onUpdateVisitedHistory: (controller, url, isReload) async { + if (!isReload!) { + await _engine?.injectDocumentEndScripts(); + } + }, + ), + + // ── Subtle loading indicator ────────────────────────────────────── + if (_loading) + const LinearProgressIndicator( + minHeight: 2, + backgroundColor: Colors.transparent, + ), + ], + ); + } +} diff --git a/v2/main.dart b/v2/main.dart new file mode 100644 index 0000000..39b7db7 --- /dev/null +++ b/v2/main.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'core/theme/system_ui_manager.dart'; +import 'core/webview/instagram_webview.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await SystemUiManager.enableEdgeToEdge(); + runApp(const FocusGramApp()); +} + +class FocusGramApp extends StatelessWidget { + const FocusGramApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'FocusGram', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0095F6)), + useMaterial3: true, + ), + home: const MainScreen(), + ); + } +} + +class MainScreen extends StatefulWidget { + const MainScreen({super.key}); + + @override + State createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + final _webViewKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Scaffold( + // backgroundColor transparent — lets WebView color bleed to system bars + backgroundColor: Colors.black, + body: SafeArea( + // bottom: false — let WebView extend behind nav bar for true edge-to-edge + bottom: false, + child: InstagramWebView(key: _webViewKey), + ), + ); + } +} diff --git a/v2/script_engine.dart b/v2/script_engine.dart new file mode 100644 index 0000000..b8b6733 --- /dev/null +++ b/v2/script_engine.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'script_registry.dart'; + +class ScriptEngine { + final InAppWebViewController controller; + final SharedPreferences prefs; + + // Cache raw JS per asset path to avoid repeated rootBundle reads + final Map _cache = {}; + + ScriptEngine({required this.controller, required this.prefs}); + + // ── Init: restore enabled state from prefs, inject DOCUMENT_START scripts ─ + // Call this from onWebViewCreated (for DOCUMENT_START scripts via addUserScript) + Future initDocumentStartScripts() async { + for (final script in ScriptRegistry.all) { + // Restore enabled state + final saved = prefs.getBool('script_${script.id.name}'); + if (saved != null) script.enabled = saved; + + if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START && + script.enabled) { + final code = await _load(script.assetPath); + if (code == null) continue; + await controller.addUserScript( + UserScript( + source: code, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + allowedOriginRules: {'https://www.instagram.com'}, + ), + ); + } + } + } + + // ── Called from onLoadStop: inject all DOCUMENT_END enabled scripts ──────── + Future injectDocumentEndScripts() async { + for (final script in ScriptRegistry.all + .where((s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END && s.enabled)) { + await _inject(script); + } + // After content_hider is injected, push saved content flags + await _pushContentFlags(); + } + + // ── Toggle a script on/off ───────────────────────────────────────────────── + Future toggle(ScriptId id, bool enabled) async { + final script = ScriptRegistry.byId(id); + script.enabled = enabled; + await prefs.setBool('script_${id.name}', enabled); + + if (!enabled) { + if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) { + await controller.removeUserScriptsByGroupName(id.name); + } + // For DOM scripts: reload so mutations stop + await controller.reload(); + return; + } + + if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) { + final code = await _load(script.assetPath); + if (code == null) return; + await controller.addUserScript( + UserScript( + source: code, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + groupName: id.name, + allowedOriginRules: {'https://www.instagram.com'}, + ), + ); + await controller.reload(); + } else { + await _inject(script); + } + } + + // ── Content hider flags ──────────────────────────────────────────────────── + Future setContentFlag(String flag, bool value) async { + await prefs.setBool('content_$flag', value); + await _pushContentFlags(); + } + + Future _pushContentFlags() async { + final contentHider = ScriptRegistry.byId(ScriptId.contentHider); + if (!contentHider.enabled) return; + + final flags = { + 'stories': prefs.getBool('content_stories') ?? false, + 'posts': prefs.getBool('content_posts') ?? false, + 'reels': prefs.getBool('content_reels') ?? false, + 'suggested': prefs.getBool('content_suggested') ?? false, + }; + await controller.evaluateJavascript( + source: 'window.__fgContent?.applyAll(${jsonEncode(flags)})', + ); + } + + // ── Online status hide ───────────────────────────────────────────────────── + Future setOnlineHide(bool enabled) async { + await prefs.setBool('ghost_online_hide', enabled); + if (enabled) { + await controller.evaluateJavascript( + source: 'window.__fgEnableOnlineHide?.()'); + } else { + await controller.evaluateJavascript( + source: 'window.__fgDisableOnlineHide?.()'); + } + } + + // ── Private helpers ──────────────────────────────────────────────────────── + Future _inject(InstaScript script) async { + final code = await _load(script.assetPath); + if (code == null) return; + try { + await controller.evaluateJavascript(source: code); + } catch (e) { + // Script failed — log but don't crash + debugPrint('[ScriptEngine] Failed to inject ${script.id.name}: $e'); + } + } + + Future _load(String assetPath) async { + if (_cache.containsKey(assetPath)) return _cache[assetPath]; + try { + final code = await rootBundle.loadString(assetPath); + _cache[assetPath] = code; + return code; + } catch (e) { + debugPrint('[ScriptEngine] Asset not found: $assetPath'); + return null; + } + } +} diff --git a/v2/script_registry.dart b/v2/script_registry.dart new file mode 100644 index 0000000..9cba347 --- /dev/null +++ b/v2/script_registry.dart @@ -0,0 +1,105 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +enum ScriptId { + ghostMode, + themeDetector, + adBlockerDom, + contentHider, + fetchInterceptor, + autoplayBlocker, + mediaDetector, + historyTracker, +} + +class InstaScript { + final ScriptId id; + final String name; + final String description; + final String assetPath; + final UserScriptInjectionTime injectionTime; + bool enabled; + + InstaScript({ + required this.id, + required this.name, + required this.description, + required this.assetPath, + this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END, + this.enabled = false, + }); +} + +class ScriptRegistry { + static final List all = [ + // ── DOCUMENT_START — must be before IG's JS loads ── + InstaScript( + id: ScriptId.ghostMode, + name: 'Ghost Mode', + description: 'Blocks story seen, message seen, and online status signals.', + assetPath: 'assets/scripts/ghost_mode.js', + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + enabled: false, + ), + InstaScript( + id: ScriptId.fetchInterceptor, + name: 'Fetch Interceptor', + description: 'Unified feed filter: blocks ads, sponsored, suggested, videos via GraphQL interception.', + assetPath: 'assets/scripts/fetch_interceptor.js', + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + enabled: false, + ), + InstaScript( + id: ScriptId.autoplayBlocker, + name: 'Autoplay Blocker', + description: 'Prevents video autoplay.', + assetPath: 'assets/scripts/autoplay_blocker.js', + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + enabled: false, + ), + + // ── DOCUMENT_END — DOM must be ready ── + InstaScript( + id: ScriptId.themeDetector, + name: 'Theme Detector', + description: 'Reads page colors and syncs system UI bars.', + assetPath: 'assets/scripts/theme_detector.js', + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END, + enabled: true, // always on — needed for native feel + ), + InstaScript( + id: ScriptId.adBlockerDom, + name: 'DOM Ad Blocker', + description: 'Removes sponsored posts and tracking elements from feed (legacy - use Fetch Interceptor instead).', + assetPath: 'assets/scripts/ad_blocker_dom.js', + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END, + enabled: false, + ), + InstaScript( + id: ScriptId.contentHider, + name: 'Content Hider', + description: 'Toggleable hide for stories, posts, reels, suggested.', + assetPath: 'assets/scripts/content_hider.js', + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END, + enabled: false, + ), + // Phase 2 scripts — registered but empty asset paths for now + InstaScript( + id: ScriptId.mediaDetector, + name: 'Media Downloader', + description: 'Injects download buttons on photos and reels.', + assetPath: 'assets/scripts/media_detector.js', + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END, + enabled: false, + ), + InstaScript( + id: ScriptId.historyTracker, + name: 'History Tracker', + description: 'Locally tracks reels watched and actions taken.', + assetPath: 'assets/scripts/history_tracker.js', + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END, + enabled: false, + ), + ]; + + static InstaScript byId(ScriptId id) => all.firstWhere((s) => s.id == id); +} diff --git a/v2/system_ui_manager.dart b/v2/system_ui_manager.dart new file mode 100644 index 0000000..6e9e3ac --- /dev/null +++ b/v2/system_ui_manager.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class SystemUiManager { + // ── Apply colors read from JS ThemeDetector ───────────────────────────── + static void applyFromThemePayload(String jsonPayload) { + try { + final data = jsonDecode(jsonPayload) as Map; + final isDark = data['isDark'] as bool? ?? false; + final bodyHex = data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff'); + final navHex = data['navHex'] as String? ?? bodyHex; + + final bodyColor = _parseHex(bodyHex); + final navColor = _parseHex(navHex); + + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarColor: bodyColor, + statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark, + statusBarBrightness: isDark ? Brightness.dark : Brightness.light, + systemNavigationBarColor: navColor, + systemNavigationBarIconBrightness: + isDark ? Brightness.light : Brightness.dark, + systemNavigationBarDividerColor: Colors.transparent, + ), + ); + } catch (_) { + // Fallback to safe defaults + applyLight(); + } + } + + // ── Fallback presets ───────────────────────────────────────────────────── + static void applyLight() { + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Color(0xFFFFFFFF), + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: Color(0xFFFFFFFF), + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarDividerColor: Colors.transparent, + )); + } + + static void applyDark() { + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Color(0xFF000000), + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemNavigationBarColor: Color(0xFF000000), + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarDividerColor: Colors.transparent, + )); + } + + // ── Edge-to-edge setup — call once in main() ───────────────────────────── + static Future enableEdgeToEdge() async { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + applyLight(); // default until theme detector fires + } + + // ───────────────────────────────────────────────────────────────────────── + static Color _parseHex(String hex) { + final clean = hex.replaceAll('#', ''); + if (clean.length == 6) { + return Color(int.parse('FF$clean', radix: 16)); + } else if (clean.length == 8) { + return Color(int.parse(clean, radix: 16)); + } + return Colors.white; + } +} diff --git a/v2/theme_detector.js b/v2/theme_detector.js new file mode 100644 index 0000000..54749c3 --- /dev/null +++ b/v2/theme_detector.js @@ -0,0 +1,89 @@ +/** + * FocusGram Theme Detector + * Reads Instagram's background + bottom nav color and reports to Flutter. + * Injected at DOCUMENT_END so DOM is ready. + */ +(function () { + 'use strict'; + + const parseRgb = (str) => { + // Parses "rgb(255, 255, 255)" or "rgba(0, 0, 0, 1)" → { r, g, b, a } + const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); + if (!m) return null; + return { + r: parseInt(m[1]), + g: parseInt(m[2]), + b: parseInt(m[3]), + a: m[4] !== undefined ? parseFloat(m[4]) : 1, + }; + }; + + const toHex = ({ r, g, b }) => + '#' + + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join(''); + + const detectColors = () => { + // Background — Instagram sets it on or a root div + const bodyBg = getComputedStyle(document.body).backgroundColor; + + // Bottom nav — IG mobile web renders a fixed bottom bar + // Target by role="navigation" or position:fixed at bottom + let navBg = bodyBg; + const navCandidates = document.querySelectorAll( + 'nav, [role="navigation"], div[style*="bottom"]' + ); + for (const el of navCandidates) { + const style = getComputedStyle(el); + if ( + style.position === 'fixed' && + parseInt(style.bottom) <= 10 && + style.backgroundColor !== 'rgba(0, 0, 0, 0)' + ) { + navBg = style.backgroundColor; + break; + } + } + + const bodyColor = parseRgb(bodyBg); + const navColor = parseRgb(navBg); + + if (!bodyColor) return; + + // Determine dark/light + const luminance = (0.299 * bodyColor.r + 0.587 * bodyColor.g + 0.114 * bodyColor.b) / 255; + const isDark = luminance < 0.5; + + const payload = { + bodyHex: toHex(bodyColor), + navHex: navColor ? toHex(navColor) : toHex(bodyColor), + isDark, + }; + + if (window.ThemeChannel) { + window.ThemeChannel.postMessage(JSON.stringify(payload)); + } + }; + + // Run on load + detectColors(); + + // Watch for Instagram's dark mode toggle (adds/removes class on ) + const observer = new MutationObserver(detectColors); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'style', 'color-scheme'], + }); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'style'], + }); + + // Also run after navigation (Instagram is SPA, URL changes without reload) + let lastUrl = location.href; + new MutationObserver(() => { + if (location.href !== lastUrl) { + lastUrl = location.href; + setTimeout(detectColors, 300); // small delay for IG to render new page + } + }).observe(document.body, { childList: true, subtree: true }); +})(); diff --git a/v2/webview_config.dart b/v2/webview_config.dart new file mode 100644 index 0000000..3dc7998 --- /dev/null +++ b/v2/webview_config.dart @@ -0,0 +1,120 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +class WebViewConfig { + // ── User agent — exactly as user specified ──────────────────────────────── + static const String userAgent = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + 'Version/26.0 Mobile/15E148 Safari/604.1'; + + static const String instagramUrl = 'https://www.instagram.com/'; + + // ── Base InAppWebView settings ──────────────────────────────────────────── + static InAppWebViewSettings get settings => InAppWebViewSettings( + // Identity + userAgent: userAgent, + + // Performance + hardwareAcceleration: true, + // useHybridComposition: false breaks some Android 12+ devices — keep true + useHybridComposition: true, + cacheEnabled: true, + cacheMode: CacheMode.LOAD_DEFAULT, + + // Media + mediaPlaybackRequiresUserGesture: false, + allowsInlineMediaPlayback: true, + allowsPictureInPictureMediaPlayback: true, + + // UX — feel like native, not browser + overScrollMode: OverScrollMode.NEVER, + verticalScrollBarEnabled: false, + horizontalScrollBarEnabled: false, + supportZoom: false, + builtInZoomControls: false, + displayZoomControls: false, + scrollsToTop: true, + + // JS & storage — IG needs all of these + javaScriptEnabled: true, + javaScriptCanOpenWindowsAutomatically: false, + domStorageEnabled: true, + databaseEnabled: true, + allowFileAccessFromFileURLs: false, + allowUniversalAccessFromFileURLs: false, + + // Compat + mixedContentMode: MixedContentMode.COMPATIBILITY_MODE, + safeBrowsingEnabled: false, // IG known-safe domain, no need for extra latency + + // Disable Chrome custom tabs popup (links open in WebView) + suppressesIncrementalRendering: false, + + // iOS specific + allowsBackForwardNavigationGestures: true, + allowsLinkPreview: false, + isFraudulentWebsiteWarningEnabled: false, + + // Android specific + forceDark: ForceDark.AUTO, // respect system dark mode + algorithmicDarkeningAllowed: true, + ); + + // ── ContentBlocker rules — ad network blocking ───────────────────────── + // These are baked-in rules targeting known ad/tracking domains. + // Full EasyList parsing is handled separately and merged at runtime. + // This set is always-on regardless of user toggle. + static List get baseContentBlockers => [ + // Meta ad infrastructure + _block('.*connect\\.facebook\\.net.*'), + _block('.*graph\\.facebook\\.com.*ads.*'), + _block('.*an\\.facebook\\.com.*'), + + // Google ad networks + _block('.*doubleclick\\.net.*'), + _block('.*googleadservices\\.com.*'), + _block('.*googlesyndication\\.com.*'), + _block('.*adservice\\.google\\..*'), + + // Common trackers + _block('.*scorecardresearch\\.com.*'), + _block('.*quantserve\\.com.*'), + _block('.*chartbeat\\.com.*'), + _block('.*newrelic\\.com.*'), + + // Ad servers + _block('.*ads\\.yahoo\\.com.*'), + _block('.*advertising\\.com.*'), + _block('.*adnxs\\.com.*'), + _block('.*adsrvr\\.org.*'), + _block('.*taboola\\.com.*'), + _block('.*outbrain\\.com.*'), + _block('.*pubmatic\\.com.*'), + _block('.*rubiconproject\\.com.*'), + _block('.*openx\\.net.*'), + _block('.*casalemedia\\.com.*'), + _block('.*criteo\\.com.*'), + _block('.*criteo\\.net.*'), + + // Pixel trackers + _block('.*pixel\\.quantserve\\.com.*'), + _block('.*pixel\\.facebook\\.com.*'), + + // IG-specific ad endpoints (safe to block — don't affect core IG) + _block('.*\\.instagram\\.com.*\\/ads\\/.*'), + ]; + + static ContentBlocker _block(String pattern) => ContentBlocker( + trigger: ContentBlockerTrigger(urlFilter: pattern), + action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK), + ); + + // ── URLRequest for initial load ─────────────────────────────────────────── + static URLRequest get initialRequest => URLRequest( + url: WebUri(instagramUrl), + headers: { + 'Accept-Language': 'en-US,en;q=0.9', + 'DNT': '1', + }, + ); +}