mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-05-26 17:07:47 +02:00
Progress SAve- downloader,blur,ghost mode(Partially) works
This commit is contained in:
+1
-29
@@ -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(() {});
|
||||
// });
|
||||
// }
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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' }));
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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' }));
|
||||
}
|
||||
})();
|
||||
@@ -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
@@ -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
@@ -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) ──
|
||||
|
||||
@@ -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 ──────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user