Progress SAve- downloader,blur,ghost mode(Partially) works

This commit is contained in:
Ujwal223
2026-05-25 18:00:57 +05:45
parent 4f63e784ac
commit 2d33dcb889
66 changed files with 6373 additions and 909 deletions
+1 -29
View File
@@ -1,41 +1,16 @@
// 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
// 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()
}
}
}
}
@@ -56,9 +31,6 @@ class MainActivity : FlutterActivity() {
// super.initState();
// _ghost = GhostModeService();
// _ghost.load().then((_) {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// _ghost.applyWindowFlags(context);
// });
// setState(() {});
// });
// }
+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();
}
};
})();
+304
View File
@@ -0,0 +1,304 @@
/**
* FocusGram Content Hider
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
* Flutter controls via window.__fgContent.*
* Injected at DOCUMENT_END.
*
* Key fixes applied:
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
* - Stories tray detection strengthened for fresh SPA navigations
* - Suggested posts detection uses multiple text-node matching strategies
*/
(function () {
'use strict';
if (window.__fgContent && window.__fgContent.__focusgramReady) {
return;
}
const STYLE_ID = 'fg-content-hider';
let hideStories = false;
let hidePosts = false;
let hideSuggested = false;
let hideReels = false;
// ─── CSS rules ─────────────────────────────────────────────────────────────
function buildCSS() {
const selectors = [];
if (hideStories) {
selectors.push(
'[role="list"]:has([aria-label*="tory"])',
'[role="listbox"]:has([aria-label*="tory"])',
'[role="menu"] > ul',
'section > div > div:first-child [style*="overflow"]',
'[role="list"] [style*="overflow"]',
);
}
if (hidePosts) {
selectors.push(
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
);
}
// hideReels CSS is intentionally NOT added here.
// We use DOM removal instead (see removeReels()) so that room is never left
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
return selectors.length
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
: '';
}
function applyCSS() {
if (document.body) {
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
}
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = buildCSS();
}
// ─── Story tray JS ─────────────────────────────────────────────────────────
function hideStoryTray() {
if (!hideStories) return;
// Strategy 1: <ul> children of a named list or menu
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
try {
const items = ul.querySelectorAll('li, button, a');
if (items.length < 2) return;
ul.style.setProperty('display', 'none', 'important');
} catch (_) {}
});
// Strategy 2: horizontally scrolling container with circle items
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
try {
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
if (cands.length < 2) return;
const s0 = window.getComputedStyle(cands[0]);
if (s0.width && parseFloat(s0.width) <= 90) {
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
}
} catch (_) {}
});
}
// ─── Suggested posts ───────────────────────────────────────────────────────
function removeSuggested() {
if (!hideSuggested) return;
var SIGNALS = [
'suggested for you',
'suggested posts',
'suggested reels',
'suggested',
'because you watched',
'because you follow',
'you might like',
'posts you might like',
'accounts you might like',
'recommendations',
];
function norm(s) {
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function hasSignal(s) {
var t = norm(s);
if (!t) return false;
return SIGNALS.some(function (signal) {
if (signal === 'suggested') return t === signal;
return t.indexOf(signal) >= 0;
});
}
function hideContainer(from) {
var parent = from;
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
var role = parent.getAttribute && parent.getAttribute('role');
var tag = parent.tagName;
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
if (
tag === 'ARTICLE' ||
tag === 'SECTION' ||
role === 'listitem' ||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
) {
parent.style.setProperty('display', 'none', 'important');
parent.setAttribute('data-fg-hidden-suggested', '1');
return true;
}
parent = parent.parentElement;
}
return false;
}
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
try {
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
var ownLabel = node.getAttribute('aria-label');
if (hasSignal(ownLabel)) { hideContainer(node); return; }
var text = norm(node.innerText || node.textContent || '');
if (
text.indexOf('suggested for you') >= 0 ||
text.indexOf('suggested posts') >= 0 ||
text.indexOf('suggested reels') >= 0 ||
text.indexOf('because you watched') >= 0 ||
text.indexOf('because you follow') >= 0
) {
hideContainer(node);
}
} catch (_) {}
});
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
try {
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
hideContainer(el);
}
} catch (_) {}
});
}
// ─── Reels DOM REMOVE (not display:none) ─────────────────────────────────
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
// gap cleanly and lets the feed flow naturally.
function removeReels() {
if (!hideReels) return;
var toRemove = [];
document.querySelectorAll('article').forEach(function (el) {
try {
// Fast path: check for a reel-signal attribute first
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
if (mt === '2') { toRemove.push(el); return; }
// Fallback: text-node scan for /reels/ markers
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var n;
while ((n = walker.nextNode())) {
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
toRemove.push(el); break;
}
}
} catch (_) {}
});
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
}
// ─── Public API ────────────────────────────────────────────────────────────
window.__fgContent = {
__focusgramReady: true,
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
setHideSuggested: function (val) {
hideSuggested = !!val;
applyCSS();
if (val) removeSuggested();
},
setHideReels: function (val) {
hideReels = !!val;
applyCSS();
if (val) removeReels();
},
applyAll: function (flags) {
hideStories = !!flags.stories;
hidePosts = !!flags.posts;
hideReels = !!flags.reels;
hideSuggested = !!flags.suggested;
applyCSS();
if (hideSuggested) removeSuggested();
if (hideStories) hideStoryTray();
if (hideReels) removeReels();
},
};
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
// pushState/replaceState don't fire any DOM event we can listen for.
// Hook the methods themselves so we know a navigation happened, then debounce
// re-apply. This also catches the case where the MutationObserver was on `body`
// and that node got replaced by Instagram's SPA re-render.
function scheduleReapply() {
clearTimeout(window.__fg_applyTimer);
window.__fg_applyTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 250);
}
var _origPush = history.pushState;
var _origReplace = history.replaceState;
history.pushState = function () {
_origPush.apply(this, arguments);
scheduleReapply();
};
history.replaceState = function () {
_origReplace.apply(this, arguments);
scheduleReapply();
};
// Reinforce on popstate too (user hits back/forward)
window.addEventListener('popstate', scheduleReapply, { passive: true });
// For pushState on the same URL (rare but possible) poll path briefly
window.addEventListener('pageshow', scheduleReapply, { passive: true });
window.addEventListener('focus', scheduleReapply, { passive: true });
// ─── MutationObserver ───────────────────────────────────────────────────────
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
// re-applies everything on each cycle. Does NOT guard on a per-element timer
// that would never re-fire after the body is replaced by SPA re-render.
if (!window.__fgContentObserver) {
window.__fgContentObserver = new MutationObserver(function () {
clearTimeout(window.__fg_moTimer);
window.__fg_moTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 300);
});
// `document.documentElement` survives SPA navigations (body gets replaced
// but <html> stays). Observing it catches both subtree mutations and, via
// the SPA heartbeat above, re-applies after pushState.
window.__fgContentObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// ─── Initial run ────────────────────────────────────────────────────────────
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
// Signal ready — Flutter will call applyAll() with stored prefs
if (window.ContentChannel) {
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+281
View File
@@ -0,0 +1,281 @@
/**
* 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,
blockGraphQLQueryWhenFeedPosts: 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;
const typename = String(node.__typename || '');
const reason = JSON.stringify({
reason: node.suggested_reason,
social_context: node.social_context,
title: node.title,
header: node.header,
label: node.label,
}).toLowerCase();
return !!(
node.is_suggested ||
node.is_suggested_for_you ||
node.is_recommendation ||
node.suggested_users ||
node.suggested_media ||
node.suggested_content ||
node.recommendation_source ||
typename.includes('Suggested') ||
typename.includes('Recommendation') ||
reason.includes('suggested') ||
reason.includes('recommend')
);
};
// 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')
))
);
};
const isFeedMediaNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.pk ||
node.id ||
node.code ||
node.media_type ||
node.image_versions2 ||
node.video_versions ||
node.carousel_media ||
node.__typename?.includes('Media') ||
node.__typename?.includes('Timeline')
);
};
// 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;
if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
return true;
}
// 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] }';
const _xhrOpen = XMLHttpRequest.prototype.open;
const _xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__fgUrl = typeof url === 'string' ? url : String(url || '');
return _xhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
if (
window.__fgFilterConfig.blockVideos &&
this.__fgUrl &&
(this.__fgUrl.includes('/api/v1/clips/') ||
this.__fgUrl.includes('/api/v1/discover/'))
) {
try { this.abort(); } catch (_) {}
return;
}
return _xhrSend.apply(this, arguments);
};
// 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;
})();
+207
View File
@@ -0,0 +1,207 @@
/**
* 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) ────────────────────────────
// Strict WS URL blocking (ghost mode requirement)
// sid/cid vary per user/chat; block by endpoint prefix, not exact query.
const isBlockedWssUrl = (u) => {
if (!u) return false;
const urlStr = String(u);
return (
urlStr.startsWith('wss://gateway.instagram.com/ws/streamcontroller') ||
urlStr.startsWith('wss://edge-chat.instagram.com/chat?sid=')
);
};
// Signal to other injected scripts that ghost-mode is active
window.__fgGhostModeActive = true;
const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) {
const urlStr = typeof url === 'string' ? url : url?.toString?.() ?? '';
// If the WebSocket URL is one of the blocked endpoints, return an inert WS-like object
if (isBlockedWssUrl(urlStr)) {
return {
send: () => {},
close: () => {},
readyState: 1,
addEventListener: () => {},
removeEventListener: () => {},
};
}
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' }));
}
})();
+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 });
})();
+31 -31
View File
@@ -23,41 +23,41 @@ class ChannelRegistry {
// ─────────────────────────────────────────────────────────────────────────
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 (_) {}
},
);
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);
},
);
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}');
},
);
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 (_) {}
},
);
name: 'ActivityChannel',
onMessageReceived: (msg) {
try {
final data = jsonDecode(msg.message) as Map<String, dynamic>;
onActivityEvent?.call(data);
} catch (_) {}
},
);
}
+38 -54
View File
@@ -3,7 +3,7 @@
// 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
// 3. FLAG_SECURE — anti-screenshot at OS level (disabled per user request)
//
// Usage:
// final service = GhostModeService();
@@ -15,8 +15,8 @@
// shouldInterceptRequest: service.shouldInterceptRequest,
// )
//
// // Anti-screenshot: call from initState after WidgetsBinding.instance.addPostFrameCallback
// service.applyWindowFlags(context);
// // Anti-screenshot: disabled per user request
// // service.applyWindowFlags(context);
import 'dart:typed_data';
@@ -36,55 +36,50 @@ class GhostFeatures {
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.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
this.disableAnalytics = true,
});
static const _keys = {
'hideStoryViews': 'gm_story',
'hideReadReceipts': 'gm_read',
'hideLiveJoin': 'gm_live',
'hideTypingIndicator': 'gm_typing',
'hideVoiceListened': 'gm_voice',
'hideStoryViews': 'gm_story',
'hideReadReceipts': 'gm_read',
'hideLiveJoin': 'gm_live',
'hideTypingIndicator': 'gm_typing',
'hideVoiceListened': 'gm_voice',
'hideReplyImageViewed': 'gm_reply',
'disableAnalytics': 'gm_analytics',
'antiScreenshot': 'gm_screenshot',
'disableAnalytics': 'gm_analytics',
};
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['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),
p.setBool(_keys['disableAnalytics']!, disableAnalytics),
]);
}
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,
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,
disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
);
}
}
@@ -110,23 +105,18 @@ final _nativeBlocklist = [
RegExp(r'/ajax/logging/'),
];
final Uint8List _fakeOkBody = Uint8List.fromList(
'{"status":"ok"}'.codeUnits,
);
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 ──────────────────────────────────────────────────────────
// ─── WebView setup ────────────────────────────────────────────────────────
/// Call from InAppWebView.onWebViewCreated
void onWebViewCreated(InAppWebViewController controller) {
@@ -170,34 +160,28 @@ class GhostModeService {
/// InAppWebViewSettings required for shouldInterceptRequest to fire
InAppWebViewSettings buildWebViewSettings() {
return InAppWebViewSettings(
useShouldInterceptRequest: true, // Enable native intercept callback
useShouldInterceptRequest: true, // Enable native intercept callback
useShouldOverrideUrlLoading: true,
javaScriptEnabled: true,
disableDefaultErrorPage: true,
useHybridComposition: true, // Needed for FLAG_SECURE to work
useHybridComposition:
true, // Needed for FLAG_SECURE to work (though disabled)
// Disable service worker cache that can replay seen-events offline
cacheEnabled: false, // Start clean — optional, tradeoff vs perf
cacheEnabled: false, // Start clean — optional, tradeoff vs perf
);
}
// ─── Anti-screenshot ────────────────────────────────────────────────────────
// Anti-screenshot disabled per user request
/// 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.');
}
// Anti-screenshot disabled per user request
return;
}
Future<void> clearWindowFlags() async {
try {
await _channel.invokeMethod('setSecure', {'secure': false});
} catch (_) {}
// Anti-screenshot disabled per user request
return;
}
// ─── Re-inject after page nav (SPA navigation doesn't re-run userScripts) ──
+60 -2
View File
@@ -6,6 +6,7 @@ import '../injection/script_engine.dart';
import '../injection/script_registry.dart';
import '../channels/channel_registry.dart';
import '../webview/webview_config.dart';
import '../services/ghost_mode_service.dart';
class InstagramWebView extends StatefulWidget {
const InstagramWebView({super.key});
@@ -17,9 +18,10 @@ class InstagramWebView extends StatefulWidget {
class InstagramWebViewState extends State<InstagramWebView> {
InAppWebViewController? _controller;
ScriptEngine? _engine;
GhostModeService? _ghostMode;
bool _loading = true;
// ── Public API — call from Settings screen ────────────────────────────────
// ── Public API — call from Settings screen ─────────────────────────────
Future<void> toggleScript(ScriptId id, bool enabled) async {
await _engine?.toggle(id, enabled);
}
@@ -32,6 +34,37 @@ class InstagramWebViewState extends State<InstagramWebView> {
await _engine?.setOnlineHide(enabled);
}
// Ghost mode controls
Future<void> setGhostModeEnabled(bool enabled) async {
if (_ghostMode != null) {
_ghostMode!.features.disableAnalytics = enabled;
_ghostMode!.features.hideStoryViews = enabled;
_ghostMode!.features.hideReadReceipts = enabled;
_ghostMode!.features.hideLiveJoin = enabled;
_ghostMode!.features.hideTypingIndicator = enabled;
_ghostMode!.features.hideVoiceListened = enabled;
_ghostMode!.features.hideReplyImageViewed = enabled;
await _ghostMode!.features.save();
// Reapply settings if webview exists
if (_controller != null) {
// Force reload to apply new settings
await _controller!.reload();
}
}
}
Future<void> setAntiScreenshot(bool enabled) async {
if (_ghostMode != null) {
_ghostMode!.features.antiScreenshot = enabled;
await _ghostMode!.features.save();
if (_ghostMode!.features.antiScreenshot) {
await _ghostMode!.applyWindowFlags(context);
} else {
await _ghostMode!.clearWindowFlags();
}
}
}
// ─────────────────────────────────────────────────────────────────────────
@override
@@ -40,12 +73,18 @@ class InstagramWebViewState extends State<InstagramWebView> {
children: [
InAppWebView(
initialUrlRequest: WebViewConfig.initialRequest,
initialSettings: WebViewConfig.settings,
initialSettings:
_ghostMode?.buildWebViewSettings() ?? WebViewConfig.settings,
// ── ContentBlockers — merged base + EasyList rules ──────────────
contentBlockers: WebViewConfig.baseContentBlockers,
// TODO Phase 1.5: merge EasyListParser.load() here at startup
// ── User Scripts — AT_DOCUMENT_START critical for ghost mode ─────
initialUserScripts: UnmodifiableListView(
_ghostMode?.buildUserScripts() ?? [],
),
// ── JavaScript channels ─────────────────────────────────────────
javascriptChannels: ChannelRegistry(
onActivityEvent: (event) {
@@ -56,6 +95,12 @@ class InstagramWebViewState extends State<InstagramWebView> {
onWebViewCreated: (controller) async {
_controller = controller;
// Initialize GhostModeService
_ghostMode = GhostModeService();
await _ghostMode!.load();
// Initialize existing script engine for other scripts
final prefs = await SharedPreferences.getInstance();
_engine = ScriptEngine(controller: controller, prefs: prefs);
@@ -66,6 +111,10 @@ class InstagramWebViewState extends State<InstagramWebView> {
onLoadStop: (controller, url) async {
// Inject DOCUMENT_END scripts
await _engine?.injectDocumentEndScripts();
// Re-inject ghost mode scripts on SPA navigation
await _ghostMode?.onPageLoaded(url?.uriValue);
setState(() => _loading = false);
},
@@ -103,6 +152,15 @@ class InstagramWebViewState extends State<InstagramWebView> {
await _engine?.injectDocumentEndScripts();
}
},
// ── Native intercept for service worker requests ────────────────
shouldInterceptRequest: (controller, request) async {
return await _ghostMode?.shouldInterceptRequest(
controller,
request,
) ??
null;
},
),
// ── Subtle loading indicator ──────────────────────────────────────
+8
View File
@@ -1,9 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'core/theme/system_ui_manager.dart';
import 'core/webview/instagram_webview.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Enable web contents debugging for ghost mode verification
if (kDebugMode) {
InAppWebViewController.setWebContentsDebuggingEnabled(true);
}
await SystemUiManager.enableEdgeToEdge();
runApp(const FocusGramApp());
}
+96 -4
View File
@@ -35,12 +35,36 @@ class ScriptEngine {
);
}
}
// Initialize script configurations after scripts are loaded
await _initializeScriptConfigs();
}
// ── Initialize script configurations from saved preferences ────────────────
Future<void> _initializeScriptConfigs() async {
// Fetch interceptor config
final fetchInterceptor = ScriptRegistry.byId(ScriptId.fetchInterceptor);
if (fetchInterceptor.enabled) {
await _updateFetchInterceptorConfig();
}
// Autoplay blocker config
final autoplayBlocker = ScriptRegistry.byId(ScriptId.autoplayBlocker);
if (autoplayBlocker.enabled) {
await _updateAutoplayBlockerConfig();
}
// Content hider flags
await _pushContentFlags();
}
// ── 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)) {
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
@@ -77,6 +101,9 @@ class ScriptEngine {
} else {
await _inject(script);
}
// Re-initialize configurations after toggle
await _initializeScriptConfigs();
}
// ── Content hider flags ────────────────────────────────────────────────────
@@ -100,15 +127,80 @@ class ScriptEngine {
);
}
// ── Fetch interceptor configuration ────────────────────────────────────────
Future<void> setFetchInterceptorConfig({
bool? blockAds,
bool? blockSponsored,
bool? blockSuggested,
bool? blockVideos,
bool? blockAutoplay,
}) async {
final prefs = await SharedPreferences.getInstance();
final config = {
'blockAds': blockAds ?? prefs.getBool('fetch_block_ads') ?? false,
'blockSponsored':
blockSponsored ?? prefs.getBool('fetch_block_sponsored') ?? false,
'blockSuggested':
blockSuggested ?? prefs.getBool('fetch_block_suggested') ?? false,
'blockVideos':
blockVideos ?? prefs.getBool('fetch_block_videos') ?? false,
'blockAutoplay':
blockAutoplay ?? prefs.getBool('fetch_block_autoplay') ?? false,
};
// Save individual prefs
await prefs.setBool('fetch_block_ads', config['blockAds']!);
await prefs.setBool('fetch_block_sponsored', config['blockSponsored']!);
await prefs.setBool('fetch_block_suggested', config['blockSuggested']!);
await prefs.setBool('fetch_block_videos', config['blockVideos']!);
await prefs.setBool('fetch_block_autoplay', config['blockAutoplay']!);
// Apply to webview
await controller.evaluateJavascript(
source: 'window.__fgSetFilterConfig?.(${jsonEncode(config)})',
);
}
Future<void> _updateFetchInterceptorConfig() async {
final prefs = await SharedPreferences.getInstance();
await setFetchInterceptorConfig(
blockAds: prefs.getBool('fetch_block_ads'),
blockSponsored: prefs.getBool('fetch_block_sponsored'),
blockSuggested: prefs.getBool('fetch_block_suggested'),
blockVideos: prefs.getBool('fetch_block_videos'),
blockAutoplay: prefs.getBool('fetch_block_autoplay'),
);
}
// ── Autoplay blocker configuration ─────────────────────────────────────────
Future<void> setAutoplayBlockerEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('autoplay_blocker_enabled', enabled);
// Apply to webview
await controller.evaluateJavascript(
source: 'window.__fgSetBlockAutoplay?.(${jsonEncode(enabled)})',
);
}
Future<void> _updateAutoplayBlockerConfig() async {
final prefs = await SharedPreferences.getInstance();
await setAutoplayBlockerEnabled(
prefs.getBool('autoplay_blocker_enabled') ?? false,
);
}
// ── Online status hide ─────────────────────────────────────────────────────
Future<void> setOnlineHide(bool enabled) async {
await prefs.setBool('ghost_online_hide', enabled);
if (enabled) {
await controller.evaluateJavascript(
source: 'window.__fgEnableOnlineHide?.()');
source: 'window.__fgEnableOnlineHide?.()',
);
} else {
await controller.evaluateJavascript(
source: 'window.__fgDisableOnlineHide?.()');
source: 'window.__fgDisableOnlineHide?.()',
);
}
}
+3 -21
View File
@@ -3,7 +3,6 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
enum ScriptId {
ghostMode,
themeDetector,
adBlockerDom,
contentHider,
fetchInterceptor,
autoplayBlocker,
@@ -32,18 +31,11 @@ class InstaScript {
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.',
name: 'Ad & Content Blocker',
description:
'Blocks ads, sponsored, suggested content, videos, and prevents autoplay via GraphQL interception.',
assetPath: 'assets/scripts/fetch_interceptor.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
@@ -66,14 +58,6 @@ class ScriptRegistry {
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',
@@ -100,6 +84,4 @@ class ScriptRegistry {
enabled: false,
),
];
static InstaScript byId(ScriptId id) => all.firstWhere((s) => s.id == id);
}
+25 -19
View File
@@ -8,7 +8,8 @@ class SystemUiManager {
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 bodyHex =
data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
final navHex = data['navHex'] as String? ?? bodyHex;
final bodyColor = _parseHex(bodyHex);
@@ -20,8 +21,9 @@ class SystemUiManager {
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
systemNavigationBarColor: navColor,
systemNavigationBarIconBrightness:
isDark ? Brightness.light : Brightness.dark,
systemNavigationBarIconBrightness: isDark
? Brightness.light
: Brightness.dark,
systemNavigationBarDividerColor: Colors.transparent,
),
);
@@ -33,25 +35,29 @@ class SystemUiManager {
// ── 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,
));
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,
));
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() ─────────────────────────────
+80 -83
View File
@@ -11,110 +11,107 @@ class WebViewConfig {
// ── Base InAppWebView settings ────────────────────────────────────────────
static InAppWebViewSettings get settings => InAppWebViewSettings(
// Identity
userAgent: userAgent,
// Identity
userAgent: userAgent,
// Performance
hardwareAcceleration: true,
// useHybridComposition: false breaks some Android 12+ devices — keep true
useHybridComposition: true,
cacheEnabled: true,
cacheMode: CacheMode.LOAD_DEFAULT,
// 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,
// 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,
// 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,
// 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
// 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,
// Disable Chrome custom tabs popup (links open in WebView)
suppressesIncrementalRendering: false,
// iOS specific
allowsBackForwardNavigationGestures: true,
allowsLinkPreview: false,
isFraudulentWebsiteWarningEnabled: false,
// iOS specific
allowsBackForwardNavigationGestures: true,
allowsLinkPreview: false,
isFraudulentWebsiteWarningEnabled: false,
// Android specific
forceDark: ForceDark.AUTO, // respect system dark mode
algorithmicDarkeningAllowed: true,
);
// 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.*'),
// 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\\..*'),
// 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.*'),
// 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.*'),
// 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.*'),
// 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\\/.*'),
];
// 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),
);
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',
},
);
url: WebUri(instagramUrl),
headers: {'Accept-Language': 'en-US,en;q=0.9', 'DNT': '1'},
);
}