JUst SAving Progress, i might fuck up

This commit is contained in:
Ujwal223
2026-05-23 11:56:23 +05:45
parent a504c51ac5
commit 4f63e784ac
17 changed files with 2220 additions and 0 deletions
+112
View File
@@ -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<Boolean>("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<InstagramWebView> {
// 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.
+66
View File
@@ -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 <application>:
#
# <activity
# android:name=".MainActivity"
# android:windowSoftInputMode="adjustResize"
# android:hardwareAccelerated="true" ← ADD THIS
# android:exported="true">
#
# Also add permissions:
# <uses-permission android:name="android.permission.INTERNET"/>
# <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
# android:maxSdkVersion="28"/>
# <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
# <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
# ── android/app/src/main/res/values/styles.xml ────────────────────────────
#
# Add to your launch theme for true edge-to-edge:
#
# <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
# <item name="android:statusBarColor">@android:color/transparent</item>
# <item name="android:navigationBarColor">@android:color/transparent</item>
# <item name="android:windowTranslucentStatus">false</item>
# <item name="android:windowTranslucentNavigation">false</item>
# <item name="android:enforceNavigationBarContrast">false</item> ← Android 10+
+100
View File
@@ -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 <span> or <div>
// 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: <a href="/ads/..."> 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,
});
})();
+83
View File
@@ -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();
}
};
})();
+63
View File
@@ -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<String, dynamic> event);
class ChannelRegistry {
final ActivityCallback? onActivityEvent;
const ChannelRegistry({this.onActivityEvent});
// ── Build all JavaScript channels ─────────────────────────────────────────
Set<JavaScriptChannel> build() {
return {
_ghostChannel(),
_themeChannel(),
_contentChannel(),
_activityChannel(),
};
}
// ─────────────────────────────────────────────────────────────────────────
JavaScriptChannel _ghostChannel() => JavaScriptChannel(
name: 'GhostChannel',
onMessageReceived: (msg) {
try {
final data = jsonDecode(msg.message) as Map<String, dynamic>;
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<String, dynamic>;
onActivityEvent?.call(data);
} catch (_) {}
},
);
}
+166
View File
@@ -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 <ul> 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' }));
}
})();
+226
View File
@@ -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;
})();
+179
View File
@@ -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' }));
}
})();
+283
View File
@@ -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');
})();
""";
+249
View File
@@ -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<void> 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<GhostFeatures> 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<void> 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<UserScript> 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<WebResourceResponse?> 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<void> 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<void> 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<void> 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}
}''';
}
}
+117
View File
@@ -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<InstagramWebView> createState() => InstagramWebViewState();
}
class InstagramWebViewState extends State<InstagramWebView> {
InAppWebViewController? _controller;
ScriptEngine? _engine;
bool _loading = true;
// ── Public API — call from Settings screen ────────────────────────────────
Future<void> toggleScript(ScriptId id, bool enabled) async {
await _engine?.toggle(id, enabled);
}
Future<void> setContentFlag(String flag, bool value) async {
await _engine?.setContentFlag(flag, value);
}
Future<void> 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,
),
],
);
}
}
+50
View File
@@ -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<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
final _webViewKey = GlobalKey<InstagramWebViewState>();
@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),
),
);
}
}
+138
View File
@@ -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<String, String> _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<void> 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<void> 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<void> 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<void> setContentFlag(String flag, bool value) async {
await prefs.setBool('content_$flag', value);
await _pushContentFlags();
}
Future<void> _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<void> 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<void> _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<String?> _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;
}
}
}
+105
View File
@@ -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<InstaScript> 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);
}
+74
View File
@@ -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<String, dynamic>;
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<void> 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;
}
}
+89
View File
@@ -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 <body> 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 <html>)
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 });
})();
+120
View File
@@ -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<ContentBlocker> 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',
},
);
}