diff --git a/.gitignore b/.gitignore
index faca3f5..949198e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,10 +12,9 @@
.swiftpm/
migrate_working_dir/
PRD.md
+.reasonix/
.agents/
-TODO.md
-v2/FOCUSGRAM_V2_PLAN.md
-v2/FocusGram_Feed_Filtering_Reference.docx
+
# IntelliJ related
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 035a394..3510bd7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,23 +1,23 @@
-## FocusGram 2.0.0
+## FocusGram 2.1.0
### What's new
-- NEW: Added Media Downloader for downloading images and videos
-- NEW: Added Ghost Mode
-- NEW: Added a toggle for scroll lock in minimal mode
-- NEW: Added Option to Choose Duration of Mindfulness Gate
-- NEW: Added ability to customize number of words in typing challenge
-- UPDATED: Redesigned Focus Control Flyout
-- UPDATED: Settings and Reordered items
-- UPDATED: Added more time Choices for reels session
-- UPDATED: Improved Permission Request invocation in onboarding page.
-- UPDATED: Improved Notification Alerts
-
+- NEW: Startup Page - choose which page to launch on app launch.
+- NEW: App lock and DM's Lock.
+- NEW: Bait me button in Focus Control.
+- NEW: Interactive Level based system for unlocking features.
+- NEW: Effort Friction Mode.
+- NEW: Strict and fully working Ghost Mode.
+- NEW: REDUCES the amount of ads in your feed (NO Toggles for this, mighn't work on some devices).
### Bug fixes
-- Fixed: back button on homepage didnt exit the app.
-- Fixed: Only First image of multiple imaged posts was blurred.
-- FIxed: Couldn't scroll the home feed after enabling minimal mode
+
+- Fixed: Greyscale mode used to turn off when app was restarted.
+- Fixed: Images in posts containing multiple images werent getting unblurred when tapped.
+- Fixed: Ghost mode didn't work properly.
+- Fixed: Reduced duplicate/spam notifications by improving notification bridge IDs.
+- Fixed: Download media button (rarely) opened random media rather than desired one.
+- Fixed: Reel Session could be started despite quota being finished.
- Perfomance Optimizations
-- A lof of other Minor fixes .
\ No newline at end of file
+- A lof of other Minor fixes .
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index ae5e115..a24ed93 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -63,11 +63,9 @@ android {
}
}
+ // Narrow exclusions to only the specific modules that cause conflicts,
+ // not entire Google/Firebase groups (which would block AdMob & Firebase).
configurations.all {
- exclude(group = "com.google.android.gms")
- exclude(group = "com.google.firebase")
- exclude(group = "com.google.android.datatransport")
- exclude(group = "com.google.android.play")
exclude(group = "com.google.android.play", module = "core")
exclude(group = "com.google.android.play", module = "core-common")
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4a38ec5..fcde977 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -61,6 +61,8 @@
+
+
diff --git a/android/gradle.properties b/android/gradle.properties
index b3764f0..b7532de 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx3G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
+# This builtInKotlin flag was added automatically by Flutter migrator
+android.builtInKotlin=false
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=false
diff --git a/assets/scripts/ghost_mode.js b/assets/scripts/ghost_mode.js
index 2b78da6..df10132 100644
--- a/assets/scripts/ghost_mode.js
+++ b/assets/scripts/ghost_mode.js
@@ -1,12 +1,36 @@
/**
- * FocusGram Ghost Mode
+ * FocusGram Ghost Mode (V2 Overlay)
* Injected at DOCUMENT_START — before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals.
+ *
+ * Uses _prev chain pattern: each section saves the PREVIOUS fetch/XHR
+ * before overriding, so they compose rather than conflict.
*/
(function () {
'use strict';
- // ─── Seen API patterns ────────────────────────────────────────────────────
+ // ─── First-interaction DM gate ──────────────────────────────────────────
+ // On /direct/*, first click blocks all api/graphql (inbox loads first).
+ window.__fgDirectApiBlocked = false;
+ document.addEventListener('click', function() {
+ if (window.location.pathname.indexOf('/direct/') === 0) window.__fgDirectApiBlocked = true;
+ }, true);
+ document.addEventListener('touchstart', function() {
+ if (window.location.pathname.indexOf('/direct/') === 0) window.__fgDirectApiBlocked = true;
+ }, true);
+ var _prevD = window.location.pathname.indexOf('/direct/') === 0;
+ setInterval(function() {
+ var now = window.location.pathname.indexOf('/direct/') === 0;
+ if (now !== _prevD) { _prevD = now; window.__fgDirectApiBlocked = false; }
+ }, 300);
+
+ function _blockIfNeeded(url) {
+ return window.__fgDirectApiBlocked &&
+ window.location.pathname.indexOf('/direct/') === 0 &&
+ url.indexOf('/api/graphql') !== -1;
+ }
+
+ // ─── SEEN + ACTIVITY patterns ───────────────────────────────────────────
const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//,
@@ -15,7 +39,6 @@
/\/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\//,
@@ -25,16 +48,9 @@
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) {
+ // ─── Fetch override — chains with whatever was there ──────────────────────
+ const _prevFetch = window.fetch;
+ window.fetch = async function (input, init) {
const url =
typeof input === 'string'
? input
@@ -42,17 +58,24 @@
? input.href
: input?.url ?? '';
- // Block seen
- if (isSeen(url)) {
- if (window.GhostChannel) {
- window.GhostChannel.postMessage(
- JSON.stringify({ type: 'seen_blocked', url })
- );
- }
- return fakeOkResponse();
+ // DM first-interaction gate
+ if (_blockIfNeeded(url)) {
+ return new Response(JSON.stringify({ status: 'ok' }), {
+ status: 200, headers: { 'Content-Type': 'application/json' }
+ });
}
- // Intercept activity for local history
+ // Seen pattern block
+ if (isSeen(url)) {
+ if (window.GhostChannel) {
+ window.GhostChannel.postMessage(JSON.stringify({ type: 'seen_blocked', url }));
+ }
+ return new Response(JSON.stringify({ status: 'ok' }), {
+ status: 200, headers: { 'Content-Type': 'application/json' }
+ });
+ }
+
+ // Activity interceptor for local history
if (isActivity(url) && window.ActivityChannel) {
const body = init?.body;
const bodyText =
@@ -66,51 +89,57 @@
);
}
- return _fetch(input, init);
+ return _prevFetch(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;
+ // ─── XHR override — chains ──────────────────────────────────────────────
+ const _prevOpen = XMLHttpRequest.prototype.open;
+ const _prevSend = 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);
+ return _prevOpen.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 url = this._fg_url || '';
+
+ // DM first-interaction gate
+ if (_blockIfNeeded(url)) {
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, 'responseText', { get: () => '{"status":"ok"}' });
+ Object.defineProperty(self, 'response', { get: () => '{"status":"ok"}' });
+ ['readystatechange', 'load'].forEach(function(t) {
+ try { self.dispatchEvent(new Event(t)); } catch(e) {}
});
- Object.defineProperty(self, 'response', {
- get: () => '{"status":"ok"}',
- });
- self.dispatchEvent(new Event('readystatechange'));
- self.dispatchEvent(new Event('load'));
- }, 10);
+ }, 5);
return;
}
- return _XHRSend.call(this, body);
+
+ // Seen pattern block
+ if (url && isSeen(url)) {
+ 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"}' });
+ ['readystatechange', 'load'].forEach(function(t) {
+ try { self.dispatchEvent(new Event(t)); } catch(e) {}
+ });
+ }, 5);
+ return;
+ }
+
+ return _prevSend.call(this, body);
};
- // ─── WebSocket intercept (message-seen via WS) ────────────────────────────
+ // ─── WebSocket intercept (message-seen via WS) ──────────────────────────
const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) {
@@ -119,7 +148,6 @@
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 (
@@ -130,7 +158,6 @@
return; // drop
}
} catch (_) {}
- // Text-based seen signal check
if (data.includes('"seen"') && data.includes('"thread_id"')) {
return;
}
@@ -141,7 +168,6 @@
return ws;
}
- // Preserve WebSocket prototype chain so IG's ws checks pass
PatchedWebSocket.prototype = _WS.prototype;
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
PatchedWebSocket.OPEN = _WS.OPEN;
@@ -149,24 +175,18 @@
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()')
+ // ─── Visibility trick — hide "Active Now" ──────────────────────────────
window.__fgEnableOnlineHide = function () {
Object.defineProperty(document, 'visibilityState', {
- get: () => 'hidden',
- configurable: true,
+ get: () => 'hidden', configurable: true,
});
Object.defineProperty(document, 'hidden', {
- get: () => true,
- configurable: true,
+ 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'));
diff --git a/decoded.jks b/decoded.jks
deleted file mode 100644
index e69de29..0000000
diff --git a/lib/features/preloader/instagram_preloader.dart b/lib/features/preloader/instagram_preloader.dart
index 741976f..13918ff 100644
--- a/lib/features/preloader/instagram_preloader.dart
+++ b/lib/features/preloader/instagram_preloader.dart
@@ -2,9 +2,10 @@ import 'dart:collection';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
-import '../../scripts/autoplay_blocker.dart';
import '../../scripts/spa_navigation_monitor.dart';
import '../../scripts/native_feel.dart';
+import '../../scripts/focus_scripts.dart';
+import '../../scripts/reel_metadata_extractor.dart';
class InstagramPreloader {
static HeadlessInAppWebView? _headlessWebView;
@@ -13,7 +14,7 @@ class InstagramPreloader {
static bool isReady = false;
static Future start(String userAgent) async {
- if (_headlessWebView != null) return; // don't start twice
+ if (_headlessWebView != null) return;
_headlessWebView = HeadlessInAppWebView(
keepAlive: keepAlive,
@@ -31,12 +32,10 @@ class InstagramPreloader {
safeBrowsingEnabled: false,
),
initialUserScripts: UnmodifiableListView([
+ // DM Ghost — comprehensive blocking, gated by window.__fgFullDmGhost flag.
+ // it should have worked, but sadly it didnt
UserScript(
- source: 'window.__fgBlockAutoplay = true;',
- injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
- ),
- UserScript(
- source: kAutoplayBlockerJS,
+ source: kFullDmGhostJS,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
@@ -47,6 +46,7 @@ class InstagramPreloader {
source: kNativeFeelingScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
+ // ReelMetadataExtractor removed — reel history feature deleted
]),
onWebViewCreated: (c) {
controller = c;
diff --git a/lib/features/reels_history/reels_history_service.dart b/lib/features/reels_history/reels_history_service.dart
index 6364902..92ee133 100644
--- a/lib/features/reels_history/reels_history_service.dart
+++ b/lib/features/reels_history/reels_history_service.dart
@@ -8,6 +8,8 @@ class ReelsHistoryEntry {
final String title;
final String thumbnailUrl;
final DateTime visitedAt;
+ final int durationSeconds; // How long the session lasted
+ final int adsWatchedInSession; // How many ads watched during this session
const ReelsHistoryEntry({
required this.id,
@@ -15,6 +17,8 @@ class ReelsHistoryEntry {
required this.title,
required this.thumbnailUrl,
required this.visitedAt,
+ this.durationSeconds = 0,
+ this.adsWatchedInSession = 0,
});
Map toJson() => {
@@ -23,6 +27,8 @@ class ReelsHistoryEntry {
'title': title,
'thumbnailUrl': thumbnailUrl,
'visitedAt': visitedAt.toUtc().toIso8601String(),
+ 'durationSeconds': durationSeconds,
+ 'adsWatchedInSession': adsWatchedInSession,
};
static ReelsHistoryEntry fromJson(Map json) {
@@ -34,6 +40,8 @@ class ReelsHistoryEntry {
visitedAt:
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
DateTime.now().toUtc(),
+ durationSeconds: (json['durationSeconds'] as num?)?.toInt() ?? 0,
+ adsWatchedInSession: (json['adsWatchedInSession'] as num?)?.toInt() ?? 0,
);
}
}
@@ -71,6 +79,8 @@ class ReelsHistoryService {
required String url,
required String title,
required String thumbnailUrl,
+ int durationSeconds = 0,
+ int adsWatchedInSession = 0,
}) async {
if (url.isEmpty) return;
final now = DateTime.now().toUtc();
@@ -89,6 +99,8 @@ class ReelsHistoryService {
title: title.isEmpty ? 'Instagram Reel' : title,
thumbnailUrl: thumbnailUrl,
visitedAt: now,
+ durationSeconds: durationSeconds,
+ adsWatchedInSession: adsWatchedInSession,
);
final updated = [entry, ...entries];
@@ -104,6 +116,44 @@ class ReelsHistoryService {
await _save(entries);
}
+ /// Get average reels watched per day in the last 7 days.
+ Future getWeeklyAverageReels() async {
+ final entries = await getEntries();
+ if (entries.isEmpty) return 0;
+
+ final now = DateTime.now();
+ final sevenDaysAgo = now.subtract(const Duration(days: 7));
+ final recent = entries.where((e) => e.visitedAt.isAfter(sevenDaysAgo)).toList();
+
+ if (recent.isEmpty) return 0;
+ return recent.length / 7.0;
+ }
+
+ /// Get reel counts grouped by day (for the level system).
+ Future