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