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

This commit is contained in:
Ujwal223
2026-05-25 18:00:57 +05:45
parent 4f63e784ac
commit 2d33dcb889
66 changed files with 6373 additions and 909 deletions
-45
View File
@@ -1,45 +0,0 @@
Categories:
- Connectivity
- Social Network
License: AGPL-3.0-only
AuthorName: Ujwal Chapagain
AuthorEmail: notujwal@proton.me
SourceCode: https://github.com/Ujwal223/FocusGram
IssueTracker: https://github.com/Ujwal223/FocusGram/issues
Changelog: https://github.com/Ujwal223/FocusGram/blob/main/CHANGELOG.md
AutoName: FocusGram
RepoType: git
Repo: https://github.com/Ujwal223/FocusGram
Builds:
- versionName: 1.0.0
versionCode: 3
commit: v1.0.0
output: build/app/outputs/flutter-apk/app-release.apk
srclibs:
- flutter@stable
prebuild:
- flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release.yml)
- '[[ $flutterVersion ]]'
- git -C $$flutter$$ checkout -f $flutterVersion
- export PUB_CACHE=$(pwd)/.pub-cache
- .flutter/bin/flutter config --no-analytics
- .flutter/bin/flutter pub get
scanignore:
- .flutter/bin/cache
scandelete:
- .flutter
- .pub-cache
build:
- export PUB_CACHE=$(pwd)/.pub-cache
- .flutter/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"
AutoUpdateMode: Version
UpdateCheckMode: Tags
VercodeOperation:
- '%c * 10 + 1'
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
CurrentVersion: 1.0.0
CurrentVersionCode: 3
+3 -1
View File
@@ -14,6 +14,9 @@ migrate_working_dir/
PRD.md PRD.md
.agents/ .agents/
TODO.md TODO.md
v2/FOCUSGRAM_V2_PLAN.md
v2/FocusGram_Feed_Filtering_Reference.docx
.codex
# IntelliJ related # IntelliJ related
*.iml *.iml
@@ -27,7 +30,6 @@ TODO.md
#.vscode/ #.vscode/
RELEASE_GUIDE.md RELEASE_GUIDE.md
android/key.properties android/key.properties
android/fdroid-config.properties
android/app/*.jks android/app/*.jks
upload-keystore.jks upload-keystore.jks
+4
View File
@@ -9,6 +9,10 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- v2/**
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+190
View File
@@ -0,0 +1,190 @@
Copyright (c) 2005-2014, The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
File diff suppressed because one or more lines are too long
+61
View File
@@ -0,0 +1,61 @@
/**
* FocusGram DOM Ad Blocker (Fallback)
*
* DEPRECATED: Use fetch_interceptor.js for reliable ad blocking.
*
* This script provides DOM-based ad removal as a FALLBACK for ads that slip through
* GraphQL filtering. It's not reliable because Instagram has already rendered the content.
*
* Injected at DOCUMENT_END
* Removes sponsored/posts/tracking elements from the DOM.
*/
(function () {
'use strict';
const AD_SIGNALS = [
'Sponsored',
'paid partnership',
'Promoted',
];
const textMatchesSignal = (txt) => {
if (!txt) return false;
const t = txt.trim().toLowerCase();
return AD_SIGNALS.some((s) => t === s.toLowerCase());
};
const removeSponsoredArticles = () => {
try {
// aria-label routes (best-effort; localization may break)
document.querySelectorAll('a[aria-label]').forEach((a) => {
const aria = a.getAttribute('aria-label') || '';
if (textMatchesSignal(aria)) {
const article = a.closest('article');
if (article) article.remove();
}
});
// Text-based removal inside feed articles (best-effort)
document.querySelectorAll('article').forEach((article) => {
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const txt = node.nodeValue;
if (textMatchesSignal(txt)) {
article.remove();
break;
}
}
});
// Suggested content is intentionally left alone. Removing suggested
// units after Instagram has virtualized the feed can snap the viewport
// back to the top on some accounts.
} catch (_) {}
};
const observer = new MutationObserver(() => removeSponsoredArticles());
observer.observe(document.body, { childList: true, subtree: true });
removeSponsoredArticles();
})();
+129
View File
@@ -0,0 +1,129 @@
/**
* 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';
// This script is only registered when the setting is enabled, so default ON.
window.__fgBlockAutoplay = typeof window.__fgBlockAutoplay === 'boolean'
? window.__fgBlockAutoplay : true;
const ALLOW_KEY = '__fgUserStartedPlayback';
let userGestureUntil = 0;
function isReelRoute() {
const path = window.location.pathname || '';
return path.indexOf('/reel/') >= 0 || path === '/reels' || path.indexOf('/reels/') >= 0;
}
function isUserGestureActive() {
return Date.now() < userGestureUntil;
}
function markUserGesture(target) {
userGestureUntil = Date.now() + 1200;
try {
let video = target && target.closest ? target.closest('video') : null;
if (!video && target && target.querySelector) video = target.querySelector('video');
if (video) video[ALLOW_KEY] = true;
} catch (_) {}
}
document.addEventListener('pointerdown', function (event) {
markUserGesture(event.target);
}, true);
document.addEventListener('touchstart', function (event) {
markUserGesture(event.target);
}, true);
document.addEventListener('click', function (event) {
markUserGesture(event.target);
}, true);
// Override HTMLMediaElement.play() to check our flag
const _play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () {
if (
window.__fgBlockAutoplay &&
!isReelRoute() &&
this[ALLOW_KEY] !== true &&
!isUserGestureActive()
) {
// Return a resolved promise to avoid breaking Instagram's code
try { this.pause(); } catch (_) {}
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 && !isReelRoute() && el[ALLOW_KEY] !== true) {
el.autoplay = false;
el.removeAttribute('autoplay');
el.removeAttribute('preload');
try { el.preload = 'none'; } catch (_) {}
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();
}
};
document.addEventListener('play', function (event) {
if (event.target && event.target.tagName === 'VIDEO' && isUserGestureActive()) {
event.target[ALLOW_KEY] = true;
}
}, true);
})();
+304
View File
@@ -0,0 +1,304 @@
/**
* FocusGram Content Hider
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
* Flutter controls via window.__fgContent.*
* Injected at DOCUMENT_END.
*
* Key fixes applied:
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
* - Stories tray detection strengthened for fresh SPA navigations
* - Suggested posts detection uses multiple text-node matching strategies
*/
(function () {
'use strict';
if (window.__fgContent && window.__fgContent.__focusgramReady) {
return;
}
const STYLE_ID = 'fg-content-hider';
let hideStories = false;
let hidePosts = false;
let hideSuggested = false;
let hideReels = false;
// ─── CSS rules ─────────────────────────────────────────────────────────────
function buildCSS() {
const selectors = [];
if (hideStories) {
selectors.push(
'[role="list"]:has([aria-label*="tory"])',
'[role="listbox"]:has([aria-label*="tory"])',
'[role="menu"] > ul',
'section > div > div:first-child [style*="overflow"]',
'[role="list"] [style*="overflow"]',
);
}
if (hidePosts) {
selectors.push(
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
);
}
// hideReels CSS is intentionally NOT added here.
// We use DOM removal instead (see removeReels()) so that room is never left
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
return selectors.length
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
: '';
}
function applyCSS() {
if (document.body) {
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
}
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = buildCSS();
}
// ─── Story tray JS ─────────────────────────────────────────────────────────
function hideStoryTray() {
if (!hideStories) return;
// Strategy 1: <ul> children of a named list or menu
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
try {
const items = ul.querySelectorAll('li, button, a');
if (items.length < 2) return;
ul.style.setProperty('display', 'none', 'important');
} catch (_) {}
});
// Strategy 2: horizontally scrolling container with circle items
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
try {
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
if (cands.length < 2) return;
const s0 = window.getComputedStyle(cands[0]);
if (s0.width && parseFloat(s0.width) <= 90) {
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
}
} catch (_) {}
});
}
// ─── Suggested posts ───────────────────────────────────────────────────────
function removeSuggested() {
if (!hideSuggested) return;
var SIGNALS = [
'suggested for you',
'suggested posts',
'suggested reels',
'suggested',
'because you watched',
'because you follow',
'you might like',
'posts you might like',
'accounts you might like',
'recommendations',
];
function norm(s) {
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function hasSignal(s) {
var t = norm(s);
if (!t) return false;
return SIGNALS.some(function (signal) {
if (signal === 'suggested') return t === signal;
return t.indexOf(signal) >= 0;
});
}
function hideContainer(from) {
var parent = from;
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
var role = parent.getAttribute && parent.getAttribute('role');
var tag = parent.tagName;
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
if (
tag === 'ARTICLE' ||
tag === 'SECTION' ||
role === 'listitem' ||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
) {
parent.style.setProperty('display', 'none', 'important');
parent.setAttribute('data-fg-hidden-suggested', '1');
return true;
}
parent = parent.parentElement;
}
return false;
}
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
try {
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
var ownLabel = node.getAttribute('aria-label');
if (hasSignal(ownLabel)) { hideContainer(node); return; }
var text = norm(node.innerText || node.textContent || '');
if (
text.indexOf('suggested for you') >= 0 ||
text.indexOf('suggested posts') >= 0 ||
text.indexOf('suggested reels') >= 0 ||
text.indexOf('because you watched') >= 0 ||
text.indexOf('because you follow') >= 0
) {
hideContainer(node);
}
} catch (_) {}
});
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
try {
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
hideContainer(el);
}
} catch (_) {}
});
}
// ─── Reels DOM REMOVE (not display:none) ─────────────────────────────────
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
// gap cleanly and lets the feed flow naturally.
function removeReels() {
if (!hideReels) return;
var toRemove = [];
document.querySelectorAll('article').forEach(function (el) {
try {
// Fast path: check for a reel-signal attribute first
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
if (mt === '2') { toRemove.push(el); return; }
// Fallback: text-node scan for /reels/ markers
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var n;
while ((n = walker.nextNode())) {
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
toRemove.push(el); break;
}
}
} catch (_) {}
});
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
}
// ─── Public API ────────────────────────────────────────────────────────────
window.__fgContent = {
__focusgramReady: true,
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
setHideSuggested: function (val) {
hideSuggested = !!val;
applyCSS();
if (val) removeSuggested();
},
setHideReels: function (val) {
hideReels = !!val;
applyCSS();
if (val) removeReels();
},
applyAll: function (flags) {
hideStories = !!flags.stories;
hidePosts = !!flags.posts;
hideReels = !!flags.reels;
hideSuggested = !!flags.suggested;
applyCSS();
if (hideSuggested) removeSuggested();
if (hideStories) hideStoryTray();
if (hideReels) removeReels();
},
};
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
// pushState/replaceState don't fire any DOM event we can listen for.
// Hook the methods themselves so we know a navigation happened, then debounce
// re-apply. This also catches the case where the MutationObserver was on `body`
// and that node got replaced by Instagram's SPA re-render.
function scheduleReapply() {
clearTimeout(window.__fg_applyTimer);
window.__fg_applyTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 250);
}
var _origPush = history.pushState;
var _origReplace = history.replaceState;
history.pushState = function () {
_origPush.apply(this, arguments);
scheduleReapply();
};
history.replaceState = function () {
_origReplace.apply(this, arguments);
scheduleReapply();
};
// Reinforce on popstate too (user hits back/forward)
window.addEventListener('popstate', scheduleReapply, { passive: true });
// For pushState on the same URL (rare but possible) poll path briefly
window.addEventListener('pageshow', scheduleReapply, { passive: true });
window.addEventListener('focus', scheduleReapply, { passive: true });
// ─── MutationObserver ───────────────────────────────────────────────────────
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
// re-applies everything on each cycle. Does NOT guard on a per-element timer
// that would never re-fire after the body is replaced by SPA re-render.
if (!window.__fgContentObserver) {
window.__fgContentObserver = new MutationObserver(function () {
clearTimeout(window.__fg_moTimer);
window.__fg_moTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 300);
});
// `document.documentElement` survives SPA navigations (body gets replaced
// but <html> stays). Observing it catches both subtree mutations and, via
// the SPA heartbeat above, re-applies after pushState.
window.__fgContentObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// ─── Initial run ────────────────────────────────────────────────────────────
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
// Signal ready — Flutter will call applyAll() with stored prefs
if (window.ContentChannel) {
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+315
View File
@@ -0,0 +1,315 @@
/**
* FocusGram Unified Feed Filter via Fetch Interception
* Injected at DOCUMENT_START — before Instagram's JS loads.
*
* This script intercepts GraphQL fetch calls and filters feed content based on:
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
* - Sponsored posts (ad_action_link, ad_header_style)
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
* - Videos/Reels (is_video, media_type, clips_metadata)
* - Autoplay blocking (video autoplay prevention)
*/
(function () {
'use strict';
// Configuration flags (set by Flutter via prefs)
window.__fgFilterConfig = {
blockAds: false,
blockSponsored: false,
blockSuggested: false,
blockVideos: false,
blockAutoplay: false,
blockGraphQLQueryWhenFeedPosts: false,
};
const textHasAdSignal = (value) => {
const s = String(value || '').toLowerCase();
return (
s === 'sponsored' ||
s.includes('"sponsored"') ||
s.includes('paid partnership') ||
s.includes('promoted') ||
s.includes('ad_id') ||
s.includes('ad_tracking') ||
s.includes('sponsor_tags')
);
};
// Helper: Check if a node is an ad
const isAdNode = (node) => {
if (!node || typeof node !== 'object') return false;
const typename = String(node.__typename || '');
const adText = JSON.stringify({
organic_tracking_token: node.organic_tracking_token,
sponsor_tags: node.sponsor_tags,
social_context: node.social_context,
title: node.title,
header: node.header,
label: node.label,
overlay_text: node.overlay_text,
});
return !!(
node.is_ad ||
node.is_paid_partnership ||
node.sponsor_tags ||
node.ad_tracking_token ||
node.ad_action_link ||
node.ad_id ||
node.ad_impression_token ||
node.ad_metadata ||
node.commerciality_status === 'commercial' ||
(node.product_type && node.product_type === 'ad') ||
(node.ad_header_style && node.ad_header_style !== 'none') ||
typename === 'GraphAdStory' ||
typename.includes('Ad') ||
textHasAdSignal(adText)
);
};
// Helper: Check if a node is sponsored
const isSponsoredNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_paid_partnership ||
node.sponsor_tags ||
(node.ad_action_link && node.ad_action_link.href) ||
(node.ad_header_style && node.ad_header_style !== 'none') ||
textHasAdSignal(JSON.stringify(node.social_context || node.header || node.label || ''))
);
};
// Helper: Check if a node is suggested content
const isSuggestedNode = (node) => {
if (!node || typeof node !== 'object') return false;
const typename = String(node.__typename || '');
const reason = JSON.stringify({
reason: node.suggested_reason,
social_context: node.social_context,
title: node.title,
header: node.header,
label: node.label,
}).toLowerCase();
return !!(
node.is_suggested ||
node.is_suggested_for_you ||
node.is_recommendation ||
node.suggested_users ||
node.suggested_media ||
node.suggested_content ||
node.recommendation_source ||
typename.includes('Suggested') ||
typename.includes('Recommendation') ||
reason.includes('suggested') ||
reason.includes('recommend')
);
};
// Helper: Check if a node is a video/reel
const isVideoNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_video ||
(node.media_type === 2) ||
node.clips_metadata ||
(node.__typename && (
node.__typename.includes('Clips') ||
node.__typename.includes('Video')
))
);
};
const isFeedMediaNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.pk ||
node.id ||
node.code ||
node.media_type ||
node.image_versions2 ||
node.video_versions ||
node.carousel_media ||
node.__typename?.includes('Media') ||
node.__typename?.includes('Timeline')
);
};
// Helper: Check for media in carousel
const hasVideoInCarousel = (node) => {
if (!node || typeof node !== 'object') return false;
if (node.media_type === 8) {
const edges = node.edge_sidecar_to_children?.edges || [];
return edges.some(edge => isVideoNode(edge.node));
}
return false;
};
// Main filter function for feed nodes
const shouldFilterNode = (node) => {
const config = window.__fgFilterConfig;
if (!node || typeof node !== 'object') return false;
if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
return true;
}
// Check ads
if (config.blockAds && isAdNode(node)) {
return true;
}
// Check sponsored (separate from ads)
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
return true;
}
// Check suggested content
if (config.blockSuggested && isSuggestedNode(node)) {
return true;
}
// Check videos/reels
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
return true;
}
return false;
};
// Recursively filter GraphQL response edges
const filterEdges = (edges, path = []) => {
if (!Array.isArray(edges)) return edges;
return edges.filter(edge => {
if (!edge || !edge.node) return true;
const node = edge.node;
// Keep the edge if it doesn't match any filter
if (!shouldFilterNode(node)) return true;
// Log filtered content for debugging
if (window.__fgDebugFilter) {
const type = node.__typename || 'Unknown';
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
}
return false;
});
};
// Recursively walk GraphQL response and filter edges
const walkAndFilter = (obj, visited = new Set()) => {
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
visited.add(obj);
// Handle arrays
if (Array.isArray(obj)) {
obj.forEach(item => walkAndFilter(item, visited));
return;
}
// Check for edges array (common GraphQL pattern)
if (obj.edges && Array.isArray(obj.edges)) {
obj.edges = filterEdges(obj.edges);
}
// Recurse into children
for (const key in obj) {
if (obj.hasOwnProperty(key) && key !== '__typename') {
const val = obj[key];
if (val && typeof val === 'object') {
walkAndFilter(val, visited);
}
}
}
};
// Override fetch
const _fetch = window.fetch.bind(window);
window.fetch = async function (input, init) {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Call original fetch
let response = await _fetch(input, init);
// Only intercept GraphQL feed queries
if (!url.includes('/graphql/query') && !url.includes('/api/v1/feed')) {
return response;
}
// Clone response to read body
const cloned = response.clone();
try {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return response;
}
const data = await cloned.json();
// Filter the response data
walkAndFilter(data);
// Return modified response
return new Response(JSON.stringify(data), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} catch (e) {
// On error, return original response
return response;
}
};
// Preserve native function appearance
Object.defineProperty(window, 'fetch', {
value: window.fetch,
writable: true,
configurable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
const _xhrOpen = XMLHttpRequest.prototype.open;
const _xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__fgUrl = typeof url === 'string' ? url : String(url || '');
return _xhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
if (
window.__fgFilterConfig.blockVideos &&
this.__fgUrl &&
(this.__fgUrl.includes('/api/v1/clips/') ||
this.__fgUrl.includes('/api/v1/discover/'))
) {
try { this.abort(); } catch (_) {}
return;
}
return _xhrSend.apply(this, arguments);
};
// Allow Flutter to update config flags
window.__fgSetFilterConfig = function (config) {
if (typeof config === 'object') {
Object.assign(window.__fgFilterConfig, config);
if (window.__fgDebugFilter) {
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
}
}
};
// Enable debug logging
window.__fgDebugFilter = false;
})();
+179
View File
@@ -0,0 +1,179 @@
/**
* FocusGram Ghost Mode
* Injected at DOCUMENT_START — before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals.
*/
(function () {
'use strict';
// ─── Seen API patterns ────────────────────────────────────────────────────
const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//,
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
];
// ─── Activity patterns (like, comment) — intercepted for local history ────
const ACTIVITY_PATTERNS = [
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
/\/api\/v1\/web\/comments\/add\//,
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
];
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
const fakeOkResponse = () =>
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// ─── Fetch override ───────────────────────────────────────────────────────
const _fetch = window.fetch.bind(window);
const patchedFetch = async function (input, init) {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Block seen
if (isSeen(url)) {
if (window.GhostChannel) {
window.GhostChannel.postMessage(
JSON.stringify({ type: 'seen_blocked', url })
);
}
return fakeOkResponse();
}
// Intercept activity for local history
if (isActivity(url) && window.ActivityChannel) {
const body = init?.body;
const bodyText =
body instanceof URLSearchParams
? body.toString()
: typeof body === 'string'
? body
: '';
window.ActivityChannel.postMessage(
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
);
}
return _fetch(input, init);
};
// Disguise as native
Object.defineProperty(window, 'fetch', {
value: patchedFetch,
writable: true,
configurable: true,
enumerable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
window.fetch[Symbol.toStringTag] = 'fetch';
// ─── XMLHttpRequest override ──────────────────────────────────────────────
const _XHROpen = XMLHttpRequest.prototype.open;
const _XHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._fg_url = url ?? '';
this._fg_method = (method ?? '').toUpperCase();
return _XHROpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function (body) {
if (this._fg_url && isSeen(this._fg_url)) {
// Fire readyState 4 with fake success without actually sending
const self = this;
setTimeout(() => {
Object.defineProperty(self, 'readyState', { get: () => 4 });
Object.defineProperty(self, 'status', { get: () => 200 });
Object.defineProperty(self, 'responseText', {
get: () => '{"status":"ok"}',
});
Object.defineProperty(self, 'response', {
get: () => '{"status":"ok"}',
});
self.dispatchEvent(new Event('readystatechange'));
self.dispatchEvent(new Event('load'));
}, 10);
return;
}
return _XHRSend.call(this, body);
};
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) {
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
const _send = ws.send.bind(ws);
ws.send = function (data) {
if (typeof data === 'string') {
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
try {
const parsed = JSON.parse(data);
if (
parsed?.op === '4' ||
parsed?.op === 'seen' ||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
) {
return; // drop
}
} catch (_) {}
// Text-based seen signal check
if (data.includes('"seen"') && data.includes('"thread_id"')) {
return;
}
}
return _send(data);
};
return ws;
}
// Preserve WebSocket prototype chain so IG's ws checks pass
PatchedWebSocket.prototype = _WS.prototype;
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
PatchedWebSocket.OPEN = _WS.OPEN;
PatchedWebSocket.CLOSING = _WS.CLOSING;
PatchedWebSocket.CLOSED = _WS.CLOSED;
window.WebSocket = PatchedWebSocket;
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
// Only applied if user enables online-status hiding
// Wrapped in a named fn so Flutter can call it:
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
window.__fgEnableOnlineHide = function () {
Object.defineProperty(document, 'visibilityState', {
get: () => 'hidden',
configurable: true,
});
Object.defineProperty(document, 'hidden', {
get: () => true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
};
window.__fgDisableOnlineHide = function () {
// Restore by deleting the overrides (falls back to native getter)
delete document.visibilityState;
delete document.hidden;
document.dispatchEvent(new Event('visibilitychange'));
};
// Signal to Flutter that ghost mode JS is active
if (window.GhostChannel) {
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+47
View File
@@ -0,0 +1,47 @@
/**
* FocusGram Theme Detector
* Reads light/dark theme from page and bridges to Flutter.
* Injected at DOCUMENT_END.
*/
(function () {
'use strict';
(function fgThemeSync() {
if (window.__fgThemeSyncRunning) return;
window.__fgThemeSyncRunning = true;
function getTheme() {
try {
const h = document.documentElement;
if (h.classList.contains('style-dark')) return 'dark';
if (h.classList.contains('style-light')) return 'light';
const bg = window.getComputedStyle(document.body).backgroundColor;
const rgb = bg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const luminance =
(0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
return luminance < 0.5 ? 'dark' : 'light';
}
} catch (_) {}
return 'dark';
}
let last = '';
function check() {
const current = getTheme();
if (current !== last) {
last = current;
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'FocusGramThemeChannel',
current
);
}
}
}
setInterval(check, 1500);
check();
})();
})();
@@ -1,5 +0,0 @@
Initial open-source release of FocusGram.
- Complete Reels and Explore hiding.
- Timed Reel sessions and daily limits.
- Isolated DM Reel player.
- Privacy-first: No Firebase or trackers.
@@ -1 +0,0 @@
Same as1st version. just version pump
@@ -1,7 +0,0 @@
New: Reels History, Minimal Mode, Disable Reels/Explore toggles, Autoplay Blocker, Screen Time Dashboard, Grayscale Mode, 8 content hide toggles.
Fixes: DM keyboard bug, Reels scroll lag.
Performance: Native nav bar, background preloading, skeleton screen, haptic feedback, smooth scrolling.
F-Droid: Removed all Google dependencies. No Play Services in APK.
@@ -1,6 +0,0 @@
What's new
- Reordered Settings Page.
- Added "Click to Unblur" for posts.
- Added Persistent Notification
- Improved Grayscale Scheduling.
and more.
@@ -1,10 +0,0 @@
FocusGram is a free and open-source Android app designed to help you regain control over your social media usage. It wraps the mobile version of Instagram in a secure WebView and injects features to minimize distractions.
Features:
- **Focus Mode**: Blur explore posts and hide reel buttons.
- **Guardrails**: Set daily usage limits and session cooldowns.
- **Mindfulness**: A mandatory breathing exercise before entering the app.
- **Privacy First**: No analytics, no tracking, and no proprietary Google Play Services requirements.
- **Hybrid Composition**: Optimized WebView performance for smooth scrolling.
FocusGram is NOT an Instagram mod. It uses the standard web version of Instagram and applies client-side CSS and JS modifications only.
@@ -1 +0,0 @@
A digital wellness wrapper for Instagram.
@@ -1 +0,0 @@
FocusGram
+17
View File
@@ -0,0 +1,17 @@
class FocusSettings {
final bool ghostMode; // hide read receipts
final bool noAds; // strip ads and sponsored posts
final bool noStories; // hide story tray
final bool noReels; // hide reels tab
final bool noAutoplay; // stop videos autoplaying
final bool noDMs; // block direct messages
const FocusSettings({
this.ghostMode = false,
this.noAds = false,
this.noStories = false,
this.noReels = false,
this.noAutoplay = false,
this.noDMs = false,
});
}
+1
View File
@@ -145,6 +145,7 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
// Step 3: Breath gate // Step 3: Breath gate
if (settings.showBreathGate && !_breathCompleted) { if (settings.showBreathGate && !_breathCompleted) {
return BreathGateScreen( return BreathGateScreen(
durationSeconds: settings.breathGateSeconds,
onFinish: () => setState(() => _breathCompleted = true), onFinish: () => setState(() => _breathCompleted = true),
); );
} }
+20 -4
View File
@@ -27,7 +27,25 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
55, 55,
60, 60,
]; ];
int _selectedIndex = 2; // default: 15 min int _selectedIndex = 0; // default: 5 min unless a previous choice exists
late final FixedExtentScrollController _scrollController;
@override
void initState() {
super.initState();
final lastMinutes = context.read<SessionManager>().lastAppSessionMinutes;
final lastIndex = _minuteOptions.indexOf(lastMinutes);
_selectedIndex = lastIndex >= 0 ? lastIndex : 0;
_scrollController = FixedExtentScrollController(
initialItem: _selectedIndex,
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -118,12 +136,10 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
perspective: 0.003, perspective: 0.003,
squeeze: 1.1, squeeze: 1.1,
diameterRatio: 2.5, diameterRatio: 2.5,
controller: _scrollController,
onSelectedItemChanged: (i) { onSelectedItemChanged: (i) {
setState(() => _selectedIndex = i); setState(() => _selectedIndex = i);
}, },
controller: FixedExtentScrollController(
initialItem: _selectedIndex,
),
childDelegate: ListWheelChildListDelegate( childDelegate: ListWheelChildListDelegate(
children: _minuteOptions.asMap().entries.map((entry) { children: _minuteOptions.asMap().entries.map((entry) {
final isSelected = entry.key == _selectedIndex; final isSelected = entry.key == _selectedIndex;
+11 -7
View File
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async'; import 'dart:async';
/// A mindfulness screen shown before the app opens. /// A mindfulness screen shown before Instagram opens.
/// Forces the user to take a deep 10-second breath.
class BreathGateScreen extends StatefulWidget { class BreathGateScreen extends StatefulWidget {
final VoidCallback onFinish; final VoidCallback onFinish;
final int durationSeconds;
const BreathGateScreen({super.key, required this.onFinish}); const BreathGateScreen({
super.key,
required this.onFinish,
this.durationSeconds = 10,
});
@override @override
State<BreathGateScreen> createState() => _BreathGateScreenState(); State<BreathGateScreen> createState() => _BreathGateScreenState();
@@ -16,15 +20,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
with TickerProviderStateMixin { with TickerProviderStateMixin {
late AnimationController _controller; late AnimationController _controller;
late Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
int _secondsRemaining = 10; late int _secondsRemaining;
Timer? _timer; Timer? _timer;
bool _canContinue = false; bool _canContinue = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
// 10-second breathing animation: 5s in, 5s out
_controller = AnimationController( _controller = AnimationController(
vsync: this, vsync: this,
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
@@ -71,7 +75,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text( const Text(
'Are you sure you want to open FocusGram?', 'Are you sure you want to open Instagram?',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
@@ -131,7 +135,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
borderRadius: BorderRadius.circular(25), borderRadius: BorderRadius.circular(25),
), ),
), ),
child: const Text('Continue to FocusGram'), child: const Text('Continue to Instagram'),
), ),
), ),
], ],
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
class ExtrasSettingsPage extends StatelessWidget {
const ExtrasSettingsPage({super.key});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
return Scaffold(
appBar: AppBar(
title: const Text(
'Extras',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
children: [
const _SectionHeader(title: 'MEDIA'),
_SwitchTile(
title: 'Download Media (Feed + Reels)',
subtitle: 'Adds a download icon on posts and reels',
value: settings.videoDownloadEnabled,
onChanged: (v) async {
await settings.setVideoDownloadEnabled(v);
HapticFeedback.selectionClick();
},
),
const _SectionHeader(title: 'FOCUS'),
_SwitchTile(
title: 'GHOST MODE',
subtitle: 'Hide seen indicator / read receipts',
value: settings.ghostMode,
onChanged: (v) async {
await settings.setGhostMode(v);
HapticFeedback.selectionClick();
},
),
const _SectionHeader(title: 'FOCUSGRAM V2'),
_SwitchTile(
title: 'Ad Blocker',
subtitle: 'Removes ads and sponsored posts',
value: settings.v2AdBlockerDomEnabled,
onChanged: (v) async {
await settings.setV2AdBlockerDomEnabled(v);
HapticFeedback.selectionClick();
},
),
_SwitchTile(
title: 'Block Suggested Posts',
subtitle: 'Removes Suggested for you and recommendation units',
value: settings.contentSuggested,
onChanged: (v) async {
await settings.setContentSuggestedEnabled(v);
HapticFeedback.selectionClick();
},
),
const SizedBox(height: 40),
],
),
);
}
}
class _SwitchTile extends StatelessWidget {
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchTile({
required this.title,
this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return SwitchListTile(
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: subtitle != null
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
: null,
value: value,
onChanged: onChanged,
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
title,
style: const TextStyle(
color: Colors.grey,
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
);
}
}
+7 -2
View File
@@ -18,7 +18,11 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
Future<void> Function() action, Future<void> Function() action,
) async { ) async {
if (sm.isScheduledBlockActive) { if (sm.isScheduledBlockActive) {
final ok = await DisciplineChallenge.show(context, count: 35); final settings = context.read<SettingsService>();
final ok = await DisciplineChallenge.show(
context,
count: settings.resolvedWordChallengeCount(),
);
if (!context.mounted || !ok) return; if (!context.mounted || !ok) return;
} }
await action(); await action();
@@ -321,7 +325,8 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
final sm = context.read<SessionManager>(); final sm = context.read<SessionManager>();
int wordCount = 15; final settings = context.read<SettingsService>();
int wordCount = settings.resolvedWordChallengeCount();
// If we are at 0 quota, increase difficulty to 35 words // If we are at 0 quota, increase difficulty to 35 words
if (widget.title.contains('Daily Reel Limit') && if (widget.title.contains('Daily Reel Limit') &&
sm.dailyRemainingSeconds <= 0) { sm.dailyRemainingSeconds <= 0) {
File diff suppressed because it is too large Load Diff
+66 -28
View File
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
final PageController _pageController = PageController(); final PageController _pageController = PageController();
int _currentPage = 0; int _currentPage = 0;
// Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications // Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications
static const int _kTotalPages = 5; static const int _kTotalPages = 5;
static const int _kBlurPage = 3; static const int _kBlurPage = 3;
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State<OnboardingPage> {
final List<Widget> slides = [ final List<Widget> slides = [
// Page 0: Welcome // Page 0: Welcome
_StaticSlide( _StaticSlide(
icon: Icons.auto_awesome, icon: Icons.auto_awesome_rounded,
color: Colors.blue, color: const Color(0xFF4F8DFF),
title: 'Welcome to FocusGram', title: 'Welcome to FocusGram',
description: description:
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.', 'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.',
), ),
// Page 1: Session Management // Page 1: Focus controls
_StaticSlide( _StaticSlide(
icon: Icons.timer, icon: Icons.timer_outlined,
color: Colors.orange, color: const Color(0xFFFFB74D),
title: 'Session Management', title: 'Time With Intent',
description: description:
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.', 'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.',
), ),
// Page 2: Open links // Page 2: Open links
_StaticSlide( _StaticSlide(
icon: Icons.link, icon: Icons.link_rounded,
color: Colors.cyan, color: const Color(0xFF35C2D6),
title: 'Open Links in FocusGram', title: 'Open Links in FocusGram',
description: description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.', 'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
@@ -63,11 +63,11 @@ class _OnboardingPageState extends State<OnboardingPage> {
// Page 4: Notifications // Page 4: Notifications
_StaticSlide( _StaticSlide(
icon: Icons.notifications_active, icon: Icons.notifications_active_outlined,
color: Colors.green, color: const Color(0xFF5DD18A),
title: 'Stay Notified', title: 'Useful Alerts Only',
description: description:
'We need notification permissions to alert you when your session is over or a new message arrives.', 'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.',
isPermissionPage: true, isPermissionPage: true,
permission: Permission.notification, permission: Permission.notification,
), ),
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
), ),
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 28),
// CTA button // CTA button
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 32), padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
final isBlur = _currentPage == _kBlurPage; final isBlur = _currentPage == _kBlurPage;
String label; String label;
if (isLast) { if (isNotif) {
label = 'Get Started'; label = 'Allow & Start';
} else if (isLink) { } else if (isLink) {
label = 'Configure'; label = 'Configure';
} else if (isNotif) {
label = 'Allow Notifications';
} else if (isBlur) { } else if (isBlur) {
label = 'Save & Continue'; label = 'Save & Continue';
} else if (isLast) {
label = 'Get Started';
} else { } else {
label = 'Next'; label = 'Next';
} }
@@ -143,7 +143,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
); );
} else if (isNotif) { } else if (isNotif) {
await Permission.notification.request(); await Permission.notification.request();
await NotificationService().init(); await NotificationService()
.requestPermissionsNow();
} }
if (!context.mounted) return; if (!context.mounted) return;
@@ -178,9 +179,19 @@ class _OnboardingPageState extends State<OnboardingPage> {
// Skip button (available on all pages except last) // Skip button (available on all pages except last)
if (_currentPage < _kTotalPages - 1) if (_currentPage < _kTotalPages - 1)
TextButton( TextButton(
onPressed: () => _finish(context), onPressed: () {
if (_currentPage == _kNotifPage) {
_finish(context);
} else {
_pageController.animateToPage(
_kTotalPages - 1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: const Text( child: const Text(
'Skip', 'Skip setup',
style: TextStyle(color: Colors.white38, fontSize: 14), style: TextStyle(color: Colors.white38, fontSize: 14),
), ),
), ),
@@ -222,18 +233,27 @@ class _StaticSlide extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160), padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, size: 120, color: color), Container(
const SizedBox(height: 48), width: 112,
height: 112,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: color.withValues(alpha: 0.28)),
),
child: Icon(icon, size: 54, color: color),
),
const SizedBox(height: 36),
Text( Text(
title, title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 32, fontSize: 30,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -243,10 +263,28 @@ class _StaticSlide extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 18, fontSize: 16,
height: 1.5, height: 1.5,
), ),
), ),
if (isPermissionPage || isAppSettingsPage) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Text(
isPermissionPage
? 'Permission is optional and can be changed later.'
: 'This opens Android settings; return here when done.',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
),
],
], ],
), ),
); );
+1
View File
@@ -115,6 +115,7 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
hideReelsTab: false, hideReelsTab: false,
hideShopTab: false, hideShopTab: false,
disableReelsEntirely: false, disableReelsEntirely: false,
blockHomeFeedScroll: false,
), ),
); );
}, },
+26 -21
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/session_manager.dart'; import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../utils/discipline_challenge.dart'; import '../utils/discipline_challenge.dart';
class SessionModal extends StatefulWidget { class SessionModal extends StatefulWidget {
@@ -63,23 +64,22 @@ class _SessionModalState extends State<SessionModal> {
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600), style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Wrap(
mainAxisAlignment: MainAxisAlignment.spaceBetween, spacing: 8,
children: [1, 5, 10, 15].map((m) { runSpacing: 8,
return Expanded( children: [1, 3, 5, 10, 15, 20, 30].map((m) {
child: Padding( return SizedBox(
padding: const EdgeInsets.symmetric(horizontal: 4.0), width: 72,
child: ElevatedButton( child: ElevatedButton(
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted) onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
? null ? null
: () => _start(m), : () => _start(m),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.white12, backgroundColor: Colors.white12,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text('${m}m'),
), ),
child: Text('${m}m'),
), ),
); );
}).toList(), }).toList(),
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
Slider( Slider(
value: _customMinutes, value: _customMinutes,
min: 1, min: 1,
max: 30, max: 60,
divisions: 29, divisions: 59,
label: '${_customMinutes.toInt()}m', label: '${_customMinutes.toInt()}m',
onChanged: (v) => setState(() => _customMinutes = v), onChanged: (v) => setState(() => _customMinutes = v),
), ),
@@ -126,10 +126,15 @@ class _SessionModalState extends State<SessionModal> {
void _start(int minutes) async { void _start(int minutes) async {
final sm = context.read<SessionManager>(); final sm = context.read<SessionManager>();
final settings = context.read<SettingsService>();
// Always require word challenge for reel sessions (User request) if (settings.requireWordChallenge) {
final success = await DisciplineChallenge.show(context); final success = await DisciplineChallenge.show(
if (!success) return; context,
count: settings.resolvedWordChallengeCount(),
);
if (!success) return;
}
if (sm.startSession(minutes)) { if (sm.startSession(minutes)) {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
+447 -80
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@@ -8,6 +9,7 @@ import '../services/settings_service.dart';
import '../services/focusgram_router.dart'; import '../services/focusgram_router.dart';
import '../features/screen_time/screen_time_screen.dart'; import '../features/screen_time/screen_time_screen.dart';
import 'guardrails_page.dart'; import 'guardrails_page.dart';
import 'extras_settings_page.dart';
// Main Settings Page // Main Settings Page
@@ -34,6 +36,7 @@ class SettingsPage extends StatelessWidget {
), ),
body: ListView( body: ListView(
children: [ children: [
const _DonateTile(),
_buildStatsRow(sm), _buildStatsRow(sm),
const _SectionHeader(title: 'FOCUS & BLOCKING'), const _SectionHeader(title: 'FOCUS & BLOCKING'),
@@ -63,6 +66,19 @@ class SettingsPage extends StatelessWidget {
), ),
), ),
const _SectionHeader(title: 'EXTRAS'),
_SubmoduleTile(
icon: Icons.download_rounded,
iconColor: Colors.orangeAccent,
title: 'Extras',
subtitle: 'Download media, Ghost Mode, Ad Blocker',
enabled: true,
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()),
),
),
const _SectionHeader(title: 'APPEARANCE'), const _SectionHeader(title: 'APPEARANCE'),
_SubmoduleTile( _SubmoduleTile(
icon: Icons.palette_outlined, icon: Icons.palette_outlined,
@@ -264,6 +280,7 @@ class FocusSettingsPage extends StatelessWidget {
body: ListView( body: ListView(
children: [ children: [
const _SectionHeader(title: 'BLOCKING'), const _SectionHeader(title: 'BLOCKING'),
Container( Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -293,12 +310,23 @@ class FocusSettingsPage extends StatelessWidget {
color: Colors.redAccent.withValues(alpha: 0.12), color: Colors.redAccent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon(Icons.shield_rounded, color: Colors.redAccent, size: 20), child: const Icon(
Icons.shield_rounded,
color: Colors.redAccent,
size: 20,
),
), ),
title: const Text('Minimal Mode', style: TextStyle(fontSize: 15)), title: const Text('Minimal Mode', style: TextStyle(fontSize: 15)),
subtitle: Text( subtitle: Text(
settings.minimalModeEnabled ? 'Enabled - tap to customize' : 'Disabled - tap to configure', settings.minimalModeEnabled
style: TextStyle(fontSize: 12, color: settings.minimalModeEnabled ? Colors.greenAccent : Colors.grey), ? 'Enabled - tap to customize'
: 'Disabled - tap to configure',
style: TextStyle(
fontSize: 12,
color: settings.minimalModeEnabled
? Colors.greenAccent
: Colors.grey,
),
), ),
trailing: Switch( trailing: Switch(
value: settings.minimalModeEnabled, value: settings.minimalModeEnabled,
@@ -307,22 +335,49 @@ class FocusSettingsPage extends StatelessWidget {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
), ),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage())), onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage()),
),
), ),
const _SectionHeader(title: 'FRICTION'), const _SectionHeader(title: 'FRICTION'),
_SwitchTile( _SwitchTile(
title: 'Mindfulness Gate', title: 'Mindfulness Gate',
subtitle: 'Breath screen before opening Instagram', subtitle: '${settings.breathGateSeconds}s before opening Instagram',
value: settings.showBreathGate, value: settings.showBreathGate,
onChanged: (v) => settings.setShowBreathGate(v), onChanged: (v) => settings.setShowBreathGate(v),
), ),
if (settings.showBreathGate)
_NumberEditTile(
title: 'Gate Duration',
label: '${settings.breathGateSeconds} seconds',
initialValue: settings.breathGateSeconds,
min: 3,
max: 60,
suffix: 'seconds',
onSubmitted: (v) => settings.setBreathGateSeconds(v),
),
_SwitchTile( _SwitchTile(
title: 'Strict Mode (Word Challenge)', title: 'Typing Challenge',
subtitle: 'Must type a phrase before starting a Reel session', subtitle: settings.wordChallengeCount == 0
? 'Random: 10-35 words'
: '${settings.wordChallengeCount} words',
value: settings.requireWordChallenge, value: settings.requireWordChallenge,
onChanged: (v) => settings.setRequireWordChallenge(v), onChanged: (v) => settings.setRequireWordChallenge(v),
), ),
if (settings.requireWordChallenge)
_ChoiceTile<int>(
title: 'Typing Words',
value: settings.wordChallengeCount,
label: settings.wordChallengeCount == 0
? 'Random (10-35)'
: '${settings.wordChallengeCount} words',
options: const [20, 25, 30, 35, 0],
optionLabel: (v) => v == 0 ? 'Random (10-35)' : '$v words',
onSelected: (v) => settings.setWordChallengeCount(v),
),
const _SectionHeader(title: 'MEDIA'), const _SectionHeader(title: 'MEDIA'),
_SwitchTile( _SwitchTile(
title: 'Block Autoplay Videos', title: 'Block Autoplay Videos',
@@ -348,6 +403,48 @@ class FocusSettingsPage extends StatelessWidget {
), ),
), ),
const _SectionHeader(title: 'FOCUSGRAM V2 OVERLAY'),
_SwitchTile(
title: 'Content Hider',
subtitle: 'Hide stories tray, feed posts, reels, suggested content',
value: settings.v2ContentHiderEnabled,
onChanged: (v) => settings.setV2ContentHiderEnabled(v),
),
if (settings.v2ContentHiderEnabled)
Padding(
padding: const EdgeInsets.only(left: 32),
child: Column(
children: [
_SwitchTile(
title: 'Hide Stories Tray',
subtitle: 'Story bubbles row',
value: settings.contentStories,
onChanged: (v) => settings.setContentStoriesEnabled(v),
),
_SwitchTile(
title: 'Hide Feed Posts',
subtitle: 'Home feed posts',
value: settings.contentPosts,
onChanged: (v) => settings.setContentPostsEnabled(v),
),
_SwitchTile(
title: 'Hide Reels (Feed)',
subtitle: 'Reels shown in the feed',
value: settings.contentReels,
onChanged: (v) => settings.setContentReelsEnabled(v),
),
_SwitchTile(
title: 'Hide Suggested Content',
subtitle: 'Suggested posts and recommendation units',
value: settings.contentSuggested,
onChanged: (v) => settings.setContentSuggestedEnabled(v),
),
],
),
),
const SizedBox(height: 40), const SizedBox(height: 40),
], ],
), ),
@@ -355,6 +452,50 @@ class FocusSettingsPage extends StatelessWidget {
} }
} }
class _DonateTile extends StatelessWidget {
const _DonateTile();
static final Uri _donateUri = Uri.parse('https://buymemomo.com/ujwal');
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
decoration: BoxDecoration(
color: Colors.pinkAccent.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.pinkAccent.withValues(alpha: 0.22)),
),
child: ListTile(
leading: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.pinkAccent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.favorite_rounded,
color: Colors.pinkAccent,
size: 20,
),
),
title: const Text(
'Please donate to support the development of this project.',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
subtitle: const Text(
'Your support keeps FocusGram free and maintained.',
style: TextStyle(fontSize: 12),
),
trailing: const Icon(Icons.open_in_new, size: 14),
onTap: () =>
launchUrl(_donateUri, mode: LaunchMode.externalApplication),
),
);
}
}
// Minimal Mode Submenu // Minimal Mode Submenu
class MinimalModeSubmenuPage extends StatefulWidget { class MinimalModeSubmenuPage extends StatefulWidget {
@@ -368,6 +509,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
late bool _blurExplore; late bool _blurExplore;
late bool _disableReelsEntirely; late bool _disableReelsEntirely;
late bool _disableExploreEntirely; late bool _disableExploreEntirely;
late bool _blockHomeFeedScroll;
@override @override
void initState() { void initState() {
@@ -376,26 +518,51 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
_blurExplore = settings.blurExplore; _blurExplore = settings.blurExplore;
_disableReelsEntirely = settings.disableReelsEntirely; _disableReelsEntirely = settings.disableReelsEntirely;
_disableExploreEntirely = settings.disableExploreEntirely; _disableExploreEntirely = settings.disableExploreEntirely;
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
} }
void _updateSetting(String key, bool value) { Future<void> _updateSetting(String key, bool value) async {
final settings = context.read<SettingsService>(); final settings = context.read<SettingsService>();
setState(() { setState(() {
switch (key) { switch (key) {
case 'blurExplore': case 'blurExplore':
_blurExplore = value; _blurExplore = value;
settings.setBlurExplore(value);
break; break;
case 'disableReelsEntirely': case 'disableReelsEntirely':
_disableReelsEntirely = value; _disableReelsEntirely = value;
settings.setDisableReelsEntirelyInternal(value);
break; break;
case 'disableExploreEntirely': case 'disableExploreEntirely':
_disableExploreEntirely = value; _disableExploreEntirely = value;
settings.setDisableExploreEntirelyInternal(value); break;
case 'blockHomeFeedScroll':
_blockHomeFeedScroll = value;
break; break;
} }
}); });
switch (key) {
case 'blurExplore':
await settings.setBlurExplore(value);
break;
case 'disableReelsEntirely':
await settings.setDisableReelsEntirelyInternal(value);
break;
case 'disableExploreEntirely':
await settings.setDisableExploreEntirelyInternal(value);
break;
case 'blockHomeFeedScroll':
await settings.setBlockHomeFeedScrollInternal(value);
break;
}
if (!mounted) return;
final latest = context.read<SettingsService>();
setState(() {
_blurExplore = latest.blurExplore;
_disableReelsEntirely = latest.disableReelsEntirely;
_disableExploreEntirely = latest.disableExploreEntirely;
_blockHomeFeedScroll = latest.blockHomeFeedScroll;
});
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
} }
@@ -406,6 +573,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
_blurExplore = true; _blurExplore = true;
_disableReelsEntirely = true; _disableReelsEntirely = true;
_disableExploreEntirely = true; _disableExploreEntirely = true;
_blockHomeFeedScroll = true;
}); });
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
} }
@@ -418,6 +586,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
_blurExplore = settings.blurExplore; _blurExplore = settings.blurExplore;
_disableReelsEntirely = settings.disableReelsEntirely; _disableReelsEntirely = settings.disableReelsEntirely;
_disableExploreEntirely = settings.disableExploreEntirely; _disableExploreEntirely = settings.disableExploreEntirely;
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
}); });
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
} }
@@ -437,61 +606,88 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: isMinimalModeEnabled colors: isMinimalModeEnabled
? [Colors.redAccent.withValues(alpha: 0.2), Colors.red.withValues(alpha: 0.1)] ? [
: [Colors.grey.withValues(alpha: 0.1), Colors.grey.withValues(alpha: 0.05)], Colors.redAccent.withValues(alpha: 0.2),
Colors.red.withValues(alpha: 0.1),
]
: [
Colors.grey.withValues(alpha: 0.1),
Colors.grey.withValues(alpha: 0.05),
],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: isMinimalModeEnabled ? Colors.redAccent.withValues(alpha: 0.3) : Colors.grey.withValues(alpha: 0.2), color: isMinimalModeEnabled
? Colors.redAccent.withValues(alpha: 0.3)
: Colors.grey.withValues(alpha: 0.2),
), ),
), ),
child: Column( child: Column(
children: [ children: [
Icon( Icon(
isMinimalModeEnabled ? Icons.shield_rounded : Icons.shield_outlined, isMinimalModeEnabled
? Icons.shield_rounded
: Icons.shield_outlined,
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey, color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
size: 48, size: 48,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
isMinimalModeEnabled ? 'Minimal Mode Active' : 'Minimal Mode Disabled', isMinimalModeEnabled
? 'Minimal Mode Active'
: 'Minimal Mode Disabled',
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey, color: isMinimalModeEnabled
? Colors.redAccent
: Colors.grey,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
isMinimalModeEnabled isMinimalModeEnabled
? 'Distractions are blocked. Customize which features stay enabled below.' ? 'Distractions are blocked. Customize which features stay enabled below.'
: 'Turn on to block all distractions at once, or customize individual settings below.', : 'Turn on to block all distractions at once, or customize individual settings below.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: isDark ? Colors.white54 : Colors.black54), style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white54 : Colors.black54,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: isMinimalModeEnabled ? _turnOffMinimalMode : _turnOnMinimalMode, onPressed: isMinimalModeEnabled
? _turnOffMinimalMode
: _turnOnMinimalMode,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: isMinimalModeEnabled ? Colors.grey : Colors.redAccent, backgroundColor: isMinimalModeEnabled
? Colors.grey
: Colors.redAccent,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
isMinimalModeEnabled
? 'Turn Off Minimal Mode'
: 'Turn On Minimal Mode',
), ),
child: Text(isMinimalModeEnabled ? 'Turn Off Minimal Mode' : 'Turn On Minimal Mode'),
), ),
), ),
], ],
), ),
), ),
const _SectionHeader(title: 'CUSTOMIZE SETTINGS'), const _SectionHeader(title: 'CUSTOMIZE SETTINGS'),
Container( Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -502,7 +698,11 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
), ),
child: const Row( child: const Row(
children: [ children: [
Icon(Icons.touch_app_rounded, size: 14, color: Colors.blueAccent), Icon(
Icons.touch_app_rounded,
size: 14,
color: Colors.blueAccent,
),
SizedBox(width: 8), SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@@ -513,13 +713,19 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
], ],
), ),
), ),
_SwitchTile( _SwitchTile(
title: 'Blur Feed & Explore', title: 'Blur Feed & Explore',
subtitle: 'Blurs post thumbnails until tapped', subtitle: 'Blurs post thumbnails until tapped',
value: _blurExplore, value: _blurExplore,
onChanged: (v) => _updateSetting('blurExplore', v), onChanged: (v) => _updateSetting('blurExplore', v),
), ),
_SwitchTile(
title: 'Block Home Feed Scroll',
subtitle: 'Freeze vertical scrolling on the home feed only',
value: _blockHomeFeedScroll,
onChanged: (v) => _updateSetting('blockHomeFeedScroll', v),
),
_SwitchTile( _SwitchTile(
title: 'Disable Reels Entirely', title: 'Disable Reels Entirely',
subtitle: 'Block all Reels with no session option', subtitle: 'Block all Reels with no session option',
@@ -532,7 +738,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
value: _disableExploreEntirely, value: _disableExploreEntirely,
onChanged: (v) => _updateSetting('disableExploreEntirely', v), onChanged: (v) => _updateSetting('disableExploreEntirely', v),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
], ],
), ),
@@ -550,43 +756,52 @@ class AppearancePage extends StatefulWidget {
} }
class _AppearancePageState extends State<AppearancePage> { class _AppearancePageState extends State<AppearancePage> {
Future<void> _addSchedule(BuildContext context, SettingsService settings) async { Future<void> _addSchedule(
BuildContext context,
SettingsService settings,
) async {
TimeOfDay? startTime = await showTimePicker( TimeOfDay? startTime = await showTimePicker(
context: context, context: context,
initialTime: const TimeOfDay(hour: 21, minute: 0), initialTime: const TimeOfDay(hour: 21, minute: 0),
helpText: 'Select start time', helpText: 'Select start time',
); );
if (startTime == null || !context.mounted) return; if (startTime == null || !context.mounted) return;
TimeOfDay? endTime = await showTimePicker( TimeOfDay? endTime = await showTimePicker(
context: context, context: context,
initialTime: const TimeOfDay(hour: 6, minute: 0), initialTime: const TimeOfDay(hour: 6, minute: 0),
helpText: 'Select end time', helpText: 'Select end time',
); );
if (endTime == null || !context.mounted) return; if (endTime == null || !context.mounted) return;
final newSchedule = { final newSchedule = {
'enabled': true, 'enabled': true,
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}', 'startTime':
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}', '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
'endTime':
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
}; };
await settings.addGrayscaleSchedule(newSchedule); await settings.addGrayscaleSchedule(newSchedule);
} }
Future<void> _editSchedule(BuildContext context, SettingsService settings, int index) async { Future<void> _editSchedule(
BuildContext context,
SettingsService settings,
int index,
) async {
final schedules = settings.grayscaleSchedules; final schedules = settings.grayscaleSchedules;
if (index >= schedules.length) return; if (index >= schedules.length) return;
final current = schedules[index]; final current = schedules[index];
final startParts = (current['startTime'] as String).split(':'); final startParts = (current['startTime'] as String).split(':');
final endParts = (current['endTime'] as String).split(':'); final endParts = (current['endTime'] as String).split(':');
// Capture context before async gap // Capture context before async gap
final capturedContext = context; final capturedContext = context;
TimeOfDay? startTime = await showTimePicker( TimeOfDay? startTime = await showTimePicker(
context: capturedContext, context: capturedContext,
initialTime: TimeOfDay( initialTime: TimeOfDay(
@@ -595,9 +810,9 @@ class _AppearancePageState extends State<AppearancePage> {
), ),
helpText: 'Select start time', helpText: 'Select start time',
); );
if (startTime == null || !capturedContext.mounted) return; if (startTime == null || !capturedContext.mounted) return;
TimeOfDay? endTime = await showTimePicker( TimeOfDay? endTime = await showTimePicker(
context: capturedContext, context: capturedContext,
initialTime: TimeOfDay( initialTime: TimeOfDay(
@@ -606,27 +821,31 @@ class _AppearancePageState extends State<AppearancePage> {
), ),
helpText: 'Select end time', helpText: 'Select end time',
); );
if (endTime == null || !capturedContext.mounted) return; if (endTime == null || !capturedContext.mounted) return;
final updatedSchedule = { final updatedSchedule = {
...current, ...current,
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}', 'startTime':
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}', '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
'endTime':
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
}; };
await settings.updateGrayscaleSchedule(index, updatedSchedule); await settings.updateGrayscaleSchedule(index, updatedSchedule);
} }
Future<void> _toggleSchedule(SettingsService settings, int index) async { Future<void> _toggleSchedule(SettingsService settings, int index) async {
final schedules = List<Map<String, dynamic>>.from(settings.grayscaleSchedules); final schedules = List<Map<String, dynamic>>.from(
settings.grayscaleSchedules,
);
if (index >= schedules.length) return; if (index >= schedules.length) return;
schedules[index] = { schedules[index] = {
...schedules[index], ...schedules[index],
'enabled': !(schedules[index]['enabled'] as bool), 'enabled': !(schedules[index]['enabled'] as bool),
}; };
await settings.setGrayscaleSchedules(schedules); await settings.setGrayscaleSchedules(schedules);
} }
@@ -648,7 +867,7 @@ class _AppearancePageState extends State<AppearancePage> {
], ],
), ),
); );
if (confirmed == true) { if (confirmed == true) {
await settings.removeGrayscaleSchedule(index); await settings.removeGrayscaleSchedule(index);
} }
@@ -669,7 +888,8 @@ class _AppearancePageState extends State<AppearancePage> {
const _SectionHeader(title: 'DISPLAY'), const _SectionHeader(title: 'DISPLAY'),
_SwitchTile( _SwitchTile(
title: 'Grayscale Mode', title: 'Grayscale Mode',
subtitle: 'Makes Instagram black & white — reduces dopamine response', subtitle:
'Makes Instagram black & white — reduces dopamine response',
value: settings.grayscaleEnabled, value: settings.grayscaleEnabled,
onChanged: (v) => settings.setGrayscaleEnabled(v), onChanged: (v) => settings.setGrayscaleEnabled(v),
), ),
@@ -687,7 +907,7 @@ class _AppearancePageState extends State<AppearancePage> {
style: TextStyle(fontSize: 12, height: 1.5), style: TextStyle(fontSize: 12, height: 1.5),
), ),
), ),
// Status indicator // Status indicator
if (settings.grayscaleSchedules.isNotEmpty) if (settings.grayscaleSchedules.isNotEmpty)
Padding( Padding(
@@ -695,26 +915,38 @@ class _AppearancePageState extends State<AppearancePage> {
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.1) : Colors.orange.withValues(alpha: 0.1), color: settings.isGrayscaleActiveNow
? Colors.green.withValues(alpha: 0.1)
: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.3) : Colors.orange.withValues(alpha: 0.3)), border: Border.all(
color: settings.isGrayscaleActiveNow
? Colors.green.withValues(alpha: 0.3)
: Colors.orange.withValues(alpha: 0.3),
),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
settings.isGrayscaleActiveNow ? Icons.check_circle : Icons.schedule, settings.isGrayscaleActiveNow
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent, ? Icons.check_circle
size: 20 : Icons.schedule,
color: settings.isGrayscaleActiveNow
? Colors.greenAccent
: Colors.orangeAccent,
size: 20,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
settings.isGrayscaleActiveNow settings.isGrayscaleActiveNow
? 'Grayscale is active now' ? 'Grayscale is active now'
: 'Grayscale is currently inactive', : 'Grayscale is currently inactive',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent color: settings.isGrayscaleActiveNow
? Colors.greenAccent
: Colors.orangeAccent,
), ),
), ),
), ),
@@ -722,7 +954,7 @@ class _AppearancePageState extends State<AppearancePage> {
), ),
), ),
), ),
// Schedule list // Schedule list
...List.generate(settings.grayscaleSchedules.length, (index) { ...List.generate(settings.grayscaleSchedules.length, (index) {
final schedule = settings.grayscaleSchedules[index]; final schedule = settings.grayscaleSchedules[index];
@@ -732,11 +964,14 @@ class _AppearancePageState extends State<AppearancePage> {
width: 36, width: 36,
height: 36, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
color: (isEnabled ? Colors.purpleAccent : Colors.grey).withValues(alpha: 0.12), color: (isEnabled ? Colors.purpleAccent : Colors.grey)
.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Icon( child: Icon(
isEnabled ? Icons.play_circle_outline : Icons.pause_circle_outline, isEnabled
? Icons.play_circle_outline
: Icons.pause_circle_outline,
color: isEnabled ? Colors.purpleAccent : Colors.grey, color: isEnabled ? Colors.purpleAccent : Colors.grey,
size: 20, size: 20,
), ),
@@ -750,7 +985,10 @@ class _AppearancePageState extends State<AppearancePage> {
), ),
subtitle: Text( subtitle: Text(
isEnabled ? 'Active' : 'Disabled', isEnabled ? 'Active' : 'Disabled',
style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45), style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black45,
),
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -795,7 +1033,7 @@ class _AppearancePageState extends State<AppearancePage> {
onTap: () => _editSchedule(context, settings, index), onTap: () => _editSchedule(context, settings, index),
); );
}), }),
// Add schedule button // Add schedule button
ListTile( ListTile(
leading: Container( leading: Container(
@@ -805,16 +1043,26 @@ class _AppearancePageState extends State<AppearancePage> {
color: Colors.green.withValues(alpha: 0.12), color: Colors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon(Icons.add_circle_outline, color: Colors.green, size: 20), child: const Icon(
Icons.add_circle_outline,
color: Colors.green,
size: 20,
),
),
title: const Text(
'Add Schedule',
style: TextStyle(color: Colors.green),
), ),
title: const Text('Add Schedule', style: TextStyle(color: Colors.green)),
subtitle: Text( subtitle: Text(
'Add a new grayscale schedule', 'Add a new grayscale schedule',
style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45), style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white54 : Colors.black45,
),
), ),
onTap: () => _addSchedule(context, settings), onTap: () => _addSchedule(context, settings),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
], ],
), ),
@@ -966,15 +1214,9 @@ class _SwitchTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SwitchListTile( return SwitchListTile(
title: Text( title: Text(title, style: const TextStyle(fontSize: 15)),
title,
style: const TextStyle(fontSize: 15),
),
subtitle: subtitle != null subtitle: subtitle != null
? Text( ? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
subtitle ?? '',
style: const TextStyle(fontSize: 12),
)
: null, : null,
value: value, value: value,
onChanged: onChanged, onChanged: onChanged,
@@ -982,6 +1224,131 @@ class _SwitchTile extends StatelessWidget {
} }
} }
class _ChoiceTile<T> extends StatelessWidget {
final String title;
final T value;
final String label;
final List<T> options;
final String Function(T value) optionLabel;
final ValueChanged<T> onSelected;
const _ChoiceTile({
required this.title,
required this.value,
required this.label,
required this.options,
required this.optionLabel,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: Text(label, style: const TextStyle(fontSize: 12)),
trailing: PopupMenuButton<T>(
initialValue: value,
onSelected: onSelected,
itemBuilder: (context) => options
.map(
(option) => PopupMenuItem<T>(
value: option,
child: Text(optionLabel(option)),
),
)
.toList(),
child: const Icon(Icons.expand_more_rounded, size: 22),
),
onTap: () async {
final selected = await showModalBottomSheet<T>(
context: context,
builder: (context) => SafeArea(
child: ListView(
shrinkWrap: true,
children: options
.map(
(option) => ListTile(
title: Text(optionLabel(option)),
trailing: option == value
? const Icon(Icons.check_rounded)
: null,
onTap: () => Navigator.pop(context, option),
),
)
.toList(),
),
),
);
if (selected != null) onSelected(selected);
},
);
}
}
class _NumberEditTile extends StatelessWidget {
final String title;
final String label;
final int initialValue;
final int min;
final int max;
final String suffix;
final ValueChanged<int> onSubmitted;
const _NumberEditTile({
required this.title,
required this.label,
required this.initialValue,
required this.min,
required this.max,
required this.suffix,
required this.onSubmitted,
});
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: Text(label, style: const TextStyle(fontSize: 12)),
trailing: const Icon(Icons.edit_outlined, size: 20),
onTap: () async {
final controller = TextEditingController(text: '$initialValue');
final result = await showDialog<int>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
suffixText: suffix,
helperText: '$min-$max $suffix',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
final parsed = int.tryParse(controller.text.trim());
if (parsed == null) return;
Navigator.pop(dialogContext, parsed.clamp(min, max).toInt());
},
child: const Text('Save'),
),
],
),
);
controller.dispose();
if (result != null) onSubmitted(result);
},
);
}
}
class _SectionHeader extends StatelessWidget { class _SectionHeader extends StatelessWidget {
final String title; final String title;
const _SectionHeader({required this.title}); const _SectionHeader({required this.title});
+7 -6
View File
@@ -277,13 +277,15 @@ const String kReelsMutationObserverJS = r'''
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]'; const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
function lockMode() { function lockMode() {
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled with reel present // Lock DM reels to prevent swipe-to-next, and optionally lock the home
// feed as a separate Minimal Mode control.
const isDmReel = window.location.pathname.includes('/direct/') && const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]'); !!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel'; if (isDmReel) return 'dm_reel';
// Only lock scroll when reel element is actually present on the page if (window.__fgBlockHomeFeedScroll === true &&
if (window.__fgDisableReelsEntirely === true && (window.location.pathname === '/' || window.location.pathname === '')) {
!!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled'; return 'home_feed';
}
return null; return null;
} }
@@ -338,8 +340,7 @@ const String kReelsMutationObserverJS = r'''
try { try {
const mode = lockMode(); const mode = lockMode();
const hasReel = !!document.querySelector(REEL_SEL); const hasReel = !!document.querySelector(REEL_SEL);
// Apply lock for dm_reel or disabled modes when reel is present if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
if (__fgOrigHtmlOverflow === null) { if (__fgOrigHtmlOverflow === null) {
__fgOrigHtmlOverflow = document.documentElement.style.overflow || ''; __fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : ''; __fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../focus_settings.dart';
// Ghost Mode
const String ghostModeJS = '''
const _WS = window.WebSocket;
window.WebSocket = function(url, protocols) {
if (url.includes('edge-chat.instagram.com') ||
url.includes('gateway.instagram.com')) {
return {
send: ()=>{}, close: ()=>{},
readyState: 1,
addEventListener: ()=>{},
removeEventListener: ()=>{},
};
}
return new _WS(url, protocols);
};
window.WebSocket.prototype = _WS.prototype;
''';
// No Story Tray
const String hideStoryTrayJS = '''
const style = document.createElement('style');
style.textContent = '[data-pagelet="story_tray"] { display: none !important; }';
document.head.appendChild(style);
''';
// No Autoplay
const String noAutoplayJS = '''
document.addEventListener('play', function(e) {
if (e.target.tagName === 'VIDEO') {
e.target.pause();
}
}, true);
''';
// No Reels / Explore
const String hideReelsJS = '''
const hideReels = () => {
// nav bar reels icon
document.querySelectorAll('a[href="/reels/"]').forEach(el => {
el.closest('div')?.style.setProperty('display', 'none', 'important');
});
// explore page
document.querySelectorAll('a[href="/explore/"]').forEach(el => {
el.closest('div')?.style.setProperty('display', 'none', 'important');
});
};
new MutationObserver(hideReels).observe(document.body, {
childList: true,
subtree: true
});
hideReels();
''';
// No DMs
const String hideDMsJS = '''
const style = document.createElement('style');
style.textContent = 'a[href="/direct/inbox/"] { display: none !important; }';
document.head.appendChild(style);
''';
List<UserScript> buildUserScripts(FocusSettings settings) {
final startScripts = <String>[];
final endScripts = <String>[];
// AT_DOCUMENT_START scripts
if (settings.ghostMode) startScripts.add(ghostModeJS);
if (settings.noAutoplay) startScripts.add(noAutoplayJS);
// AT_DOCUMENT_END scripts
if (settings.noStories) endScripts.add(hideStoryTrayJS);
if (settings.noReels) endScripts.add(hideReelsJS);
if (settings.noDMs) endScripts.add(hideDMsJS);
final scripts = <UserScript>[];
if (startScripts.isNotEmpty) {
scripts.add(UserScript(
source: startScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: false,
));
}
if (endScripts.isNotEmpty) {
scripts.add(UserScript(
source: endScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
forMainFrameOnly: true,
));
}
return scripts;
}
+355
View File
@@ -0,0 +1,355 @@
/// Best-effort Instagram media downloader UI.
///
/// The script only exposes URLs already rendered in the WebView. It cannot
/// decrypt or fetch media that Instagram has not loaded, but it covers visible
/// feed posts, reels, profile avatars, and DM visual/video messages.
const String kVideoDownloadJS = r'''
(function() {
'use strict';
if (window.__fgMediaDownloadRunning) return;
window.__fgMediaDownloadRunning = true;
const BTN_ATTR = 'data-fg-download-btn';
const URL_ATTR = 'data-fg-download-url';
const TYPE_ATTR = 'data-fg-download-type';
const MAX_PER_PASS = 60;
function text(value) {
try { return (value || '').toString(); } catch (_) { return ''; }
}
function isHttp(value) {
const s = text(value);
return s.indexOf('https://') === 0 || s.indexOf('http://') === 0;
}
function cleanUrl(value) {
const s = text(value).trim();
if (!isHttp(s)) return null;
return s.replace(/&amp;/g, '&');
}
function bestFromSrcset(srcset) {
const raw = text(srcset);
if (!raw) return null;
let best = null;
let bestScore = -1;
raw.split(',').forEach(function(part) {
const bits = part.trim().split(/\s+/);
const url = cleanUrl(bits[0]);
if (!url) return;
const score = parseFloat(text(bits[1]).replace(/[^\d.]/g, '')) || 1;
if (score >= bestScore) {
bestScore = score;
best = url;
}
});
return best;
}
function backgroundUrl(el) {
try {
const bg = window.getComputedStyle(el).backgroundImage || '';
const match = bg.match(/url\(["']?(.*?)["']?\)/);
return match ? cleanUrl(match[1]) : null;
} catch (_) {
return null;
}
}
function urlFromJsonishAttribute(el) {
const attrs = ['data-store', 'data-props', 'data-visualcompletion'];
for (let i = 0; i < attrs.length; i++) {
const value = text(el.getAttribute && el.getAttribute(attrs[i]));
const match = value.match(/https?:\\?\/\\?\/[^"'\s\\]+/);
if (match) return cleanUrl(match[0].replace(/\\\//g, '/'));
}
return null;
}
function mediaUrl(el) {
if (!el) return null;
const tag = text(el.tagName).toLowerCase();
if (tag === 'video') {
return cleanUrl(el.currentSrc || el.src) ||
cleanUrl(el.getAttribute('src')) ||
cleanUrl(el.getAttribute('poster')) ||
firstSource(el);
}
if (tag === 'img') {
return cleanUrl(el.currentSrc || el.src) ||
bestFromSrcset(el.getAttribute('srcset')) ||
cleanUrl(el.getAttribute('src'));
}
return backgroundUrl(el) || urlFromJsonishAttribute(el);
}
function firstSource(video) {
try {
const sources = video.querySelectorAll('source');
for (let i = 0; i < sources.length; i++) {
const url = cleanUrl(sources[i].src || sources[i].getAttribute('src'));
if (url) return url;
}
} catch (_) {}
return null;
}
function typeFrom(el, url) {
const tag = text(el && el.tagName).toLowerCase();
const u = text(url).toLowerCase();
if (tag === 'video' || u.indexOf('.mp4') >= 0 || u.indexOf('.m3u8') >= 0) {
return 'video';
}
return 'photo';
}
function looksLikeAvatar(el) {
try {
const img = el && el.tagName && el.tagName.toLowerCase() === 'img' ? el : null;
if (!img) return false;
const alt = text(img.getAttribute('alt')).toLowerCase();
const r = img.getBoundingClientRect();
const rounded =
window.getComputedStyle(img).borderRadius.indexOf('%') >= 0 ||
parseFloat(window.getComputedStyle(img).borderRadius) >= Math.min(r.width, r.height) / 3;
return r.width <= 72 && r.height <= 72 && (rounded || alt.indexOf('profile') >= 0 || alt.indexOf('avatar') >= 0);
} catch (_) {
return false;
}
}
function mediaScore(item) {
try {
const r = item.el.getBoundingClientRect();
let score = Math.max(0, r.width) * Math.max(0, r.height);
if (item.type === 'video') score += 10000000;
if (looksLikeAvatar(item.el)) score -= 10000000;
if (text(item.url).toLowerCase().indexOf('s150x150') >= 0) score -= 5000000;
return score;
} catch (_) {
return 0;
}
}
function filename(type) {
const ext = type === 'video' ? 'mp4' : 'jpg';
return 'focusgram_' + type + '_' + Date.now() + '.' + ext;
}
function inView(el) {
try {
const r = el.getBoundingClientRect();
return r.width > 24 && r.height > 24 && r.bottom > 0 && r.top < window.innerHeight;
} catch (_) {
return false;
}
}
function icon() {
return '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
}
function sendDownload(url, type) {
try {
if (!url || !window.flutter_inappwebview || !window.flutter_inappwebview.callHandler) return;
window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON.stringify({
type: type,
url: url,
filename: filename(type),
}));
} catch (_) {}
}
function makeButton(url, type, mode) {
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute(BTN_ATTR, '1');
btn.setAttribute(URL_ATTR, url);
btn.setAttribute(TYPE_ATTR, type);
btn.setAttribute('aria-label', 'Download media');
btn.innerHTML = icon();
btn.style.cssText = [
'position:absolute',
'z-index:2147483647',
'width:34px',
'height:34px',
'border-radius:10px',
'border:1px solid rgba(255,255,255,.18)',
'background:' + (mode === 'inline' ? 'transparent' : 'rgba(0,0,0,.58)'),
'color:rgba(255,255,255,.94)',
'display:flex',
'align-items:center',
'justify-content:center',
'padding:0',
'cursor:pointer',
'pointer-events:auto',
'backdrop-filter:blur(8px)',
'-webkit-backdrop-filter:blur(8px)',
].join(';');
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sendDownload(btn.getAttribute(URL_ATTR), btn.getAttribute(TYPE_ATTR) || type);
}, true);
return btn;
}
function ensureRelative(container) {
try {
const pos = window.getComputedStyle(container).position;
if (!pos || pos === 'static') container.style.position = 'relative';
} catch (_) {}
}
function placeNearSave(article, url, type) {
const ref = article.querySelector([
'button[aria-label*="Save" i]',
'button[aria-label*="Bookmark" i]',
'svg[aria-label*="Save" i]',
'svg[aria-label*="Bookmark" i]',
'a[href*="/save"]',
].join(','));
if (!ref) return false;
const target = ref.closest('button,a,div') || ref;
const bar = target.parentElement || article;
if (bar.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
const btn = makeButton(url, type, 'inline');
btn.style.position = 'relative';
btn.style.inset = 'auto';
btn.style.marginLeft = '8px';
btn.style.color = 'currentColor';
btn.style.border = '0';
btn.style.backdropFilter = 'none';
btn.style.webkitBackdropFilter = 'none';
try {
target.insertAdjacentElement('afterend', btn);
return true;
} catch (_) {
return false;
}
}
function placeOverlay(container, url, type, where) {
if (!container || container.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
ensureRelative(container);
const btn = makeButton(url, type, 'overlay');
if (where === 'reel') {
btn.style.top = '12px';
btn.style.right = '12px';
} else if (where === 'profile') {
btn.style.top = '8px';
btn.style.right = '8px';
} else {
btn.style.right = '10px';
btn.style.bottom = '10px';
}
container.appendChild(btn);
return true;
}
function visibleMedia(root) {
return Array.prototype.slice.call(root.querySelectorAll('video,img,[style*="background-image"]'))
.filter(inView)
.map(function(el) {
const url = mediaUrl(el);
return url ? { el: el, url: url, type: typeFrom(el, url) } : null;
})
.filter(Boolean);
}
function handleFeed() {
let added = 0;
document.querySelectorAll('article').forEach(function(article) {
if (added >= MAX_PER_PASS || article.querySelector('[' + BTN_ATTR + '="1"]')) return;
const media = visibleMedia(article)
.filter(function(item) { return !looksLikeAvatar(item.el); })
.sort(function(a, b) { return mediaScore(b) - mediaScore(a); })[0];
if (!media) return;
if (placeNearSave(article, media.url, media.type) ||
placeOverlay(article, media.url, media.type, 'feed')) {
added++;
}
});
return added;
}
function handleReels() {
let added = 0;
visibleMedia(document).forEach(function(media) {
if (added >= MAX_PER_PASS) return;
const container =
media.el.closest('[class*="ReelsVideoPlayer"]') ||
media.el.closest('article') ||
media.el.closest('[role="presentation"]') ||
media.el.parentElement;
if (placeOverlay(container, media.url, media.type, 'reel')) added++;
});
return added;
}
function handleDirect() {
let added = 0;
visibleMedia(document).forEach(function(media) {
if (added >= MAX_PER_PASS) return;
const bubble =
media.el.closest('[role="button"]') ||
media.el.closest('div[style*="max-width"]') ||
media.el.closest('article') ||
media.el.parentElement;
if (placeOverlay(bubble, media.url, media.type, 'dm')) added++;
});
return added;
}
function handleProfile() {
let added = 0;
const path = window.location.pathname || '/';
if (path === '/' || path.indexOf('/explore') === 0 || path.indexOf('/direct') === 0) return 0;
document.querySelectorAll('header img,img[alt*="profile" i],img[alt*="avatar" i]').forEach(function(img) {
if (added >= 4 || !inView(img)) return;
const url = mediaUrl(img);
if (!url) return;
const r = img.getBoundingClientRect();
if (r.width < 56 && r.height < 56) return;
const container = img.closest('div') || img.parentElement;
if (placeOverlay(container, url, 'photo', 'profile')) added++;
});
return added;
}
function pass() {
try {
const path = window.location.pathname || '/';
if (path.indexOf('/direct') === 0) {
handleDirect();
} else if (path.indexOf('/reels') === 0 || path.indexOf('/reel/') >= 0) {
handleReels();
} else {
handleFeed();
handleProfile();
}
} catch (_) {}
}
let timer = null;
function schedule() {
clearTimeout(timer);
timer = setTimeout(pass, 220);
}
new MutationObserver(schedule).observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'srcset', 'style'],
});
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule, { passive: true });
window.addEventListener('focus', schedule, { passive: true });
pass();
})();
''';
@@ -0,0 +1,430 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class AdblockContentBlockerData {
final List<ContentBlocker> contentBlockers;
final Set<String> blockedHosts;
final String sourceTag;
const AdblockContentBlockerData({
required this.contentBlockers,
required this.blockedHosts,
required this.sourceTag,
});
Map<String, dynamic> toJson() => {
'sourceTag': sourceTag,
'hosts': blockedHosts.toList(),
// We cant safely serialize ContentBlocker objects; rebuild from hosts.
// contentBlockers will always be regenerated from hosts when restoring.
};
static AdblockContentBlockerData fromJson(Map<String, dynamic> json) {
final hosts =
(json['hosts'] as List?)?.whereType<String>().toSet() ?? <String>{};
return AdblockContentBlockerData(
contentBlockers: hosts
.map(
(h) => ContentBlocker(
trigger: ContentBlockerTrigger(
urlFilter: AdblockContentBlockerLoader._urlFilterForHost(h),
),
action: ContentBlockerAction(
type: ContentBlockerActionType.BLOCK,
),
),
)
.toList(growable: false),
blockedHosts: hosts,
sourceTag: (json['sourceTag'] as String?) ?? 'cached',
);
}
}
class AdblockContentBlockerLoader {
// Cache keys
static const _keyCache = 'adblock_cb_cache_v2';
static const _keyCacheUpdatedAt = 'adblock_cb_cache_updated_at_v1';
static const _keySourceCache = 'adblock_source_cache_v1';
static const _maxContentBlockerRules = 5000;
// Raw GitHub sources, intentionally split by repository sections so the app
// follows upstream changes without depending on third-party packaged mirrors.
static const _sources = <_SourceSpec>[
// uBlock Origin built-in Annoyances family:
// https://github.com/uBlockOrigin/uAssets/tree/master/filters
_SourceSpec(
tag: 'ublock_annoyances',
url:
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt',
),
_SourceSpec(
tag: 'ublock_annoyances_cookies',
url:
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-cookies.txt',
),
_SourceSpec(
tag: 'ublock_annoyances_others',
url:
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-others.txt',
),
// EasyList network-blocking sections:
// https://github.com/easylist/easylist/tree/master/easylist
_SourceSpec(
tag: 'easylist_adservers',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_adservers.txt',
),
_SourceSpec(
tag: 'easylist_general_block',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_block.txt',
),
_SourceSpec(
tag: 'easylist_specific_block',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_specific_block.txt',
),
_SourceSpec(
tag: 'easylist_thirdparty',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_thirdparty.txt',
),
// AdGuard BaseFilter network-blocking sections:
// https://github.com/AdguardTeam/AdguardFilters/tree/master/BaseFilter/sections
_SourceSpec(
tag: 'adguard_base_adservers',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers.txt',
),
_SourceSpec(
tag: 'adguard_base_adservers_firstparty',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers_firstparty.txt',
),
_SourceSpec(
tag: 'adguard_base_antiadblock',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/antiadblock.txt',
),
_SourceSpec(
tag: 'adguard_base_cryptominers',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/cryptominers.txt',
),
_SourceSpec(
tag: 'adguard_base_general_url',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/general_url.txt',
),
_SourceSpec(
tag: 'adguard_base_specific',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/specific.txt',
),
];
Future<AdblockContentBlockerData> loadOrUpdateIfNeeded({
required bool enabled,
required SharedPreferences prefs,
int timeoutMs = 8000,
}) async {
if (!enabled) {
return const AdblockContentBlockerData(
contentBlockers: [],
blockedHosts: {},
sourceTag: 'disabled',
);
}
final cachedData = _readCachedData(prefs);
final sourceCache = _readSourceCache(prefs);
final fetchResults = await _fetchAllSources(
cache: sourceCache,
timeoutMs: timeoutMs,
);
if (fetchResults.isEmpty && cachedData != null) {
return cachedData;
}
final sourceEntries = <String, _CachedSource>{...sourceCache};
for (final result in fetchResults) {
sourceEntries[result.tag] = result.source;
}
final hosts = sourceEntries.values
.expand((source) => source.hosts)
.where(_isValidHostname)
.toSet();
if (hosts.isEmpty && cachedData != null) {
return cachedData;
}
final data = _buildData(
hosts: hosts,
sourceTag: fetchResults.any((r) => r.changed)
? 'updated-github'
: 'validated-github-cache',
);
await prefs.setString(_keyCache, jsonEncode(data.toJson()));
await prefs.setString(
_keySourceCache,
jsonEncode({
for (final entry in sourceEntries.entries) entry.key: entry.value,
}),
);
await prefs.setInt(
_keyCacheUpdatedAt,
DateTime.now().millisecondsSinceEpoch,
);
return data;
}
AdblockContentBlockerData? _readCachedData(SharedPreferences prefs) {
final cached = prefs.getString(_keyCache);
if (cached == null) return null;
try {
final decoded = jsonDecode(cached) as Map<String, dynamic>;
return AdblockContentBlockerData.fromJson(decoded);
} catch (_) {
return null;
}
}
Map<String, _CachedSource> _readSourceCache(SharedPreferences prefs) {
final cached = prefs.getString(_keySourceCache);
if (cached == null) return {};
try {
final decoded = jsonDecode(cached) as Map<String, dynamic>;
return decoded.map((tag, value) {
return MapEntry(
tag,
_CachedSource.fromJson(value as Map<String, dynamic>),
);
});
} catch (_) {
return {};
}
}
AdblockContentBlockerData _buildData({
required Set<String> hosts,
required String sourceTag,
}) {
final sortedHosts = hosts.toList(growable: false)..sort();
final cappedHosts = sortedHosts.take(_maxContentBlockerRules).toSet();
return AdblockContentBlockerData(
contentBlockers: cappedHosts
.map(
(h) => ContentBlocker(
trigger: ContentBlockerTrigger(urlFilter: _urlFilterForHost(h)),
action: ContentBlockerAction(
type: ContentBlockerActionType.BLOCK,
),
),
)
.toList(growable: false),
blockedHosts: cappedHosts,
sourceTag: sourceTag,
);
}
Future<List<_FetchedSource>> _fetchAllSources({
required Map<String, _CachedSource> cache,
required int timeoutMs,
}) async {
final client = http.Client();
try {
final timeout = Duration(milliseconds: timeoutMs);
return Future.wait(
_sources.map(
(source) => _fetchSource(
client: client,
source: source,
cached: cache[source.tag],
timeout: timeout,
),
),
).then((results) => results.whereType<_FetchedSource>().toList());
} finally {
client.close();
}
}
Future<_FetchedSource?> _fetchSource({
required http.Client client,
required _SourceSpec source,
required _CachedSource? cached,
required Duration timeout,
}) async {
try {
final headers = <String, String>{
if (cached?.etag != null) 'If-None-Match': cached!.etag!,
if (cached?.lastModified != null)
'If-Modified-Since': cached!.lastModified!,
'User-Agent': 'FocusGram-AdblockListUpdater',
};
final res = await client
.get(Uri.parse(source.url), headers: headers)
.timeout(timeout);
if (res.statusCode == 304 && cached != null) {
return _FetchedSource(tag: source.tag, source: cached, changed: false);
}
if (res.statusCode != 200 || res.body.isEmpty) return null;
return _FetchedSource(
tag: source.tag,
source: _CachedSource(
url: source.url,
etag: res.headers['etag'],
lastModified: res.headers['last-modified'],
hosts: parseHostsFromFilterText(res.body),
),
changed: true,
);
} catch (_) {
return null;
}
}
/// Strict/strong: we only extract domain-ish entries from common uBlock/EasyList
/// syntax forms:
/// - ||example.com^
/// - ||example.com/
/// - ||example.com
///
/// We ignore all element-hiding/cosmetic rules and $ options.
@visibleForTesting
static Set<String> parseHostsFromFilterText(String raw) {
final hosts = <String>{};
for (final line in raw.split('\n')) {
final l = line.trim();
if (l.isEmpty) continue;
if (l.startsWith('!')) continue;
if (l.startsWith('@@')) continue;
// Skip comments / metadata
if (l.startsWith('[')) continue;
// Skip cosmetic element-hiding rules
if (l.contains('##') || l.contains('#@#') || l.contains(r'#$#')) {
continue;
}
// uBlock-style host anchors
if (l.startsWith('||')) {
final body = l.substring(2);
// Drop anything after a separator like '^', '/', '?', ' ' (conservative)
// e.g. "example.com^" -> "example.com"
// e.g. "example.com/" -> "example.com"
// e.g. "example.com^$third-party" -> "example.com"
final stopChars = ['^', '/', '?', '\\', '|', '\t', ' ', r'$'];
String host = body;
for (final sc in stopChars) {
final idx = host.indexOf(sc);
if (idx >= 0) host = host.substring(0, idx);
}
host = host.trim();
// Remove leading/trailing dots
host = host
.replaceAll(RegExp(r'^\.+'), '')
.replaceAll(RegExp(r'\.+$'), '');
if (host.isEmpty) continue;
if (host.contains('*') || host.contains(',')) continue;
final normalized = host.toLowerCase();
if (!_isValidHostname(normalized)) continue;
hosts.add(normalized);
}
}
return hosts;
}
static String _urlFilterForHost(String host) {
final escaped = RegExp.escape(host);
return r'^https?://([^/?#]+\.)?'
'$escaped'
r'([/?#:].*)?$';
}
static bool _isValidHostname(String host) {
if (!host.contains('.')) return false;
if (host.length > 255) return false;
if (host.startsWith('.') || host.endsWith('.')) return false;
if (host.contains('..')) return false;
return RegExp(r'^[a-z0-9][a-z0-9.-]*[a-z0-9]$').hasMatch(host);
}
}
class _SourceSpec {
final String tag;
final String url;
const _SourceSpec({required this.tag, required this.url});
}
class _FetchedSource {
final String tag;
final _CachedSource source;
final bool changed;
_FetchedSource({
required this.tag,
required this.source,
required this.changed,
});
}
class _CachedSource {
final String url;
final String? etag;
final String? lastModified;
final Set<String> hosts;
const _CachedSource({
required this.url,
required this.etag,
required this.lastModified,
required this.hosts,
});
factory _CachedSource.fromJson(Map<String, dynamic> json) {
return _CachedSource(
url: (json['url'] as String?) ?? '',
etag: json['etag'] as String?,
lastModified: json['lastModified'] as String?,
hosts: (json['hosts'] as List?)?.whereType<String>().toSet() ?? {},
);
}
Map<String, dynamic> toJson() => {
'url': url,
'etag': etag,
'lastModified': lastModified,
'hosts': hosts.toList(growable: false)..sort(),
};
}
+4 -9
View File
@@ -57,15 +57,15 @@ class InjectionController {
required bool blurReels, required bool blurReels,
required bool tapToUnblur, required bool tapToUnblur,
required bool enableTextSelection, required bool enableTextSelection,
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager required bool hideSuggestedPosts,
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager required bool hideSponsoredPosts,
required bool hideLikeCounts, required bool hideLikeCounts,
required bool hideFollowerCounts, required bool hideFollowerCounts,
// hideStoriesBar parameter removed per user request
required bool hideExploreTab, required bool hideExploreTab,
required bool hideReelsTab, required bool hideReelsTab,
required bool hideShopTab, required bool hideShopTab,
required bool disableReelsEntirely, required bool disableReelsEntirely,
required bool blockHomeFeedScroll,
}) { }) {
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS); final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS); if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
@@ -75,18 +75,12 @@ class InjectionController {
css.writeln(scripts.kHideReelsFeedContentCSS); css.writeln(scripts.kHideReelsFeedContentCSS);
} }
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
// Previously it was inside that block alongside display:none on the parent
// you cannot blur children of a display:none element, making it dead code.
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
// when sessionActive=false, reels are hidden anyway (blur harmless).
if (blurReels) css.writeln(scripts.kBlurReelsCSS); if (blurReels) css.writeln(scripts.kBlurReelsCSS);
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS); if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS); if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS); if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
// Stories hiding removed per user request
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS); if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS); if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS); if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
@@ -94,6 +88,7 @@ class InjectionController {
return ''' return '''
${buildSessionStateJS(sessionActive)} ${buildSessionStateJS(sessionActive)}
window.__fgDisableReelsEntirely = $disableReelsEntirely; window.__fgDisableReelsEntirely = $disableReelsEntirely;
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
window.__fgTapToUnblur = $tapToUnblur; window.__fgTapToUnblur = $tapToUnblur;
${scripts.kTrackPathJS} ${scripts.kTrackPathJS}
${_buildMutationObserver(css.toString())} ${_buildMutationObserver(css.toString())}
+46 -19
View File
@@ -6,6 +6,7 @@ import 'injection_controller.dart';
import '../scripts/grayscale.dart' as grayscale; import '../scripts/grayscale.dart' as grayscale;
import '../scripts/ui_hider.dart' as ui_hider; import '../scripts/ui_hider.dart' as ui_hider;
import '../scripts/content_disabling.dart' as content_disabling; import '../scripts/content_disabling.dart' as content_disabling;
import '../scripts/video_downloader.dart' as video_downloader;
// Core JS and CSS payloads injected into the Instagram WebView. // Core JS and CSS payloads injected into the Instagram WebView.
// //
@@ -386,18 +387,39 @@ const String kLinkSanitizationJS = r'''
// InjectionManager class // InjectionManager class
class InjectionManager { abstract class JsEvaluator {
Future<void> evaluateJavascript({required String source});
}
class _WebViewJsEvaluator implements JsEvaluator {
final InAppWebViewController controller; final InAppWebViewController controller;
_WebViewJsEvaluator(this.controller);
@override
Future<void> evaluateJavascript({required String source}) {
return controller.evaluateJavascript(source: source);
}
}
class InjectionManager {
final JsEvaluator _jsEvaluator;
final SharedPreferences prefs; final SharedPreferences prefs;
final SessionManager sessionManager; final SessionManager sessionManager;
SettingsService? _settingsService; SettingsService? _settingsService;
InjectionManager({ InjectionManager({
required this.controller, required InAppWebViewController controller,
required this.prefs, required this.prefs,
required this.sessionManager, required this.sessionManager,
}); JsEvaluator? jsEvaluator,
}) : _jsEvaluator = jsEvaluator ?? _WebViewJsEvaluator(controller);
InjectionManager.forTest({
required JsEvaluator jsEvaluator,
required this.prefs,
required this.sessionManager,
}) : _jsEvaluator = jsEvaluator;
void setSettingsService(SettingsService settingsService) { void setSettingsService(SettingsService settingsService) {
_settingsService = settingsService; _settingsService = settingsService;
@@ -415,18 +437,19 @@ class InjectionManager {
final blurExplore = settings.blurExplore || settings.minimalModeEnabled; final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
final tapToUnblur = settings.tapToUnblur; final tapToUnblur = settings.tapToUnblur;
final enableTextSelection = settings.enableTextSelection; final enableTextSelection = settings.enableTextSelection;
final hideSponsoredPosts = settings.hideSponsoredPosts;
// Per request: remove ALL Hide Suggested Posts behavior/UI/JS injection.
final hideSuggestedPosts = false;
final hideLikeCounts = settings.hideLikeCounts; final hideLikeCounts = settings.hideLikeCounts;
final hideFollowerCounts = settings.hideFollowerCounts; final hideFollowerCounts = settings.hideFollowerCounts;
// Stories hiding functionality removed per user request
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely // Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
// These are now only controllable via minimal mode submenu // These are now only controllable via minimal mode submenu
final disableExploreEntirely = settings.disableExploreEntirely; final disableExploreEntirely = settings.disableExploreEntirely;
final disableReelsEntirely = settings.disableReelsEntirely; final disableReelsEntirely = settings.disableReelsEntirely;
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
final hideExploreTab = disableExploreEntirely; final hideExploreTab = disableExploreEntirely;
final hideReelsTab = disableReelsEntirely; final hideReelsTab = disableReelsEntirely;
final hideShopTab = settings.hideShopTab; final hideShopTab = settings.hideShopTab;
final isGrayscaleActive = settings.isGrayscaleActiveNow;
final injectionJS = InjectionController.buildInjectionJS( final injectionJS = InjectionController.buildInjectionJS(
sessionActive: sessionActive, sessionActive: sessionActive,
@@ -434,33 +457,35 @@ class InjectionManager {
blurReels: false, // Blur reels feature removed blurReels: false, // Blur reels feature removed
tapToUnblur: blurExplore && tapToUnblur, tapToUnblur: blurExplore && tapToUnblur,
enableTextSelection: enableTextSelection, enableTextSelection: enableTextSelection,
hideSuggestedPosts: false, // Feature removed hideSuggestedPosts: hideSuggestedPosts,
hideSponsoredPosts: hideSponsoredPosts, hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
hideLikeCounts: hideLikeCounts, hideLikeCounts: hideLikeCounts,
hideFollowerCounts: hideFollowerCounts, hideFollowerCounts: hideFollowerCounts,
// hideStoriesBar removed per user request
hideExploreTab: hideExploreTab, hideExploreTab: hideExploreTab,
hideReelsTab: hideReelsTab, hideReelsTab: hideReelsTab,
hideShopTab: hideShopTab, hideShopTab: hideShopTab,
disableReelsEntirely: disableReelsEntirely, disableReelsEntirely: disableReelsEntirely,
blockHomeFeedScroll: blockHomeFeedScroll,
); );
try { try {
await controller.evaluateJavascript(source: injectionJS); await _jsEvaluator.evaluateJavascript(source: injectionJS);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
// Inject grayscale when active, remove when not active // Inject grayscale when active, remove when not active
if (isGrayscaleActive) { if (settings.isGrayscaleActiveNow) {
try { try {
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS); await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
} else { } else {
try { try {
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS); await _jsEvaluator.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
@@ -469,7 +494,9 @@ class InjectionManager {
// Inject hide like counts JS when enabled // Inject hide like counts JS when enabled
if (hideLikeCounts) { if (hideLikeCounts) {
try { try {
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS); await _jsEvaluator.evaluateJavascript(
source: ui_hider.kHideLikeCountsJS,
);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
@@ -478,11 +505,11 @@ class InjectionManager {
// Stories hiding functionality removed per user request // Stories hiding functionality removed per user request
// No stories overlay injection needed // No stories overlay injection needed
// Inject hide sponsored posts JS when enabled // Inject video downloader UI when enabled
if (hideSponsoredPosts) { if (settings.videoDownloadEnabled) {
try { try {
await controller.evaluateJavascript( await _jsEvaluator.evaluateJavascript(
source: ui_hider.kHideSponsoredPostsJS, source: video_downloader.kVideoDownloadJS,
); );
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
@@ -492,7 +519,7 @@ class InjectionManager {
// Inject DM Reel blocker when disableReelsEntirely is enabled // Inject DM Reel blocker when disableReelsEntirely is enabled
if (disableReelsEntirely) { if (disableReelsEntirely) {
try { try {
await controller.evaluateJavascript( await _jsEvaluator.evaluateJavascript(
source: content_disabling.kDmReelBlockerJS, source: content_disabling.kDmReelBlockerJS,
); );
} catch (e) { } catch (e) {
+10 -5
View File
@@ -9,16 +9,16 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin = final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
Future<void> init() async { Future<void> init({bool requestPermissions = false}) async {
const AndroidInitializationSettings initializationSettingsAndroid = const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher'); AndroidInitializationSettings('@mipmap/ic_launcher');
// Request permissions for iOS // Request permissions for iOS
final DarwinInitializationSettings initializationSettingsIOS = final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings( DarwinInitializationSettings(
requestAlertPermission: true, requestAlertPermission: requestPermissions,
requestBadgePermission: true, requestBadgePermission: requestPermissions,
requestSoundPermission: true, requestSoundPermission: requestPermissions,
defaultPresentAlert: true, defaultPresentAlert: true,
defaultPresentBadge: true, defaultPresentBadge: true,
defaultPresentSound: true, defaultPresentSound: true,
@@ -37,7 +37,12 @@ class NotificationService {
}, },
); );
// Request permissions after initialization if (requestPermissions) {
await requestPermissionsNow();
}
}
Future<void> requestPermissionsNow() async {
await _requestIOSPermissions(); await _requestIOSPermissions();
await _requestAndroidPermissions(); await _requestAndroidPermissions();
} }
+5 -6
View File
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
/// ///
/// Storage format (in SharedPreferences, key `screen_time_data`): /// Storage format (in SharedPreferences, key `screen_time_data`):
/// { /// {
/// "2026-02-26": 3420, // seconds /// "2026-05-26": 3420, // seconds
/// "2026-02-25": 1800 /// "2026-05-25": 1800
/// } /// }
/// ///
/// All data stays on-device only. /// All data stays on-device only.
@@ -22,6 +22,8 @@ class ScreenTimeService extends ChangeNotifier {
bool _tracking = false; bool _tracking = false;
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate); Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
int get totalSeconds =>
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
Future<void> init() async { Future<void> init() async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
@@ -37,9 +39,7 @@ class ScreenTimeService extends ChangeNotifier {
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
_secondsByDate = decoded.map( _secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
(k, v) => MapEntry(k, (v as num).toInt()),
);
} }
} catch (_) { } catch (_) {
_secondsByDate = {}; _secondsByDate = {};
@@ -104,4 +104,3 @@ class ScreenTimeService extends ChangeNotifier {
super.dispose(); super.dispose();
} }
} }
+20 -10
View File
@@ -57,6 +57,7 @@ class SessionManager extends ChangeNotifier {
static const _keyAppSessionEnd = 'app_sess_end_ts'; static const _keyAppSessionEnd = 'app_sess_end_ts';
static const _keyAppSessionExtUsed = 'app_sess_ext_used'; static const _keyAppSessionExtUsed = 'app_sess_ext_used';
static const _keyLastAppSessEnd = 'app_sess_last_end_ts'; static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
static const _keyDailyOpenCount = 'app_open_count'; static const _keyDailyOpenCount = 'app_open_count';
static const _keyScheduleEnabled = 'sched_enabled'; static const _keyScheduleEnabled = 'sched_enabled';
static const _keyScheduleStartHour = 'sched_start_h'; static const _keyScheduleStartHour = 'sched_start_h';
@@ -81,6 +82,7 @@ class SessionManager extends ChangeNotifier {
bool _appSessionExpiredFlag = bool _appSessionExpiredFlag =
false; // set when time runs out, waiting for user action false; // set when time runs out, waiting for user action
int _dailyOpenCount = 0; int _dailyOpenCount = 0;
int _lastAppSessionMinutes = 5;
// Scheduled Blocking runtime // Scheduled Blocking runtime
bool _scheduleEnabled = false; bool _scheduleEnabled = false;
@@ -90,8 +92,10 @@ class SessionManager extends ChangeNotifier {
int _schedEndMin = 0; int _schedEndMin = 0;
List<FocusSchedule> _schedules = []; List<FocusSchedule> _schedules = [];
bool _lastScheduleState = false; bool _lastScheduleState = false;
bool _scheduleNotificationShown = false; // Track if schedule notification was shown bool _scheduleNotificationShown =
bool _sessionEndNotificationShown = true; // Default to true to prevent notification on app startup (will be reset when new session starts) false; // Track if schedule notification was shown
bool _sessionEndNotificationShown =
true; // Default to true to prevent notification on app startup (will be reset when new session starts)
bool _isInForeground = true; // Tracking app lifecycle state bool _isInForeground = true; // Tracking app lifecycle state
int _cachedRemainingSessionSeconds = 0; int _cachedRemainingSessionSeconds = 0;
@@ -175,6 +179,7 @@ class SessionManager extends ChangeNotifier {
/// How many times the user has opened the app today. /// How many times the user has opened the app today.
int get dailyOpenCount => _dailyOpenCount; int get dailyOpenCount => _dailyOpenCount;
int get lastAppSessionMinutes => _lastAppSessionMinutes;
// Scheduled Blocking Getters // Scheduled Blocking Getters
bool get scheduleEnabled => _scheduleEnabled; bool get scheduleEnabled => _scheduleEnabled;
@@ -309,6 +314,7 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs); _appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
} }
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false; _appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0; final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
if (lastAppEndMs > 0) { if (lastAppEndMs > 0) {
@@ -375,12 +381,12 @@ class SessionManager extends ChangeNotifier {
} }
} }
// App session expiry check // App session countdown / expiry check
if (_appSessionEnd != null && !_appSessionExpiredFlag) { if (_appSessionEnd != null && !_appSessionExpiredFlag) {
if (DateTime.now().isAfter(_appSessionEnd!)) { if (DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true; _appSessionExpiredFlag = true;
changed = true;
} }
changed = true;
} }
if (isCooldownActive) { if (isCooldownActive) {
@@ -396,7 +402,7 @@ class SessionManager extends ChangeNotifier {
if (sched != _lastScheduleState) { if (sched != _lastScheduleState) {
_lastScheduleState = sched; _lastScheduleState = sched;
changed = true; changed = true;
// Show notification when schedule becomes active // Show notification when schedule becomes active
if (sched && !_scheduleNotificationShown) { if (sched && !_scheduleNotificationShown) {
_scheduleNotificationShown = true; _scheduleNotificationShown = true;
@@ -420,10 +426,11 @@ class SessionManager extends ChangeNotifier {
// (i.e., when loading an expired session from a previous app session) // (i.e., when loading an expired session from a previous app session)
if (showNotification && !_sessionEndNotificationShown) { if (showNotification && !_sessionEndNotificationShown) {
_sessionEndNotificationShown = true; _sessionEndNotificationShown = true;
// Check if user wants session end notifications // Check if user wants session end notifications
final notifySessionEnd = _prefs?.getBool('set_notify_session_end') ?? false; final notifySessionEnd =
_prefs?.getBool('set_notify_session_end') ?? false;
if (notifySessionEnd) { if (notifySessionEnd) {
NotificationService().showNotification( NotificationService().showNotification(
id: 999, id: 999,
@@ -432,7 +439,7 @@ class SessionManager extends ChangeNotifier {
); );
} }
} }
_isSessionActive = false; _isSessionActive = false;
_sessionExpiry = null; _sessionExpiry = null;
_lastSessionEnd = DateTime.now(); _lastSessionEnd = DateTime.now();
@@ -448,7 +455,8 @@ class SessionManager extends ChangeNotifier {
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds); final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed)); _sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
_isSessionActive = true; _isSessionActive = true;
_sessionEndNotificationShown = false; // Reset notification flag for new session _sessionEndNotificationShown =
false; // Reset notification flag for new session
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch); _prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners(); notifyListeners();
return true; return true;
@@ -482,8 +490,10 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = end; _appSessionEnd = end;
_appSessionExpiredFlag = false; _appSessionExpiredFlag = false;
_appExtensionUsed = false; _appExtensionUsed = false;
_lastAppSessionMinutes = minutes;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch); _prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false); _prefs?.setBool(_keyAppSessionExtUsed, false);
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
notifyListeners(); notifyListeners();
} }
+409 -66
View File
@@ -2,14 +2,18 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
/// Stores and retrieves all user-configurable app settings. /// Stores and retrieves all user-configurable app settings.
class SettingsService extends ChangeNotifier { class SettingsService extends ChangeNotifier {
static const _keyBlurExplore = 'set_blur_explore'; static const _keyBlurExplore = 'set_blur_explore';
static const _keyBlurReels = 'set_blur_reels'; static const _keyBlurReels = 'set_blur_reels';
static const _keyTapToUnblur = 'set_tap_to_unblur'; static const _keyTapToUnblur = 'set_tap_to_unblur';
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyShowBreathGate = 'set_show_breath_gate'; static const _keyShowBreathGate = 'set_show_breath_gate';
static const _keyRequireWordChallenge = 'set_require_word_challenge'; static const _keyRequireWordChallenge = 'set_require_word_challenge';
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyBreathGateSeconds = 'breath_gate_seconds';
static const _keyWordChallengeCount = 'word_challenge_count';
static const _keyEnableTextSelection = 'set_enable_text_selection'; static const _keyEnableTextSelection = 'set_enable_text_selection';
static const _keyEnabledTabs = 'set_enabled_tabs'; static const _keyEnabledTabs = 'set_enabled_tabs';
static const _keyShowInstaSettings = 'set_show_insta_settings'; static const _keyShowInstaSettings = 'set_show_insta_settings';
@@ -18,23 +22,42 @@ class SettingsService extends ChangeNotifier {
// Focus / playback // Focus / playback
static const _keyBlockAutoplay = 'block_autoplay'; static const _keyBlockAutoplay = 'block_autoplay';
// Extras (Phase 2)
static const _keyVideoDownloadEnabled = 'video_download_enabled';
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
// FocusGram v2 overlay toggles
static const _keyV2GhostModeEnabled = 'v2_ghost_mode_enabled';
static const _keyV2AdBlockerDomEnabled = 'v2_adblock_dom_enabled';
static const _keyV2ContentHiderEnabled = 'v2_content_hider_enabled';
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
static const _keyContentStories = 'content_stories';
static const _keyContentPosts = 'content_posts';
static const _keyContentReels = 'content_reels';
static const _keyContentSuggested = 'content_suggested';
// Grayscale mode - now supports multiple schedules // Grayscale mode - now supports multiple schedules
static const _keyGrayscaleEnabled = 'grayscale_enabled'; static const _keyGrayscaleEnabled = 'grayscale_enabled';
static const _keyGrayscaleSchedules = 'grayscale_schedules'; static const _keyGrayscaleSchedules = 'grayscale_schedules';
// Content filtering / UI hiding // Content filtering / UI hiding
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
static const _keyHideLikeCounts = 'hide_like_counts'; static const _keyHideLikeCounts = 'hide_like_counts';
static const _keyHideFollowerCounts = 'hide_follower_counts'; static const _keyHideFollowerCounts = 'hide_follower_counts';
static const _keyHideShopTab = 'hide_shop_tab'; static const _keyHideShopTab = 'hide_shop_tab';
// Minimal mode // Minimal mode
static const _keyMinimalModeEnabled = 'minimal_mode_enabled'; static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
// Minimal mode state tracking for smart restore // Minimal mode state tracking for smart restore
static const _keyMinimalModePrevDisableReels = 'minimal_mode_prev_disable_reels'; static const _keyMinimalModePrevDisableReels =
static const _keyMinimalModePrevDisableExplore = 'minimal_mode_prev_disable_explore'; 'minimal_mode_prev_disable_reels';
static const _keyMinimalModePrevBlurExplore = 'minimal_mode_prev_blur_explore'; static const _keyMinimalModePrevDisableExplore =
'minimal_mode_prev_disable_explore';
static const _keyMinimalModePrevBlurExplore =
'minimal_mode_prev_blur_explore';
static const _keyMinimalModePrevBlockHomeFeedScroll =
'minimal_mode_prev_block_home_feed_scroll';
// Reels History // Reels History
static const _keyReelsHistoryEnabled = 'reels_history_enabled'; static const _keyReelsHistoryEnabled = 'reels_history_enabled';
@@ -46,6 +69,14 @@ class SettingsService extends ChangeNotifier {
static const _keyNotifySessionEnd = 'set_notify_session_end'; static const _keyNotifySessionEnd = 'set_notify_session_end';
static const _keyNotifyPersistent = 'set_notify_persistent'; static const _keyNotifyPersistent = 'set_notify_persistent';
// Focus mode settings
static const _keyGhostMode = 'ghost_mode';
static const _keyNoAds = 'no_ads';
static const _keyNoStories = 'no_stories';
static const _keyNoReels = 'no_reels';
static const _keyNoAutoplay = 'no_autoplay';
static const _keyNoDMs = 'no_dms';
SharedPreferences? _prefs; SharedPreferences? _prefs;
bool _blurExplore = true; bool _blurExplore = true;
@@ -54,19 +85,33 @@ class SettingsService extends ChangeNotifier {
bool _requireLongPress = true; bool _requireLongPress = true;
bool _showBreathGate = true; bool _showBreathGate = true;
bool _requireWordChallenge = true; bool _requireWordChallenge = true;
int _breathGateSeconds = 10;
int _wordChallengeCount = 30;
bool _enableTextSelection = false; bool _enableTextSelection = false;
bool _showInstaSettings = true; bool _showInstaSettings = true;
bool _isDarkMode = true; // Default to dark as per existing app theme bool _isDarkMode = true; // Default to dark as per existing app theme
bool _blockAutoplay = true; bool _blockAutoplay = true;
bool _videoDownloadEnabled = false;
bool _hideSuggestedPosts = false;
// FocusGram v2 overlay toggles
bool _v2GhostModeEnabled = false;
bool _v2AdBlockerDomEnabled = false;
bool _v2ContentHiderEnabled = false;
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
bool _contentStories = false;
bool _contentPosts = false;
bool _contentReels = false;
bool _contentSuggested = false;
// Grayscale mode - now supports multiple schedules
bool _grayscaleEnabled = false; bool _grayscaleEnabled = false;
// Grayscale schedules - list of {enabled, startTime, endTime}
// startTime and endTime are in format "HH:MM"
List<Map<String, dynamic>> _grayscaleSchedules = []; List<Map<String, dynamic>> _grayscaleSchedules = [];
bool _hideSponsoredPosts = false; // Content filtering / UI hiding
bool _hideLikeCounts = false; bool _hideLikeCounts = false;
bool _hideFollowerCounts = false; bool _hideFollowerCounts = false;
bool _hideShopTab = false; bool _hideShopTab = false;
@@ -74,12 +119,14 @@ class SettingsService extends ChangeNotifier {
// These are now controlled internally by minimal mode // These are now controlled internally by minimal mode
bool _disableReelsEntirely = false; bool _disableReelsEntirely = false;
bool _disableExploreEntirely = false; bool _disableExploreEntirely = false;
bool _blockHomeFeedScroll = false;
bool _minimalModeEnabled = false; bool _minimalModeEnabled = false;
// Tracking for smart restore // Tracking for smart restore
bool _prevDisableReels = false; bool _prevDisableReels = false;
bool _prevDisableExplore = false; bool _prevDisableExplore = false;
bool _prevBlurExplore = false; bool _prevBlurExplore = false;
bool _prevBlockHomeFeedScroll = false;
bool _reelsHistoryEnabled = true; bool _reelsHistoryEnabled = true;
@@ -90,6 +137,14 @@ class SettingsService extends ChangeNotifier {
bool _notifySessionEnd = false; bool _notifySessionEnd = false;
bool _notifyPersistent = false; bool _notifyPersistent = false;
// Focus mode settings
bool _ghostMode = false;
bool _noAds = false;
bool _noStories = false;
bool _noReels = false;
bool _noAutoplay = false;
bool _noDMs = false;
List<String> _enabledTabs = [ List<String> _enabledTabs = [
'Home', 'Home',
'Search', 'Search',
@@ -105,12 +160,28 @@ class SettingsService extends ChangeNotifier {
bool get requireLongPress => _requireLongPress; bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate; bool get showBreathGate => _showBreathGate;
bool get requireWordChallenge => _requireWordChallenge; bool get requireWordChallenge => _requireWordChallenge;
int get breathGateSeconds => _breathGateSeconds;
int get wordChallengeCount => _wordChallengeCount;
bool get enableTextSelection => _enableTextSelection; bool get enableTextSelection => _enableTextSelection;
bool get showInstaSettings => _showInstaSettings; bool get showInstaSettings => _showInstaSettings;
List<String> get enabledTabs => _enabledTabs; List<String> get enabledTabs => _enabledTabs;
bool get isFirstRun => _isFirstRun; bool get isFirstRun => _isFirstRun;
bool get isDarkMode => _isDarkMode; bool get isDarkMode => _isDarkMode;
bool get blockAutoplay => _blockAutoplay; bool get blockAutoplay => _blockAutoplay;
// Extras (Phase 2)
bool get videoDownloadEnabled => _videoDownloadEnabled;
bool get hideSuggestedPosts => _hideSuggestedPosts;
// FocusGram v2 overlay toggles
bool get v2GhostModeEnabled => _v2GhostModeEnabled;
bool get v2AdBlockerDomEnabled => _v2AdBlockerDomEnabled;
bool get v2ContentHiderEnabled => _v2ContentHiderEnabled;
bool get contentStories => _contentStories;
bool get contentPosts => _contentPosts;
bool get contentReels => _contentReels;
bool get contentSuggested => _contentSuggested;
bool get notifyDMs => _notifyDMs; bool get notifyDMs => _notifyDMs;
bool get notifyActivity => _notifyActivity; bool get notifyActivity => _notifyActivity;
bool get notifySessionEnd => _notifySessionEnd; bool get notifySessionEnd => _notifySessionEnd;
@@ -119,14 +190,22 @@ class SettingsService extends ChangeNotifier {
bool get grayscaleEnabled => _grayscaleEnabled; bool get grayscaleEnabled => _grayscaleEnabled;
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules; List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
bool get hideSponsoredPosts => _hideSponsoredPosts;
bool get hideLikeCounts => _hideLikeCounts; bool get hideLikeCounts => _hideLikeCounts;
bool get hideFollowerCounts => _hideFollowerCounts; bool get hideFollowerCounts => _hideFollowerCounts;
bool get hideShopTab => _hideShopTab; bool get hideShopTab => _hideShopTab;
// Focus mode settings
bool get ghostMode => _ghostMode;
bool get noAds => _noAds;
bool get noStories => _noStories;
bool get noReels => _noReels;
bool get noAutoplay => _noAutoplay;
bool get noDMs => _noDMs;
// These are now controlled by minimal mode only // These are now controlled by minimal mode only
bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely; bool get disableReelsEntirely => _disableReelsEntirely;
bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely; bool get disableExploreEntirely => _disableExploreEntirely;
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
bool get minimalModeEnabled => _minimalModeEnabled; bool get minimalModeEnabled => _minimalModeEnabled;
bool get reelsHistoryEnabled => _reelsHistoryEnabled; bool get reelsHistoryEnabled => _reelsHistoryEnabled;
@@ -136,22 +215,23 @@ class SettingsService extends ChangeNotifier {
bool get isGrayscaleActiveNow { bool get isGrayscaleActiveNow {
if (_grayscaleEnabled) return true; if (_grayscaleEnabled) return true;
if (_grayscaleSchedules.isEmpty) return false; if (_grayscaleSchedules.isEmpty) return false;
final now = DateTime.now(); final now = DateTime.now();
final currentMinutes = now.hour * 60 + now.minute; final currentMinutes = now.hour * 60 + now.minute;
for (final schedule in _grayscaleSchedules) { for (final schedule in _grayscaleSchedules) {
if (schedule['enabled'] != true) continue; if (schedule['enabled'] != true) continue;
try { try {
final startParts = (schedule['startTime'] as String).split(':'); final startParts = (schedule['startTime'] as String).split(':');
final endParts = (schedule['endTime'] as String).split(':'); final endParts = (schedule['endTime'] as String).split(':');
if (startParts.length != 2 || endParts.length != 2) continue; if (startParts.length != 2 || endParts.length != 2) continue;
final startMinutes = int.parse(startParts[0]) * 60 + int.parse(startParts[1]); final startMinutes =
int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]); final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
// Handle overnight schedules (e.g., 21:00 to 06:00) // Handle overnight schedules (e.g., 21:00 to 06:00)
if (endMinutes < startMinutes) { if (endMinutes < startMinutes) {
// Overnight: active if current time is >= start OR < end // Overnight: active if current time is >= start OR < end
@@ -182,43 +262,80 @@ class SettingsService extends ChangeNotifier {
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true; _requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true; _showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true; _requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
_breathGateSeconds = (_prefs!.getInt(_keyBreathGateSeconds) ?? 10)
.clamp(3, 60)
.toInt();
_wordChallengeCount = _normaliseWordChallengeCount(
_prefs!.getInt(_keyWordChallengeCount) ?? 30,
);
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false; _enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true; _showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true; _blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false; // Extras (Phase 2) - defaults OFF for safety/non-invasive behavior
_videoDownloadEnabled = _prefs!.getBool(_keyVideoDownloadEnabled) ?? false;
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
// FocusGram v2 overlay toggles
_v2GhostModeEnabled = _prefs!.getBool(_keyV2GhostModeEnabled) ?? false;
_v2AdBlockerDomEnabled =
_prefs!.getBool(_keyV2AdBlockerDomEnabled) ?? false;
_v2ContentHiderEnabled =
_prefs!.getBool(_keyV2ContentHiderEnabled) ?? false;
_contentStories = _prefs!.getBool(_keyContentStories) ?? false;
_contentPosts = _prefs!.getBool(_keyContentPosts) ?? false;
_contentReels = _prefs!.getBool(_keyContentReels) ?? false;
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
// Load grayscale schedules // Load grayscale schedules
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules); final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
if (schedulesJson != null) { if (schedulesJson != null) {
try { try {
_grayscaleSchedules = List<Map<String, dynamic>>.from( _grayscaleSchedules = List<Map<String, dynamic>>.from(
(jsonDecode(schedulesJson) as List).map((e) => Map<String, dynamic>.from(e)) (jsonDecode(schedulesJson) as List).map(
(e) => Map<String, dynamic>.from(e),
),
); );
} catch (_) { } catch (_) {
_grayscaleSchedules = []; _grayscaleSchedules = [];
} }
} }
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false; _hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false; _hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false; _hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
// Load minimal mode // Load minimal mode
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false; _minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
// Load previous states for smart restore // Load previous states for smart restore
_prevDisableReels = _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false; _prevDisableReels =
_prevDisableExplore = _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false; _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
_prevDisableExplore =
_prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false; _prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
_prevBlockHomeFeedScroll =
_prefs!.getBool(_keyMinimalModePrevBlockHomeFeedScroll) ?? false;
// These are now internal states, not user-facing settings // These are now internal states, not user-facing settings
_disableReelsEntirely = _prefs!.getBool('internal_disable_reels_entirely') ?? false; _disableReelsEntirely =
_disableExploreEntirely = _prefs!.getBool('internal_disable_explore_entirely') ?? false; _prefs!.getBool('internal_disable_reels_entirely') ?? false;
_disableExploreEntirely =
_prefs!.getBool('internal_disable_explore_entirely') ?? false;
_blockHomeFeedScroll =
_prefs!.getBool('internal_block_home_feed_scroll') ?? false;
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true; _reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
// Focus mode settings
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
_noReels = _prefs!.getBool(_keyNoReels) ?? false;
_noAutoplay = _prefs!.getBool(_keyNoAutoplay) ?? false;
_noDMs = _prefs!.getBool(_keyNoDMs) ?? false;
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true; _sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false; _notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false; _notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
@@ -245,12 +362,12 @@ class SettingsService extends ChangeNotifier {
Future<void> setBlurExplore(bool v) async { Future<void> setBlurExplore(bool v) async {
_blurExplore = v; _blurExplore = v;
// Sync blur explore with blur reels - enabling one enables the other
if (v && !_blurReels) {
_blurReels = true;
await _prefs?.setBool(_keyBlurReels, true);
}
await _prefs?.setBool(_keyBlurExplore, v); await _prefs?.setBool(_keyBlurExplore, v);
if (_minimalModeEnabled) {
await _checkAndAutoDisableMinimalMode();
}
notifyListeners(); notifyListeners();
} }
@@ -289,6 +406,30 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> setBreathGateSeconds(int seconds) async {
_breathGateSeconds = seconds.clamp(3, 60).toInt();
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
notifyListeners();
}
Future<void> setWordChallengeCount(int count) async {
_wordChallengeCount = _normaliseWordChallengeCount(count);
await _prefs?.setInt(_keyWordChallengeCount, _wordChallengeCount);
notifyListeners();
}
int resolvedWordChallengeCount() {
if (_wordChallengeCount != 0) return _wordChallengeCount;
final now = DateTime.now().microsecondsSinceEpoch;
return 10 + (now % 26);
}
static int _normaliseWordChallengeCount(int count) {
if (count == 0) return 0;
const allowed = [20, 25, 30, 35];
return allowed.contains(count) ? count : 30;
}
Future<void> setEnableTextSelection(bool v) async { Future<void> setEnableTextSelection(bool v) async {
_enableTextSelection = v; _enableTextSelection = v;
await _prefs?.setBool(_keyEnableTextSelection, v); await _prefs?.setBool(_keyEnableTextSelection, v);
@@ -307,13 +448,29 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// Extras (Phase 2)
Future<void> setVideoDownloadEnabled(bool v) async {
_videoDownloadEnabled = v;
await _prefs?.setBool(_keyVideoDownloadEnabled, v);
notifyListeners();
}
Future<void> setHideSuggestedPosts(bool v) async {
_hideSuggestedPosts = v;
await _prefs?.setBool(_keyHideSuggestedPosts, v);
notifyListeners();
}
Future<void> setGrayscaleEnabled(bool v) async { Future<void> setGrayscaleEnabled(bool v) async {
_grayscaleEnabled = v; _grayscaleEnabled = v;
await _prefs?.setBool(_keyGrayscaleEnabled, v); await _prefs?.setBool(_keyGrayscaleEnabled, v);
notifyListeners(); notifyListeners();
} }
Future<void> setGrayscaleSchedules(List<Map<String, dynamic>> schedules) async { Future<void> setGrayscaleSchedules(
List<Map<String, dynamic>> schedules,
) async {
_grayscaleSchedules = schedules; _grayscaleSchedules = schedules;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules)); await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
notifyListeners(); notifyListeners();
@@ -321,14 +478,23 @@ class SettingsService extends ChangeNotifier {
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async { Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
_grayscaleSchedules.add(schedule); _grayscaleSchedules.add(schedule);
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules)); await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners(); notifyListeners();
} }
Future<void> updateGrayscaleSchedule(int index, Map<String, dynamic> schedule) async { Future<void> updateGrayscaleSchedule(
int index,
Map<String, dynamic> schedule,
) async {
if (index >= 0 && index < _grayscaleSchedules.length) { if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules[index] = schedule; _grayscaleSchedules[index] = schedule;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules)); await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners(); notifyListeners();
} }
} }
@@ -336,20 +502,76 @@ class SettingsService extends ChangeNotifier {
Future<void> removeGrayscaleSchedule(int index) async { Future<void> removeGrayscaleSchedule(int index) async {
if (index >= 0 && index < _grayscaleSchedules.length) { if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules.removeAt(index); _grayscaleSchedules.removeAt(index);
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules)); await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners(); notifyListeners();
} }
} }
Future<void> setHideSponsoredPosts(bool v) async { Future<void> setHideShopTab(bool v) async {
_hideSponsoredPosts = v; _hideShopTab = v;
await _prefs?.setBool(_keyHideSponsoredPosts, v); await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners(); notifyListeners();
} }
Future<void> setHideLikeCounts(bool v) async { // FocusGram v2 overlay setters
_hideLikeCounts = v; Future<void> setV2GhostModeEnabled(bool v) async {
await _prefs?.setBool(_keyHideLikeCounts, v); _v2GhostModeEnabled = v;
await _prefs?.setBool(_keyV2GhostModeEnabled, v);
notifyListeners();
}
Future<void> setV2AdBlockerDomEnabled(bool v) async {
_v2AdBlockerDomEnabled = v;
await _prefs?.setBool(_keyV2AdBlockerDomEnabled, v);
notifyListeners();
}
Future<void> setV2ContentHiderEnabled(bool v) async {
_v2ContentHiderEnabled = v;
await _prefs?.setBool(_keyV2ContentHiderEnabled, v);
notifyListeners();
}
Future<void> setContentStoriesEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentStories = v;
await _prefs?.setBool(_keyContentStories, v);
notifyListeners();
}
Future<void> setContentPostsEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentPosts = v;
await _prefs?.setBool(_keyContentPosts, v);
notifyListeners();
}
Future<void> setContentReelsEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentReels = v;
await _prefs?.setBool(_keyContentReels, v);
notifyListeners();
}
Future<void> setContentSuggestedEnabled(bool v) async {
if (v && !_v2ContentHiderEnabled) {
_v2ContentHiderEnabled = true;
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
}
_contentSuggested = v;
await _prefs?.setBool(_keyContentSuggested, v);
notifyListeners(); notifyListeners();
} }
@@ -359,62 +581,138 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> setHideShopTab(bool v) async {
_hideShopTab = v;
await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners();
}
/// Setter for internal disable reels state (used by minimal mode submenu) /// Setter for internal disable reels state (used by minimal mode submenu)
/// Auto-disables minimal mode if all features are turned off
Future<void> setDisableReelsEntirelyInternal(bool v) async { Future<void> setDisableReelsEntirelyInternal(bool v) async {
_disableReelsEntirely = v; _disableReelsEntirely = v;
await _prefs?.setBool('internal_disable_reels_entirely', v); await _prefs?.setBool('internal_disable_reels_entirely', v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners(); notifyListeners();
} }
/// Setter for internal disable explore state (used by minimal mode submenu) /// Setter for internal disable explore state (used by minimal mode submenu)
/// Auto-disables minimal mode if all features are turned off
Future<void> setDisableExploreEntirelyInternal(bool v) async { Future<void> setDisableExploreEntirelyInternal(bool v) async {
_disableExploreEntirely = v; _disableExploreEntirely = v;
await _prefs?.setBool('internal_disable_explore_entirely', v); await _prefs?.setBool('internal_disable_explore_entirely', v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners(); notifyListeners();
} }
/// Setter for home feed scroll blocking state (used by minimal mode submenu).
Future<void> setBlockHomeFeedScrollInternal(bool v) async {
_blockHomeFeedScroll = v;
await _prefs?.setBool('internal_block_home_feed_scroll', v);
await _checkAndAutoDisableMinimalMode();
notifyListeners();
}
/// Helper: Auto-disable minimal mode if all its features are disabled
/// This ensures minimal mode auto-turns-off when user disables all sub-features
///
/// NOTE: We must check the RAW state variables here, NOT the public getters
/// (disableReelsEntirely/disableExploreEntirely), because those getters
/// unconditionally return true when _minimalModeEnabled is true, which would
/// make the "all disabled" condition impossible to reach.
Future<void> _checkAndAutoDisableMinimalMode() async {
if (!_minimalModeEnabled) return;
// Check the RAW saved state, not the getters
final rawReels =
_prefs?.getBool('internal_disable_reels_entirely') ??
_disableReelsEntirely;
final rawExplore =
_prefs?.getBool('internal_disable_explore_entirely') ??
_disableExploreEntirely;
final rawHomeFeedScroll =
_prefs?.getBool('internal_block_home_feed_scroll') ??
_blockHomeFeedScroll;
final allDisabled =
!rawReels && !rawExplore && !rawHomeFeedScroll && !_blurExplore;
if (allDisabled) {
_minimalModeEnabled = false;
await _prefs?.setBool(_keyMinimalModeEnabled, false);
}
}
/// Smart minimal mode toggle with state preservation /// Smart minimal mode toggle with state preservation
Future<void> setMinimalModeEnabled(bool v) async { Future<void> setMinimalModeEnabled(bool v) async {
if (v) { if (v) {
// Turning ON - save current states BEFORE enabling minimal mode // Turning ON
// Save current pre-minimal-mode states so we can restore them later
_prevDisableReels = _disableReelsEntirely; _prevDisableReels = _disableReelsEntirely;
_prevDisableExplore = _disableExploreEntirely; _prevDisableExplore = _disableExploreEntirely;
_prevBlurExplore = _blurExplore; _prevBlurExplore = _blurExplore;
_prevBlockHomeFeedScroll = _blockHomeFeedScroll;
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels); await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore); await _prefs?.setBool(
_keyMinimalModePrevDisableExplore,
_prevDisableExplore,
);
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore); await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
await _prefs?.setBool(
// Enable all minimal mode settings _keyMinimalModePrevBlockHomeFeedScroll,
_prevBlockHomeFeedScroll,
);
_minimalModeEnabled = true; _minimalModeEnabled = true;
_disableReelsEntirely = true; _disableReelsEntirely = true;
_disableExploreEntirely = true; _disableExploreEntirely = true;
_blurExplore = true; _blockHomeFeedScroll = true;
_blurExplore = true; // blurExplore is controlled by minimal mode while ON
await _prefs?.setBool(_keyMinimalModeEnabled, true); await _prefs?.setBool(_keyMinimalModeEnabled, true);
await _prefs?.setBool('internal_disable_reels_entirely', true); await _prefs?.setBool('internal_disable_reels_entirely', true);
await _prefs?.setBool('internal_disable_explore_entirely', true); await _prefs?.setBool('internal_disable_explore_entirely', true);
await _prefs?.setBool('internal_block_home_feed_scroll', true);
await _prefs?.setBool(_keyBlurExplore, true); await _prefs?.setBool(_keyBlurExplore, true);
} else { } else {
// Turning OFF - restore to PREVIOUS states (before minimal mode was turned on) // Turning OFF
// Restore states that were saved BEFORE minimal mode was enabled.
// _prevDisableReels/Explore were saved at the moment minimal mode turned ON.
_minimalModeEnabled = false; _minimalModeEnabled = false;
// Simply restore to the states that were saved BEFORE minimal mode was enabled
_disableReelsEntirely = _prevDisableReels; _disableReelsEntirely = _prevDisableReels;
_disableExploreEntirely = _prevDisableExplore; _disableExploreEntirely = _prevDisableExplore;
_blockHomeFeedScroll = _prevBlockHomeFeedScroll;
// For blurExplore: use _prevBlurExplore if it was saved, otherwise fall back
// to the saved prefs value (covers the case where no prev was saved).
_blurExplore = _prevBlurExplore; _blurExplore = _prevBlurExplore;
// Save the restored states
await _prefs?.setBool(_keyMinimalModeEnabled, false); await _prefs?.setBool(_keyMinimalModeEnabled, false);
await _prefs?.setBool('internal_disable_reels_entirely', _disableReelsEntirely); await _prefs?.setBool(
await _prefs?.setBool('internal_disable_explore_entirely', _disableExploreEntirely); 'internal_disable_reels_entirely',
_disableReelsEntirely,
);
await _prefs?.setBool(
'internal_disable_explore_entirely',
_disableExploreEntirely,
);
await _prefs?.setBool(
'internal_block_home_feed_scroll',
_blockHomeFeedScroll,
);
await _prefs?.setBool(_keyBlurExplore, _blurExplore); await _prefs?.setBool(_keyBlurExplore, _blurExplore);
// After restoring, check whether the user had ALL minimal features OFF
// already if so, minimal mode should stay off (no-op).
if (!_disableReelsEntirely &&
!_disableExploreEntirely &&
!_blockHomeFeedScroll &&
!_blurExplore) {
// All features are off minimal mode correctly stays off. No action needed.
}
} }
notifyListeners(); notifyListeners();
} }
@@ -441,24 +739,69 @@ class SettingsService extends ChangeNotifier {
Future<void> setNotifyDMs(bool v) async { Future<void> setNotifyDMs(bool v) async {
_notifyDMs = v; _notifyDMs = v;
await _prefs?.setBool(_keyNotifyDMs, v); await _prefs?.setBool(_keyNotifyDMs, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners(); notifyListeners();
} }
Future<void> setNotifyActivity(bool v) async { Future<void> setNotifyActivity(bool v) async {
_notifyActivity = v; _notifyActivity = v;
await _prefs?.setBool(_keyNotifyActivity, v); await _prefs?.setBool(_keyNotifyActivity, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners(); notifyListeners();
} }
Future<void> setNotifySessionEnd(bool v) async { Future<void> setNotifySessionEnd(bool v) async {
_notifySessionEnd = v; _notifySessionEnd = v;
await _prefs?.setBool(_keyNotifySessionEnd, v); await _prefs?.setBool(_keyNotifySessionEnd, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners(); notifyListeners();
} }
Future<void> setNotifyPersistent(bool v) async { Future<void> setNotifyPersistent(bool v) async {
_notifyPersistent = v; _notifyPersistent = v;
await _prefs?.setBool(_keyNotifyPersistent, v); await _prefs?.setBool(_keyNotifyPersistent, v);
if (v) {
await NotificationService().requestPermissionsNow();
} else {
await NotificationService().cancelPersistentNotification(id: 5001);
}
notifyListeners();
}
// Focus mode settings
Future<void> setGhostMode(bool v) async {
_ghostMode = v;
await _prefs?.setBool(_keyGhostMode, v);
notifyListeners();
}
Future<void> setNoAds(bool v) async {
_noAds = v;
await _prefs?.setBool(_keyNoAds, v);
notifyListeners();
}
Future<void> setNoStories(bool v) async {
_noStories = v;
await _prefs?.setBool(_keyNoStories, v);
notifyListeners();
}
Future<void> setNoReels(bool v) async {
_noReels = v;
await _prefs?.setBool(_keyNoReels, v);
notifyListeners();
}
Future<void> setNoAutoplay(bool v) async {
_noAutoplay = v;
await _prefs?.setBool(_keyNoAutoplay, v);
notifyListeners();
}
Future<void> setNoDMs(bool v) async {
_noDMs = v;
await _prefs?.setBool(_keyNoDMs, v);
notifyListeners(); notifyListeners();
} }
+1 -1
View File
@@ -517,7 +517,7 @@ class DisciplineChallenge {
]; ];
/// Shows the word challenge dialog. Returns true if successful. /// Shows the word challenge dialog. Returns true if successful.
static Future<bool> show(BuildContext context, {int count = 15}) async { static Future<bool> show(BuildContext context, {int count = 30}) async {
final list = List<String>.from(_words)..shuffle(); final list = List<String>.from(_words)..shuffle();
final challenge = list.take(count).join(' '); final challenge = list.take(count).join(' ');
final controller = TextEditingController(); final controller = TextEditingController();
@@ -0,0 +1,141 @@
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_v2_overlay.dart';
class ScriptEngineV2Overlay {
final InAppWebViewController controller;
final SharedPreferences prefs;
final Map<String, String> _cache = {};
ScriptEngineV2Overlay({required this.controller, required this.prefs});
Future<void> initDocumentStartScripts() async {
for (final s in V2OverlayScriptRegistry.all) {
final enabled = _getEnabled(s.id);
s.enabled = enabled;
if (!enabled) continue;
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
final code = await _load(s.assetPath);
if (code == null) continue;
await controller.addUserScript(
userScript: UserScript(
source: code,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
allowedOriginRules: {'https://www.instagram.com'},
),
);
}
}
}
Future<void> injectDocumentEndScripts() async {
for (final s in V2OverlayScriptRegistry.all) {
final enabled = _getEnabled(s.id);
s.enabled = enabled;
if (!enabled) continue;
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END) {
final code = await _load(s.assetPath);
if (code == null) continue;
try {
await controller.evaluateJavascript(source: code);
} catch (_) {
// Best-effort injection; never crash UI.
}
}
}
await _pushContentFlagsIfNeeded();
}
Future<void> toggle(V2OverlayScriptId id, bool enabled) async {
await prefs.setBool(_enabledKey(id), enabled);
// For DOCUMENT_START scripts, require reload for clean removal.
if (V2OverlayScriptRegistry.byId(id).injectionTime ==
UserScriptInjectionTime.AT_DOCUMENT_START) {
await controller.reload();
return;
}
// For DOCUMENT_END scripts: just reload too to ensure DOM effects stop.
await controller.reload();
}
bool _getEnabled(V2OverlayScriptId id) {
return prefs.getBool(_enabledKey(id)) ??
(id == V2OverlayScriptId.themeDetector);
}
String _enabledKey(V2OverlayScriptId id) => 'fg_v2_${id.name}_enabled';
Future<void> _pushContentFlagsIfNeeded() async {
final contentScriptEnabled = _getEnabled(V2OverlayScriptId.contentHider);
final contentFlags = <String, bool>{
'stories': prefs.getBool('content_stories') ?? false,
'posts': prefs.getBool('content_posts') ?? false,
'reels': prefs.getBool('content_reels') ?? false,
'suggested': prefs.getBool('content_suggested') ?? false,
};
// Apply DOM content hider flags
if (contentScriptEnabled) {
await controller.evaluateJavascript(
source: 'window.__fgContent?.applyAll(${jsonEncode(contentFlags)});',
);
}
// Also push network filter flags used by fetch_interceptor.js
// so toggles actually affect request/response behavior.
final noAds =
(prefs.getBool('no_ads') ?? false) ||
(prefs.getBool(_enabledKey(V2OverlayScriptId.adBlockerDom)) ?? false);
final blockFeedPosts = contentFlags['posts'] ?? false;
final blockSuggested = contentFlags['suggested'] ?? false;
final blockReels = contentFlags['reels'] ?? false;
final blockAutoplay =
prefs.getBool(_enabledKey(V2OverlayScriptId.autoplayBlocker)) ?? false;
await controller.evaluateJavascript(
source:
'window.__fgSetFilterConfig?.(${jsonEncode({
// Strictly requested: when Hide Feed Posts is ON, block ALL graphql/query.
'blockGraphQLQueryWhenFeedPosts': blockFeedPosts,
// Ads blocker: use existing FocusGram "noAds" toggle (wired elsewhere in prefs).
'blockAds': noAds,
'blockSponsored': noAds,
'blockSuggested': blockSuggested,
// Keep video blocking controlled by existing toggles if desired.
'blockVideos': blockReels,
'blockAutoplay': blockAutoplay,
})});',
);
await controller.evaluateJavascript(
source: 'window.__fgSetBlockAutoplay?.($blockAutoplay);',
);
}
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 (_) {
return null;
}
}
}
@@ -0,0 +1,77 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
enum V2OverlayScriptId {
ghostMode,
themeDetector,
adBlockerDom,
contentHider,
fetchInterceptor,
autoplayBlocker,
}
class V2OverlayInstaScript {
final V2OverlayScriptId id;
final String name;
final String assetPath;
final UserScriptInjectionTime injectionTime;
bool enabled;
V2OverlayInstaScript({
required this.id,
required this.name,
required this.assetPath,
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
this.enabled = false,
});
}
class V2OverlayScriptRegistry {
static final List<V2OverlayInstaScript> all = [
V2OverlayInstaScript(
id: V2OverlayScriptId.ghostMode,
name: 'ghost_mode',
assetPath: 'assets/scripts/ghost_mode.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.themeDetector,
name: 'theme_detector',
assetPath: 'assets/scripts/theme_detector.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: true,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.adBlockerDom,
name: 'ad_blocker_dom',
assetPath: 'assets/scripts/ad_blocker_dom.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.contentHider,
name: 'content_hider',
assetPath: 'assets/scripts/content_hider.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.fetchInterceptor,
name: 'fetch_interceptor',
assetPath: 'assets/scripts/fetch_interceptor.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.autoplayBlocker,
name: 'autoplay_blocker',
assetPath: 'assets/scripts/autoplay_blocker.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
];
static V2OverlayInstaScript byId(V2OverlayScriptId id) {
return all.firstWhere((s) => s.id == id);
}
}
-45
View File
@@ -1,45 +0,0 @@
Categories:
- Connectivity
- Social Network
License: AGPL-3.0-only
AuthorName: Ujwal Chapagain
AuthorEmail: notujwal@proton.me
SourceCode: https://github.com/Ujwal223/FocusGram
IssueTracker: https://github.com/Ujwal223/FocusGram/issues
Changelog: https://github.com/Ujwal223/FocusGram/blob/main/CHANGELOG.md
AutoName: FocusGram
RepoType: git
Repo: https://github.com/Ujwal223/FocusGram
Builds:
- versionName: 1.0.0
versionCode: 3
commit: v1.0.0
output: build/app/outputs/flutter-apk/app-release.apk
srclibs:
- flutter@stable
prebuild:
- flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release.yml)
- '[[ $flutterVersion ]]'
- git -C $$flutter$$ checkout -f $flutterVersion
- export PUB_CACHE=$(pwd)/.pub-cache
- .flutter/bin/flutter config --no-analytics
- .flutter/bin/flutter pub get
scanignore:
- .flutter/bin/cache
scandelete:
- .flutter
- .pub-cache
build:
- export PUB_CACHE=$(pwd)/.pub-cache
- .flutter/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"
AutoUpdateMode: Version
UpdateCheckMode: Tags
VercodeOperation:
- '%c * 10 + 1'
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
CurrentVersion: 1.0.0
CurrentVersionCode: 3
+16 -16
View File
@@ -37,10 +37,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: app_settings name: app_settings
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795" sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.1" version: "7.0.0"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -213,10 +213,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: fl_chart name: fl_chart
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08" sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.69.2" version: "0.71.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -290,10 +290,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_launcher_icons name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.1" version: "0.14.4"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -372,10 +372,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: google_fonts name: google_fonts
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.0.2" version: "8.1.0"
gtk: gtk:
dependency: transitive dependency: transitive
description: description:
@@ -596,10 +596,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.3.1" version: "9.0.1"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -668,18 +668,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: permission_handler name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.0.1" version: "11.4.0"
permission_handler_android: permission_handler_android:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_android name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "13.0.1" version: "12.1.0"
permission_handler_apple: permission_handler_apple:
dependency: transitive dependency: transitive
description: description:
@@ -764,10 +764,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.4" version: "2.5.5"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
+17 -11
View File
@@ -11,11 +11,11 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely # WebView engine
flutter_inappwebview: ^6.1.5 flutter_inappwebview: ^6.1.5
# Local key-value persistence — latest stable # Local key-value persistence — latest stable
shared_preferences: ^2.5.4 shared_preferences: ^2.5.5
# Date/time formatting for daily resets — latest stable # Date/time formatting for daily resets — latest stable
intl: ^0.20.2 intl: ^0.20.2
@@ -28,26 +28,26 @@ dependencies:
# URL launcher for About page links — latest stable # URL launcher for About page links — latest stable
url_launcher: ^6.3.2 url_launcher: ^6.3.2
package_info_plus: ^8.1.2 package_info_plus: ^9.0.0
# Handling Instagram deep links — latest stable # Handling Instagram deep links — latest stable
app_links: ^6.3.2 app_links: ^6.4.1
# Open system settings — latest stable # Open system settings — latest stable
app_settings: ^6.1.1 app_settings: ^7.0.0
google_fonts: ^8.0.2 google_fonts: ^8.1.0
http: ^1.3.0 http: ^1.6.0
permission_handler: ^12.0.1 permission_handler: ^11.4.0
# Image/file picker for story uploads on Android # Image/file picker for story uploads on Android
image_picker: ^1.1.2 image_picker: ^1.2.0
flutter_windowmanager_plus: ^1.0.1 flutter_windowmanager_plus: ^1.0.1
# Charts for on-device screen time dashboard (MIT) # Charts for on-device screen time dashboard (MIT)
fl_chart: ^0.69.0 fl_chart: ^0.71.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.14.4
flutter: flutter:
uses-material-design: true uses-material-design: true
@@ -55,6 +55,12 @@ flutter:
assets: assets:
- assets/images/focusgram.png - assets/images/focusgram.png
- assets/images/focusgram.ico - assets/images/focusgram.ico
- assets/scripts/ghost_mode.js
- assets/scripts/ad_blocker_dom.js
- assets/scripts/content_hider.js
- assets/scripts/theme_detector.js
- assets/scripts/fetch_interceptor.js
- assets/scripts/autoplay_blocker.js
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
@@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focusgram/screens/main_webview_page.dart';
void main() {
group('handleFocusGramMediaDownload', () {
test('rejects non-http(s) schemes', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{"type":"video","url":"file:///etc/passwd","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
test('accepts http(s) instagram-like hosts and calls launcher', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{"type":"video","url":"https://cdninstagram.com/v/1.mp4","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isTrue);
expect(launched, hasLength(1));
expect(launched.first.scheme, 'https');
expect(launched.first.host.toLowerCase(), contains('cdninstagram.com'));
});
test('rejects non-instagram hosts even if http(s)', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{"type":"video","url":"https://example.com/video.mp4","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
test('rejects malformed JSON safely', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{not json',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
test('rejects missing url field', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{"type":"video","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
});
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/injection_manager.dart';
import 'package:focusgram/services/adblock/adblock_content_blocker_loader.dart';
import 'package:focusgram/services/session_manager.dart';
import 'package:focusgram/services/settings_service.dart';
class _FakeJsEvaluator implements JsEvaluator {
final List<String> sources = [];
@override
Future<void> evaluateJavascript({required String source}) async {
sources.add(source);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test(
'v2AdBlockerDomEnabled(true) does NOT trigger sponsored-post JS injection (handled by V2 engine)',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setV2AdBlockerDomEnabled(true);
expect(settings.v2AdBlockerDomEnabled, isTrue);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
// Verify that sponsored posts JS injection is NOT triggered by InjectionManager
// (it's handled by the V2 DOM Ad Blocker engine instead)
final sponsoredPostsInjected = fakeEval.sources.any(
(s) => s.contains('hideSponsoredPosts') || s.contains('Sponsored'),
);
expect(
sponsoredPostsInjected,
isFalse,
reason:
'Sponsored posts blocking is now handled by V2 DOM Ad Blocker, not JS injection',
);
},
);
test(
'adblock parser extracts strict host rules and ignores allow/cosmetic rules',
() {
final hosts = AdblockContentBlockerLoader.parseHostsFromFilterText('''
! comment
[Adblock Plus 2.0]
||ads.example.com^
||tracker.example.net/path.js\$third-party
@@||allowed.example.com^
example.com##.sponsored
||wild*.example.com^
||bad,domain.example^
||sub.adguard.example.org^\$script,third-party
''');
expect(
hosts,
containsAll({
'ads.example.com',
'tracker.example.net',
'sub.adguard.example.org',
}),
);
expect(hosts, isNot(contains('allowed.example.com')));
expect(hosts, isNot(contains('wild*.example.com')));
expect(hosts, isNot(contains('bad,domain.example')));
},
);
}
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/injection_manager.dart';
import 'package:focusgram/services/session_manager.dart';
import 'package:focusgram/services/settings_service.dart';
class _FakeJsEvaluator implements JsEvaluator {
final List<String> sources = [];
@override
Future<void> evaluateJavascript({required String source}) async {
sources.add(source);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test(
'does NOT inject hideSuggestedPosts JS even when legacy setting is true',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setHideSuggestedPosts(true);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
expect(any, isFalse);
},
);
test(
'does NOT inject hideSuggestedPosts JS when settings.hideSuggestedPosts=false',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setHideSuggestedPosts(false);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
expect(any, isFalse);
},
);
test(
'injects video downloader JS only when settings.videoDownloadEnabled=true',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setVideoDownloadEnabled(true);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any(
(s) => s.contains('__fgMediaDownloadRunning'),
);
expect(any, isTrue);
},
);
test(
'does NOT inject video downloader JS when settings.videoDownloadEnabled=false',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setVideoDownloadEnabled(false);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any(
(s) => s.contains('__fgMediaDownloadRunning'),
);
expect(any, isFalse);
},
);
test('injects home feed scroll lock flag when enabled', () async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setBlockHomeFeedScrollInternal(true);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any(
(s) => s.contains('window.__fgBlockHomeFeedScroll = true;'),
);
expect(any, isTrue);
});
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focusgram/services/injection_controller.dart';
void main() {
group('InjectionController reels blocker', () {
test('includes strict reels blocker JS when sessionActive=false', () {
final js = InjectionController.buildInjectionJS(
sessionActive: false,
blurExplore: false,
blurReels: false,
tapToUnblur: false,
enableTextSelection: false,
hideSuggestedPosts: false,
hideSponsoredPosts: false,
hideLikeCounts: false,
hideFollowerCounts: false,
hideExploreTab: false,
hideReelsTab: false,
hideShopTab: false,
disableReelsEntirely: false,
blockHomeFeedScroll: false,
);
expect(js, contains('window.__fgReelsBlockPatched'));
expect(js, contains("window.location.href = '/reels/?fg=blocked';"));
});
test(
'does NOT include strict reels blocker JS when sessionActive=true',
() {
final js = InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
tapToUnblur: false,
enableTextSelection: false,
hideSuggestedPosts: false,
hideSponsoredPosts: false,
hideLikeCounts: false,
hideFollowerCounts: false,
hideExploreTab: false,
hideReelsTab: false,
hideShopTab: false,
disableReelsEntirely: false,
blockHomeFeedScroll: false,
);
expect(js, isNot(contains('window.__fgReelsBlockPatched')));
expect(
js,
isNot(contains("window.location.href = '/reels/?fg=blocked';")),
);
},
);
});
}
@@ -0,0 +1,66 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/screen_time_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('init loads persisted secondsByDate', () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
ScreenTimeService.prefKey,
'{"2026-01-01": 42, "2026-01-02": 7}',
);
final s = ScreenTimeService();
await s.init();
expect(s.secondsByDate['2026-01-01'], 42);
expect(s.secondsByDate['2026-01-02'], 7);
});
test('resetAll clears stored data and in-memory map', () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(ScreenTimeService.prefKey, '{"2026-01-01": 42}');
final s = ScreenTimeService();
await s.init();
expect(s.secondsByDate.isNotEmpty, isTrue);
await s.resetAll();
expect(s.secondsByDate, isEmpty);
final raw = prefs.getString(ScreenTimeService.prefKey);
expect(raw, isNull);
});
test('startTracking increments today seconds and stopTracking persists', () async {
final s = ScreenTimeService();
await s.init();
final beforeTodayKey = DateTime.now();
final todayKey =
'${beforeTodayKey.year.toString().padLeft(4, '0')}-'
'${beforeTodayKey.month.toString().padLeft(2, '0')}-'
'${beforeTodayKey.day.toString().padLeft(2, '0')}';
s.startTracking();
// Wait ~2 seconds (test is unit-ish; still acceptable).
await Future<void>.delayed(const Duration(seconds: 2));
s.stopTracking();
expect(s.secondsByDate[todayKey], isNotNull);
expect(s.secondsByDate[todayKey]!, greaterThanOrEqualTo(2));
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(ScreenTimeService.prefKey);
expect(stored, isNotNull);
expect(stored, contains(todayKey));
});
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/settings_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('SettingsService — Phase 2 Extras', () {
test('defaults are OFF for video download/hide suggested', () async {
final s = SettingsService();
await s.init();
expect(s.videoDownloadEnabled, isFalse);
expect(s.hideSuggestedPosts, isFalse);
});
test('setVideoDownloadEnabled persists', () async {
final s = SettingsService();
await s.init();
await s.setVideoDownloadEnabled(true);
final prefs = await SharedPreferences.getInstance();
expect(s.videoDownloadEnabled, isTrue);
expect(prefs.getBool('video_download_enabled'), isTrue);
});
test('setHideSuggestedPosts persists', () async {
final s = SettingsService();
await s.init();
await s.setHideSuggestedPosts(true);
final prefs = await SharedPreferences.getInstance();
expect(s.hideSuggestedPosts, isTrue);
expect(prefs.getBool('hide_suggested_posts'), isTrue);
});
});
group('SettingsService — minimal mode', () {
test(
'home feed scroll can be disabled while minimal mode stays on',
() async {
final s = SettingsService();
await s.init();
await s.setMinimalModeEnabled(true);
await s.setBlockHomeFeedScrollInternal(false);
expect(s.minimalModeEnabled, isTrue);
expect(s.blockHomeFeedScroll, isFalse);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('internal_block_home_feed_scroll'), isFalse);
expect(prefs.getBool('minimal_mode_enabled'), isTrue);
},
);
test(
'minimal mode turns off when all child features are disabled',
() async {
final s = SettingsService();
await s.init();
await s.setMinimalModeEnabled(true);
await s.setBlurExplore(false);
await s.setBlockHomeFeedScrollInternal(false);
await s.setDisableReelsEntirelyInternal(false);
await s.setDisableExploreEntirelyInternal(false);
expect(s.minimalModeEnabled, isFalse);
expect(s.blurExplore, isFalse);
expect(s.blockHomeFeedScroll, isFalse);
expect(s.disableReelsEntirely, isFalse);
expect(s.disableExploreEntirely, isFalse);
},
);
});
group('SettingsService — v2 filtering split', () {
test(
'ad blocker and suggested posts toggles persist independently',
() async {
final s = SettingsService();
await s.init();
await s.setV2AdBlockerDomEnabled(true);
await s.setContentSuggestedEnabled(true);
await s.setV2AdBlockerDomEnabled(false);
expect(s.v2AdBlockerDomEnabled, isFalse);
expect(s.contentSuggested, isTrue);
expect(s.v2ContentHiderEnabled, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('v2_adblock_dom_enabled'), isFalse);
expect(prefs.getBool('content_suggested'), isTrue);
},
);
});
}
+1 -29
View File
@@ -1,41 +1,16 @@
// android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt // android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt
// //
// Adds: // Ghost mode WebView integration notes
// 1. Platform channel for FLAG_SECURE (anti-screenshot at OS level)
// 2. Ghost mode WebView integration notes
package com.focusgram.focusgram package com.focusgram.focusgram
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val CHANNEL = "com.focusgram/window_flags"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
CHANNEL
).setMethodCallHandler { call, result ->
when (call.method) {
"setSecure" -> {
val secure = call.argument<Boolean>("secure") ?: false
if (secure) {
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
result.success(null)
}
else -> result.notImplemented()
}
}
} }
} }
@@ -56,9 +31,6 @@ class MainActivity : FlutterActivity() {
// super.initState(); // super.initState();
// _ghost = GhostModeService(); // _ghost = GhostModeService();
// _ghost.load().then((_) { // _ghost.load().then((_) {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// _ghost.applyWindowFlags(context);
// });
// setState(() {}); // setState(() {});
// }); // });
// } // }
+100
View File
@@ -0,0 +1,100 @@
/**
* FocusGram DOM Ad Blocker
* Removes sponsored posts, "Suggested for you" injections, and ad elements.
* Uses structure-based selectors NOT class names (those change weekly).
* Injected at DOCUMENT_END.
*/
(function () {
'use strict';
// ─── Sponsored text signals (Instagram localizes these) ───────────────────
// We match the STRUCTURE not just English text.
// In IG mobile web, sponsored label appears as a <span> or <div>
// that is a direct sibling/child of the article header area.
const SPONSORED_TEXTS = new Set([
'sponsored', // en
'gesponsert', // de
'patrocinado', // es/pt
'sponsorisé', // fr
'sponsorizzato', // it
'sponsrad', // sv
'sponsoreret', // da
'gesponsord', // nl
'рекламa', // ru
'विज्ञापन', // hi
'广告', // zh
'ad', // en short
]);
const isSponsoredText = (text) =>
SPONSORED_TEXTS.has(text.trim().toLowerCase());
// ─── Remove a single article element ──────────────────────────────────────
const removeArticle = (el) => {
// Walk up to find the article or main feed item container
const target = el.closest('article') ?? el.closest('div[data-media-id]') ?? el;
target.remove();
};
// ─── Core ad scanner ──────────────────────────────────────────────────────
const scanAndRemove = () => {
// Strategy 1: <a href="/ads/..."> inside feed
document.querySelectorAll('a[href*="/ads/"]').forEach((a) => {
a.closest('article')?.remove();
});
// Strategy 2: Sponsored text in article spans
document.querySelectorAll('article').forEach((article) => {
const spans = article.querySelectorAll('span, div');
for (const span of spans) {
if (
span.children.length === 0 && // leaf node
isSponsoredText(span.textContent)
) {
article.remove();
return;
}
}
});
// Strategy 3: "Suggested for you" feed injections
document.querySelectorAll('article, section').forEach((el) => {
const firstText = el.querySelector('span, div, h4')?.textContent?.trim();
if (
firstText &&
(firstText.toLowerCase().startsWith('suggested') ||
firstText.toLowerCase().startsWith('you might') ||
firstText.toLowerCase() === 'posts you might like')
) {
el.remove();
}
});
// Strategy 4: Instagram marks some ad containers with aria-label
document
.querySelectorAll('[aria-label*="Sponsored"], [aria-label*="Ad"]')
.forEach((el) => {
el.closest('article')?.remove();
});
// Strategy 5: Tracking pixel iframes / hidden images
document.querySelectorAll('iframe[width="0"], iframe[height="0"]').forEach((el) => el.remove());
document
.querySelectorAll('img[width="1"][height="1"], img[width="0"][height="0"]')
.forEach((el) => el.remove());
};
// ─── Run on load + watch for new content ──────────────────────────────────
scanAndRemove();
const observer = new MutationObserver((mutations) => {
// Only scan if nodes were added (skip attribute/text changes)
const hasAdditions = mutations.some((m) => m.addedNodes.length > 0);
if (hasAdditions) scanAndRemove();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
})();
+83
View File
@@ -0,0 +1,83 @@
/**
* FocusGram Autoplay Blocker
* Injected at DOCUMENT_START before Instagram's JS loads.
* Prevents video autoplay by:
* 1. Blocking play() calls on video elements
* 2. Disabling autoplay attribute
* 3. Removing preload attributes
*/
(function () {
'use strict';
window.__fgBlockAutoplay = false;
// Override HTMLMediaElement.play() to check our flag
const _play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () {
if (window.__fgBlockAutoplay) {
// Return a resolved promise to avoid breaking Instagram's code
return Promise.resolve();
}
return _play.call(this);
};
// Override autoplay property setter
const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
const _originalAutoplaySetter = _videoDescriptor.set;
Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
set: function (value) {
if (window.__fgBlockAutoplay && value) {
// Silently ignore autoplay attempts when blocking is enabled
return;
}
if (_originalAutoplaySetter) {
_originalAutoplaySetter.call(this, value);
}
},
get: function () {
if (_videoDescriptor.get) {
return _videoDescriptor.get.call(this);
}
return this.getAttribute('autoplay') !== null;
},
enumerable: _videoDescriptor.enumerable,
configurable: true,
});
// On page load and SPA navigation, scan for video elements and remove autoplay
const removeAutoplayFromVideos = () => {
document.querySelectorAll('video, [role="video"]').forEach(el => {
if (window.__fgBlockAutoplay) {
el.autoplay = false;
el.removeAttribute('autoplay');
if (el.paused === false) {
el.pause();
}
}
});
};
// Run on load and when document changes
removeAutoplayFromVideos();
if (!window.__fgAutoplayObserver) {
let _timer = null;
window.__fgAutoplayObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(removeAutoplayFromVideos, 500);
});
window.__fgAutoplayObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// Allow Flutter to toggle
window.__fgSetBlockAutoplay = function (enabled) {
window.__fgBlockAutoplay = !!enabled;
if (enabled) {
removeAutoplayFromVideos();
}
};
})();
+304
View File
@@ -0,0 +1,304 @@
/**
* FocusGram Content Hider
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
* Flutter controls via window.__fgContent.*
* Injected at DOCUMENT_END.
*
* Key fixes applied:
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
* - Stories tray detection strengthened for fresh SPA navigations
* - Suggested posts detection uses multiple text-node matching strategies
*/
(function () {
'use strict';
if (window.__fgContent && window.__fgContent.__focusgramReady) {
return;
}
const STYLE_ID = 'fg-content-hider';
let hideStories = false;
let hidePosts = false;
let hideSuggested = false;
let hideReels = false;
// ─── CSS rules ─────────────────────────────────────────────────────────────
function buildCSS() {
const selectors = [];
if (hideStories) {
selectors.push(
'[role="list"]:has([aria-label*="tory"])',
'[role="listbox"]:has([aria-label*="tory"])',
'[role="menu"] > ul',
'section > div > div:first-child [style*="overflow"]',
'[role="list"] [style*="overflow"]',
);
}
if (hidePosts) {
selectors.push(
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
);
}
// hideReels CSS is intentionally NOT added here.
// We use DOM removal instead (see removeReels()) so that room is never left
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
return selectors.length
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
: '';
}
function applyCSS() {
if (document.body) {
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
}
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = buildCSS();
}
// ─── Story tray JS ─────────────────────────────────────────────────────────
function hideStoryTray() {
if (!hideStories) return;
// Strategy 1: <ul> children of a named list or menu
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
try {
const items = ul.querySelectorAll('li, button, a');
if (items.length < 2) return;
ul.style.setProperty('display', 'none', 'important');
} catch (_) {}
});
// Strategy 2: horizontally scrolling container with circle items
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
try {
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
if (cands.length < 2) return;
const s0 = window.getComputedStyle(cands[0]);
if (s0.width && parseFloat(s0.width) <= 90) {
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
}
} catch (_) {}
});
}
// ─── Suggested posts ───────────────────────────────────────────────────────
function removeSuggested() {
if (!hideSuggested) return;
var SIGNALS = [
'suggested for you',
'suggested posts',
'suggested reels',
'suggested',
'because you watched',
'because you follow',
'you might like',
'posts you might like',
'accounts you might like',
'recommendations',
];
function norm(s) {
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function hasSignal(s) {
var t = norm(s);
if (!t) return false;
return SIGNALS.some(function (signal) {
if (signal === 'suggested') return t === signal;
return t.indexOf(signal) >= 0;
});
}
function hideContainer(from) {
var parent = from;
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
var role = parent.getAttribute && parent.getAttribute('role');
var tag = parent.tagName;
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
if (
tag === 'ARTICLE' ||
tag === 'SECTION' ||
role === 'listitem' ||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
) {
parent.style.setProperty('display', 'none', 'important');
parent.setAttribute('data-fg-hidden-suggested', '1');
return true;
}
parent = parent.parentElement;
}
return false;
}
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
try {
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
var ownLabel = node.getAttribute('aria-label');
if (hasSignal(ownLabel)) { hideContainer(node); return; }
var text = norm(node.innerText || node.textContent || '');
if (
text.indexOf('suggested for you') >= 0 ||
text.indexOf('suggested posts') >= 0 ||
text.indexOf('suggested reels') >= 0 ||
text.indexOf('because you watched') >= 0 ||
text.indexOf('because you follow') >= 0
) {
hideContainer(node);
}
} catch (_) {}
});
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
try {
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
hideContainer(el);
}
} catch (_) {}
});
}
// ─── Reels DOM REMOVE (not display:none) ─────────────────────────────────
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
// gap cleanly and lets the feed flow naturally.
function removeReels() {
if (!hideReels) return;
var toRemove = [];
document.querySelectorAll('article').forEach(function (el) {
try {
// Fast path: check for a reel-signal attribute first
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
if (mt === '2') { toRemove.push(el); return; }
// Fallback: text-node scan for /reels/ markers
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var n;
while ((n = walker.nextNode())) {
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
toRemove.push(el); break;
}
}
} catch (_) {}
});
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
}
// ─── Public API ────────────────────────────────────────────────────────────
window.__fgContent = {
__focusgramReady: true,
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
setHideSuggested: function (val) {
hideSuggested = !!val;
applyCSS();
if (val) removeSuggested();
},
setHideReels: function (val) {
hideReels = !!val;
applyCSS();
if (val) removeReels();
},
applyAll: function (flags) {
hideStories = !!flags.stories;
hidePosts = !!flags.posts;
hideReels = !!flags.reels;
hideSuggested = !!flags.suggested;
applyCSS();
if (hideSuggested) removeSuggested();
if (hideStories) hideStoryTray();
if (hideReels) removeReels();
},
};
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
// pushState/replaceState don't fire any DOM event we can listen for.
// Hook the methods themselves so we know a navigation happened, then debounce
// re-apply. This also catches the case where the MutationObserver was on `body`
// and that node got replaced by Instagram's SPA re-render.
function scheduleReapply() {
clearTimeout(window.__fg_applyTimer);
window.__fg_applyTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 250);
}
var _origPush = history.pushState;
var _origReplace = history.replaceState;
history.pushState = function () {
_origPush.apply(this, arguments);
scheduleReapply();
};
history.replaceState = function () {
_origReplace.apply(this, arguments);
scheduleReapply();
};
// Reinforce on popstate too (user hits back/forward)
window.addEventListener('popstate', scheduleReapply, { passive: true });
// For pushState on the same URL (rare but possible) poll path briefly
window.addEventListener('pageshow', scheduleReapply, { passive: true });
window.addEventListener('focus', scheduleReapply, { passive: true });
// ─── MutationObserver ───────────────────────────────────────────────────────
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
// re-applies everything on each cycle. Does NOT guard on a per-element timer
// that would never re-fire after the body is replaced by SPA re-render.
if (!window.__fgContentObserver) {
window.__fgContentObserver = new MutationObserver(function () {
clearTimeout(window.__fg_moTimer);
window.__fg_moTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 300);
});
// `document.documentElement` survives SPA navigations (body gets replaced
// but <html> stays). Observing it catches both subtree mutations and, via
// the SPA heartbeat above, re-applies after pushState.
window.__fgContentObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// ─── Initial run ────────────────────────────────────────────────────────────
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
// Signal ready — Flutter will call applyAll() with stored prefs
if (window.ContentChannel) {
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+281
View File
@@ -0,0 +1,281 @@
/**
* FocusGram Unified Feed Filter via Fetch Interception
* Injected at DOCUMENT_START before Instagram's JS loads.
*
* This script intercepts GraphQL fetch calls and filters feed content based on:
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
* - Sponsored posts (ad_action_link, ad_header_style)
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
* - Videos/Reels (is_video, media_type, clips_metadata)
* - Autoplay blocking (video autoplay prevention)
*/
(function () {
'use strict';
// Configuration flags (set by Flutter via prefs)
window.__fgFilterConfig = {
blockAds: false,
blockSponsored: false,
blockSuggested: false,
blockVideos: false,
blockAutoplay: false,
blockGraphQLQueryWhenFeedPosts: false,
};
// Helper: Check if a node is an ad
const isAdNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_ad ||
node.ad_action_link ||
node.ad_id ||
(node.product_type && node.product_type === 'ad') ||
(node.ad_header_style && node.ad_header_style !== 'none') ||
(node.__typename && node.__typename === 'GraphAdStory')
);
};
// Helper: Check if a node is sponsored
const isSponsoredNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
(node.ad_action_link && node.ad_action_link.href) ||
(node.ad_header_style && node.ad_header_style !== 'none')
);
};
// Helper: Check if a node is suggested content
const isSuggestedNode = (node) => {
if (!node || typeof node !== 'object') return false;
const typename = String(node.__typename || '');
const reason = JSON.stringify({
reason: node.suggested_reason,
social_context: node.social_context,
title: node.title,
header: node.header,
label: node.label,
}).toLowerCase();
return !!(
node.is_suggested ||
node.is_suggested_for_you ||
node.is_recommendation ||
node.suggested_users ||
node.suggested_media ||
node.suggested_content ||
node.recommendation_source ||
typename.includes('Suggested') ||
typename.includes('Recommendation') ||
reason.includes('suggested') ||
reason.includes('recommend')
);
};
// Helper: Check if a node is a video/reel
const isVideoNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_video ||
(node.media_type === 2) ||
node.clips_metadata ||
(node.__typename && (
node.__typename.includes('Clips') ||
node.__typename.includes('Video')
))
);
};
const isFeedMediaNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.pk ||
node.id ||
node.code ||
node.media_type ||
node.image_versions2 ||
node.video_versions ||
node.carousel_media ||
node.__typename?.includes('Media') ||
node.__typename?.includes('Timeline')
);
};
// Helper: Check for media in carousel
const hasVideoInCarousel = (node) => {
if (!node || typeof node !== 'object') return false;
if (node.media_type === 8) {
const edges = node.edge_sidecar_to_children?.edges || [];
return edges.some(edge => isVideoNode(edge.node));
}
return false;
};
// Main filter function for feed nodes
const shouldFilterNode = (node) => {
const config = window.__fgFilterConfig;
if (!node || typeof node !== 'object') return false;
if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
return true;
}
// Check ads
if (config.blockAds && isAdNode(node)) {
return true;
}
// Check sponsored (separate from ads)
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
return true;
}
// Check suggested content
if (config.blockSuggested && isSuggestedNode(node)) {
return true;
}
// Check videos/reels
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
return true;
}
return false;
};
// Recursively filter GraphQL response edges
const filterEdges = (edges, path = []) => {
if (!Array.isArray(edges)) return edges;
return edges.filter(edge => {
if (!edge || !edge.node) return true;
const node = edge.node;
// Keep the edge if it doesn't match any filter
if (!shouldFilterNode(node)) return true;
// Log filtered content for debugging
if (window.__fgDebugFilter) {
const type = node.__typename || 'Unknown';
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
}
return false;
});
};
// Recursively walk GraphQL response and filter edges
const walkAndFilter = (obj, visited = new Set()) => {
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
visited.add(obj);
// Handle arrays
if (Array.isArray(obj)) {
obj.forEach(item => walkAndFilter(item, visited));
return;
}
// Check for edges array (common GraphQL pattern)
if (obj.edges && Array.isArray(obj.edges)) {
obj.edges = filterEdges(obj.edges);
}
// Recurse into children
for (const key in obj) {
if (obj.hasOwnProperty(key) && key !== '__typename') {
const val = obj[key];
if (val && typeof val === 'object') {
walkAndFilter(val, visited);
}
}
}
};
// Override fetch
const _fetch = window.fetch.bind(window);
window.fetch = async function (input, init) {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Call original fetch
let response = await _fetch(input, init);
// Only intercept GraphQL feed queries
if (!url.includes('/graphql/query') && !url.includes('/api/v1/feed')) {
return response;
}
// Clone response to read body
const cloned = response.clone();
try {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return response;
}
const data = await cloned.json();
// Filter the response data
walkAndFilter(data);
// Return modified response
return new Response(JSON.stringify(data), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} catch (e) {
// On error, return original response
return response;
}
};
// Preserve native function appearance
Object.defineProperty(window, 'fetch', {
value: window.fetch,
writable: true,
configurable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
const _xhrOpen = XMLHttpRequest.prototype.open;
const _xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__fgUrl = typeof url === 'string' ? url : String(url || '');
return _xhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
if (
window.__fgFilterConfig.blockVideos &&
this.__fgUrl &&
(this.__fgUrl.includes('/api/v1/clips/') ||
this.__fgUrl.includes('/api/v1/discover/'))
) {
try { this.abort(); } catch (_) {}
return;
}
return _xhrSend.apply(this, arguments);
};
// Allow Flutter to update config flags
window.__fgSetFilterConfig = function (config) {
if (typeof config === 'object') {
Object.assign(window.__fgFilterConfig, config);
if (window.__fgDebugFilter) {
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
}
}
};
// Enable debug logging
window.__fgDebugFilter = false;
})();
+207
View File
@@ -0,0 +1,207 @@
/**
* FocusGram Ghost Mode
* Injected at DOCUMENT_START before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals.
*/
(function () {
'use strict';
// ─── Seen API patterns ────────────────────────────────────────────────────
const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//,
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
];
// ─── Activity patterns (like, comment) — intercepted for local history ────
const ACTIVITY_PATTERNS = [
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
/\/api\/v1\/web\/comments\/add\//,
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
];
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
const fakeOkResponse = () =>
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// ─── Fetch override ───────────────────────────────────────────────────────
const _fetch = window.fetch.bind(window);
const patchedFetch = async function (input, init) {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Block seen
if (isSeen(url)) {
if (window.GhostChannel) {
window.GhostChannel.postMessage(
JSON.stringify({ type: 'seen_blocked', url })
);
}
return fakeOkResponse();
}
// Intercept activity for local history
if (isActivity(url) && window.ActivityChannel) {
const body = init?.body;
const bodyText =
body instanceof URLSearchParams
? body.toString()
: typeof body === 'string'
? body
: '';
window.ActivityChannel.postMessage(
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
);
}
return _fetch(input, init);
};
// Disguise as native
Object.defineProperty(window, 'fetch', {
value: patchedFetch,
writable: true,
configurable: true,
enumerable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
window.fetch[Symbol.toStringTag] = 'fetch';
// ─── XMLHttpRequest override ──────────────────────────────────────────────
const _XHROpen = XMLHttpRequest.prototype.open;
const _XHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._fg_url = url ?? '';
this._fg_method = (method ?? '').toUpperCase();
return _XHROpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function (body) {
if (this._fg_url && isSeen(this._fg_url)) {
// Fire readyState 4 with fake success without actually sending
const self = this;
setTimeout(() => {
Object.defineProperty(self, 'readyState', { get: () => 4 });
Object.defineProperty(self, 'status', { get: () => 200 });
Object.defineProperty(self, 'responseText', {
get: () => '{"status":"ok"}',
});
Object.defineProperty(self, 'response', {
get: () => '{"status":"ok"}',
});
self.dispatchEvent(new Event('readystatechange'));
self.dispatchEvent(new Event('load'));
}, 10);
return;
}
return _XHRSend.call(this, body);
};
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
// Strict WS URL blocking (ghost mode requirement)
// sid/cid vary per user/chat; block by endpoint prefix, not exact query.
const isBlockedWssUrl = (u) => {
if (!u) return false;
const urlStr = String(u);
return (
urlStr.startsWith('wss://gateway.instagram.com/ws/streamcontroller') ||
urlStr.startsWith('wss://edge-chat.instagram.com/chat?sid=')
);
};
// Signal to other injected scripts that ghost-mode is active
window.__fgGhostModeActive = true;
const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) {
const urlStr = typeof url === 'string' ? url : url?.toString?.() ?? '';
// If the WebSocket URL is one of the blocked endpoints, return an inert WS-like object
if (isBlockedWssUrl(urlStr)) {
return {
send: () => {},
close: () => {},
readyState: 1,
addEventListener: () => {},
removeEventListener: () => {},
};
}
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
const _send = ws.send.bind(ws);
ws.send = function (data) {
if (typeof data === 'string') {
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
try {
const parsed = JSON.parse(data);
if (
parsed?.op === '4' ||
parsed?.op === 'seen' ||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
) {
return; // drop
}
} catch (_) {}
// Text-based seen signal check
if (data.includes('"seen"') && data.includes('"thread_id"')) {
return;
}
}
return _send(data);
};
return ws;
}
// Preserve WebSocket prototype chain so IG's ws checks pass
PatchedWebSocket.prototype = _WS.prototype;
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
PatchedWebSocket.OPEN = _WS.OPEN;
PatchedWebSocket.CLOSING = _WS.CLOSING;
PatchedWebSocket.CLOSED = _WS.CLOSED;
window.WebSocket = PatchedWebSocket;
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
// Only applied if user enables online-status hiding
// Wrapped in a named fn so Flutter can call it:
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
window.__fgEnableOnlineHide = function () {
Object.defineProperty(document, 'visibilityState', {
get: () => 'hidden',
configurable: true,
});
Object.defineProperty(document, 'hidden', {
get: () => true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
};
window.__fgDisableOnlineHide = function () {
// Restore by deleting the overrides (falls back to native getter)
delete document.visibilityState;
delete document.hidden;
document.dispatchEvent(new Event('visibilitychange'));
};
// Signal to Flutter that ghost mode JS is active
if (window.GhostChannel) {
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+89
View File
@@ -0,0 +1,89 @@
/**
* FocusGram Theme Detector
* Reads Instagram's background + bottom nav color and reports to Flutter.
* Injected at DOCUMENT_END so DOM is ready.
*/
(function () {
'use strict';
const parseRgb = (str) => {
// Parses "rgb(255, 255, 255)" or "rgba(0, 0, 0, 1)" → { r, g, b, a }
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return null;
return {
r: parseInt(m[1]),
g: parseInt(m[2]),
b: parseInt(m[3]),
a: m[4] !== undefined ? parseFloat(m[4]) : 1,
};
};
const toHex = ({ r, g, b }) =>
'#' +
[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
const detectColors = () => {
// Background — Instagram sets it on <body> or a root div
const bodyBg = getComputedStyle(document.body).backgroundColor;
// Bottom nav — IG mobile web renders a fixed bottom bar
// Target by role="navigation" or position:fixed at bottom
let navBg = bodyBg;
const navCandidates = document.querySelectorAll(
'nav, [role="navigation"], div[style*="bottom"]'
);
for (const el of navCandidates) {
const style = getComputedStyle(el);
if (
style.position === 'fixed' &&
parseInt(style.bottom) <= 10 &&
style.backgroundColor !== 'rgba(0, 0, 0, 0)'
) {
navBg = style.backgroundColor;
break;
}
}
const bodyColor = parseRgb(bodyBg);
const navColor = parseRgb(navBg);
if (!bodyColor) return;
// Determine dark/light
const luminance = (0.299 * bodyColor.r + 0.587 * bodyColor.g + 0.114 * bodyColor.b) / 255;
const isDark = luminance < 0.5;
const payload = {
bodyHex: toHex(bodyColor),
navHex: navColor ? toHex(navColor) : toHex(bodyColor),
isDark,
};
if (window.ThemeChannel) {
window.ThemeChannel.postMessage(JSON.stringify(payload));
}
};
// Run on load
detectColors();
// Watch for Instagram's dark mode toggle (adds/removes class on <html>)
const observer = new MutationObserver(detectColors);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style', 'color-scheme'],
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'style'],
});
// Also run after navigation (Instagram is SPA, URL changes without reload)
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(detectColors, 300); // small delay for IG to render new page
}
}).observe(document.body, { childList: true, subtree: true });
})();
+31 -31
View File
@@ -23,41 +23,41 @@ class ChannelRegistry {
// //
JavaScriptChannel _ghostChannel() => JavaScriptChannel( JavaScriptChannel _ghostChannel() => JavaScriptChannel(
name: 'GhostChannel', name: 'GhostChannel',
onMessageReceived: (msg) { onMessageReceived: (msg) {
try { try {
final data = jsonDecode(msg.message) as Map<String, dynamic>; final data = jsonDecode(msg.message) as Map<String, dynamic>;
if (kDebugMode) { if (kDebugMode) {
debugPrint('[Ghost] ${data['type']}${data['url'] ?? ''}'); debugPrint('[Ghost] ${data['type']}${data['url'] ?? ''}');
} }
// In release: silent. Could surface to a debug overlay in dev builds. // In release: silent. Could surface to a debug overlay in dev builds.
} catch (_) {} } catch (_) {}
}, },
); );
JavaScriptChannel _themeChannel() => JavaScriptChannel( JavaScriptChannel _themeChannel() => JavaScriptChannel(
name: 'ThemeChannel', name: 'ThemeChannel',
onMessageReceived: (msg) { onMessageReceived: (msg) {
SystemUiManager.applyFromThemePayload(msg.message); SystemUiManager.applyFromThemePayload(msg.message);
}, },
); );
JavaScriptChannel _contentChannel() => JavaScriptChannel( JavaScriptChannel _contentChannel() => JavaScriptChannel(
name: 'ContentChannel', name: 'ContentChannel',
onMessageReceived: (msg) { onMessageReceived: (msg) {
// 'ready' signal engine pushes flags back via evaluateJavascript // 'ready' signal engine pushes flags back via evaluateJavascript
// handled in ScriptEngine.injectDocumentEndScripts() // handled in ScriptEngine.injectDocumentEndScripts()
if (kDebugMode) debugPrint('[Content] ${msg.message}'); if (kDebugMode) debugPrint('[Content] ${msg.message}');
}, },
); );
JavaScriptChannel _activityChannel() => JavaScriptChannel( JavaScriptChannel _activityChannel() => JavaScriptChannel(
name: 'ActivityChannel', name: 'ActivityChannel',
onMessageReceived: (msg) { onMessageReceived: (msg) {
try { try {
final data = jsonDecode(msg.message) as Map<String, dynamic>; final data = jsonDecode(msg.message) as Map<String, dynamic>;
onActivityEvent?.call(data); onActivityEvent?.call(data);
} catch (_) {} } catch (_) {}
}, },
); );
} }
+38 -54
View File
@@ -3,7 +3,7 @@
// Three-layer ghost mode: // Three-layer ghost mode:
// 1. AT_DOCUMENT_START JS injection overrides fetch/XHR/WS before IG code runs // 1. AT_DOCUMENT_START JS injection overrides fetch/XHR/WS before IG code runs
// 2. shouldInterceptRequest native Android intercept (catches SW requests too) // 2. shouldInterceptRequest native Android intercept (catches SW requests too)
// 3. FLAG_SECURE anti-screenshot at OS level // 3. FLAG_SECURE anti-screenshot at OS level (disabled per user request)
// //
// Usage: // Usage:
// final service = GhostModeService(); // final service = GhostModeService();
@@ -15,8 +15,8 @@
// shouldInterceptRequest: service.shouldInterceptRequest, // shouldInterceptRequest: service.shouldInterceptRequest,
// ) // )
// //
// // Anti-screenshot: call from initState after WidgetsBinding.instance.addPostFrameCallback // // Anti-screenshot: disabled per user request
// service.applyWindowFlags(context); // // service.applyWindowFlags(context);
import 'dart:typed_data'; import 'dart:typed_data';
@@ -36,55 +36,50 @@ class GhostFeatures {
bool hideVoiceListened; bool hideVoiceListened;
bool hideReplyImageViewed; bool hideReplyImageViewed;
bool disableAnalytics; bool disableAnalytics;
bool antiScreenshot;
GhostFeatures({ GhostFeatures({
this.hideStoryViews = true, this.hideStoryViews = true,
this.hideReadReceipts = true, this.hideReadReceipts = true,
this.hideLiveJoin = true, this.hideLiveJoin = true,
this.hideTypingIndicator = true, this.hideTypingIndicator = true,
this.hideVoiceListened = true, this.hideVoiceListened = true,
this.hideReplyImageViewed = true, this.hideReplyImageViewed = true,
this.disableAnalytics = true, this.disableAnalytics = true,
this.antiScreenshot = false, // Off by default user must opt in
}); });
static const _keys = { static const _keys = {
'hideStoryViews': 'gm_story', 'hideStoryViews': 'gm_story',
'hideReadReceipts': 'gm_read', 'hideReadReceipts': 'gm_read',
'hideLiveJoin': 'gm_live', 'hideLiveJoin': 'gm_live',
'hideTypingIndicator': 'gm_typing', 'hideTypingIndicator': 'gm_typing',
'hideVoiceListened': 'gm_voice', 'hideVoiceListened': 'gm_voice',
'hideReplyImageViewed': 'gm_reply', 'hideReplyImageViewed': 'gm_reply',
'disableAnalytics': 'gm_analytics', 'disableAnalytics': 'gm_analytics',
'antiScreenshot': 'gm_screenshot',
}; };
Future<void> save() async { Future<void> save() async {
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
await Future.wait([ await Future.wait([
p.setBool(_keys['hideStoryViews']!, hideStoryViews), p.setBool(_keys['hideStoryViews']!, hideStoryViews),
p.setBool(_keys['hideReadReceipts']!, hideReadReceipts), p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
p.setBool(_keys['hideLiveJoin']!, hideLiveJoin), p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator), p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
p.setBool(_keys['hideVoiceListened']!, hideVoiceListened), p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed), p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed),
p.setBool(_keys['disableAnalytics']!, disableAnalytics), p.setBool(_keys['disableAnalytics']!, disableAnalytics),
p.setBool(_keys['antiScreenshot']!, antiScreenshot),
]); ]);
} }
static Future<GhostFeatures> load() async { static Future<GhostFeatures> load() async {
final p = await SharedPreferences.getInstance(); final p = await SharedPreferences.getInstance();
return GhostFeatures( return GhostFeatures(
hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true, hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true, hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true, hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true, hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true, hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true, hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true,
disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true, disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
antiScreenshot: p.getBool(_keys['antiScreenshot']!) ?? false,
); );
} }
} }
@@ -110,23 +105,18 @@ final _nativeBlocklist = [
RegExp(r'/ajax/logging/'), RegExp(r'/ajax/logging/'),
]; ];
final Uint8List _fakeOkBody = Uint8List.fromList( final Uint8List _fakeOkBody = Uint8List.fromList('{"status":"ok"}'.codeUnits);
'{"status":"ok"}'.codeUnits,
);
// Main service // Main service
class GhostModeService { class GhostModeService {
GhostFeatures features = GhostFeatures(); GhostFeatures features = GhostFeatures();
InAppWebViewController? _controller; InAppWebViewController? _controller;
// Platform channel for FLAG_SECURE (anti-screenshot)
static const _channel = MethodChannel('com.focusgram/window_flags');
Future<void> load() async { Future<void> load() async {
features = await GhostFeatures.load(); features = await GhostFeatures.load();
} }
// WebView setup // WebView setup
/// Call from InAppWebView.onWebViewCreated /// Call from InAppWebView.onWebViewCreated
void onWebViewCreated(InAppWebViewController controller) { void onWebViewCreated(InAppWebViewController controller) {
@@ -170,34 +160,28 @@ class GhostModeService {
/// InAppWebViewSettings required for shouldInterceptRequest to fire /// InAppWebViewSettings required for shouldInterceptRequest to fire
InAppWebViewSettings buildWebViewSettings() { InAppWebViewSettings buildWebViewSettings() {
return InAppWebViewSettings( return InAppWebViewSettings(
useShouldInterceptRequest: true, // Enable native intercept callback useShouldInterceptRequest: true, // Enable native intercept callback
useShouldOverrideUrlLoading: true, useShouldOverrideUrlLoading: true,
javaScriptEnabled: true, javaScriptEnabled: true,
disableDefaultErrorPage: true, disableDefaultErrorPage: true,
useHybridComposition: true, // Needed for FLAG_SECURE to work useHybridComposition:
true, // Needed for FLAG_SECURE to work (though disabled)
// Disable service worker cache that can replay seen-events offline // Disable service worker cache that can replay seen-events offline
cacheEnabled: false, // Start clean optional, tradeoff vs perf cacheEnabled: false, // Start clean optional, tradeoff vs perf
); );
} }
// Anti-screenshot // Anti-screenshot
// Anti-screenshot disabled per user request
/// Call from initState addPostFrameCallback
Future<void> applyWindowFlags(BuildContext context) async { Future<void> applyWindowFlags(BuildContext context) async {
if (!features.antiScreenshot) return; // Anti-screenshot disabled per user request
try { return;
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 { Future<void> clearWindowFlags() async {
try { // Anti-screenshot disabled per user request
await _channel.invokeMethod('setSecure', {'secure': false}); return;
} catch (_) {}
} }
// Re-inject after page nav (SPA navigation doesn't re-run userScripts) ── // Re-inject after page nav (SPA navigation doesn't re-run userScripts) ──
+60 -2
View File
@@ -6,6 +6,7 @@ import '../injection/script_engine.dart';
import '../injection/script_registry.dart'; import '../injection/script_registry.dart';
import '../channels/channel_registry.dart'; import '../channels/channel_registry.dart';
import '../webview/webview_config.dart'; import '../webview/webview_config.dart';
import '../services/ghost_mode_service.dart';
class InstagramWebView extends StatefulWidget { class InstagramWebView extends StatefulWidget {
const InstagramWebView({super.key}); const InstagramWebView({super.key});
@@ -17,9 +18,10 @@ class InstagramWebView extends StatefulWidget {
class InstagramWebViewState extends State<InstagramWebView> { class InstagramWebViewState extends State<InstagramWebView> {
InAppWebViewController? _controller; InAppWebViewController? _controller;
ScriptEngine? _engine; ScriptEngine? _engine;
GhostModeService? _ghostMode;
bool _loading = true; bool _loading = true;
// Public API call from Settings screen // Public API call from Settings screen
Future<void> toggleScript(ScriptId id, bool enabled) async { Future<void> toggleScript(ScriptId id, bool enabled) async {
await _engine?.toggle(id, enabled); await _engine?.toggle(id, enabled);
} }
@@ -32,6 +34,37 @@ class InstagramWebViewState extends State<InstagramWebView> {
await _engine?.setOnlineHide(enabled); await _engine?.setOnlineHide(enabled);
} }
// Ghost mode controls
Future<void> setGhostModeEnabled(bool enabled) async {
if (_ghostMode != null) {
_ghostMode!.features.disableAnalytics = enabled;
_ghostMode!.features.hideStoryViews = enabled;
_ghostMode!.features.hideReadReceipts = enabled;
_ghostMode!.features.hideLiveJoin = enabled;
_ghostMode!.features.hideTypingIndicator = enabled;
_ghostMode!.features.hideVoiceListened = enabled;
_ghostMode!.features.hideReplyImageViewed = enabled;
await _ghostMode!.features.save();
// Reapply settings if webview exists
if (_controller != null) {
// Force reload to apply new settings
await _controller!.reload();
}
}
}
Future<void> setAntiScreenshot(bool enabled) async {
if (_ghostMode != null) {
_ghostMode!.features.antiScreenshot = enabled;
await _ghostMode!.features.save();
if (_ghostMode!.features.antiScreenshot) {
await _ghostMode!.applyWindowFlags(context);
} else {
await _ghostMode!.clearWindowFlags();
}
}
}
// //
@override @override
@@ -40,12 +73,18 @@ class InstagramWebViewState extends State<InstagramWebView> {
children: [ children: [
InAppWebView( InAppWebView(
initialUrlRequest: WebViewConfig.initialRequest, initialUrlRequest: WebViewConfig.initialRequest,
initialSettings: WebViewConfig.settings, initialSettings:
_ghostMode?.buildWebViewSettings() ?? WebViewConfig.settings,
// ContentBlockers merged base + EasyList rules // ContentBlockers merged base + EasyList rules
contentBlockers: WebViewConfig.baseContentBlockers, contentBlockers: WebViewConfig.baseContentBlockers,
// TODO Phase 1.5: merge EasyListParser.load() here at startup // TODO Phase 1.5: merge EasyListParser.load() here at startup
// User Scripts AT_DOCUMENT_START critical for ghost mode
initialUserScripts: UnmodifiableListView(
_ghostMode?.buildUserScripts() ?? [],
),
// JavaScript channels // JavaScript channels
javascriptChannels: ChannelRegistry( javascriptChannels: ChannelRegistry(
onActivityEvent: (event) { onActivityEvent: (event) {
@@ -56,6 +95,12 @@ class InstagramWebViewState extends State<InstagramWebView> {
onWebViewCreated: (controller) async { onWebViewCreated: (controller) async {
_controller = controller; _controller = controller;
// Initialize GhostModeService
_ghostMode = GhostModeService();
await _ghostMode!.load();
// Initialize existing script engine for other scripts
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_engine = ScriptEngine(controller: controller, prefs: prefs); _engine = ScriptEngine(controller: controller, prefs: prefs);
@@ -66,6 +111,10 @@ class InstagramWebViewState extends State<InstagramWebView> {
onLoadStop: (controller, url) async { onLoadStop: (controller, url) async {
// Inject DOCUMENT_END scripts // Inject DOCUMENT_END scripts
await _engine?.injectDocumentEndScripts(); await _engine?.injectDocumentEndScripts();
// Re-inject ghost mode scripts on SPA navigation
await _ghostMode?.onPageLoaded(url?.uriValue);
setState(() => _loading = false); setState(() => _loading = false);
}, },
@@ -103,6 +152,15 @@ class InstagramWebViewState extends State<InstagramWebView> {
await _engine?.injectDocumentEndScripts(); await _engine?.injectDocumentEndScripts();
} }
}, },
// Native intercept for service worker requests
shouldInterceptRequest: (controller, request) async {
return await _ghostMode?.shouldInterceptRequest(
controller,
request,
) ??
null;
},
), ),
// Subtle loading indicator // Subtle loading indicator
+8
View File
@@ -1,9 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'core/theme/system_ui_manager.dart'; import 'core/theme/system_ui_manager.dart';
import 'core/webview/instagram_webview.dart'; import 'core/webview/instagram_webview.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Enable web contents debugging for ghost mode verification
if (kDebugMode) {
InAppWebViewController.setWebContentsDebuggingEnabled(true);
}
await SystemUiManager.enableEdgeToEdge(); await SystemUiManager.enableEdgeToEdge();
runApp(const FocusGramApp()); runApp(const FocusGramApp());
} }
+96 -4
View File
@@ -35,12 +35,36 @@ class ScriptEngine {
); );
} }
} }
// Initialize script configurations after scripts are loaded
await _initializeScriptConfigs();
}
// Initialize script configurations from saved preferences
Future<void> _initializeScriptConfigs() async {
// Fetch interceptor config
final fetchInterceptor = ScriptRegistry.byId(ScriptId.fetchInterceptor);
if (fetchInterceptor.enabled) {
await _updateFetchInterceptorConfig();
}
// Autoplay blocker config
final autoplayBlocker = ScriptRegistry.byId(ScriptId.autoplayBlocker);
if (autoplayBlocker.enabled) {
await _updateAutoplayBlockerConfig();
}
// Content hider flags
await _pushContentFlags();
} }
// Called from onLoadStop: inject all DOCUMENT_END enabled scripts // Called from onLoadStop: inject all DOCUMENT_END enabled scripts
Future<void> injectDocumentEndScripts() async { Future<void> injectDocumentEndScripts() async {
for (final script in ScriptRegistry.all for (final script in ScriptRegistry.all.where(
.where((s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END && s.enabled)) { (s) =>
s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END &&
s.enabled,
)) {
await _inject(script); await _inject(script);
} }
// After content_hider is injected, push saved content flags // After content_hider is injected, push saved content flags
@@ -77,6 +101,9 @@ class ScriptEngine {
} else { } else {
await _inject(script); await _inject(script);
} }
// Re-initialize configurations after toggle
await _initializeScriptConfigs();
} }
// Content hider flags // Content hider flags
@@ -100,15 +127,80 @@ class ScriptEngine {
); );
} }
// Fetch interceptor configuration
Future<void> setFetchInterceptorConfig({
bool? blockAds,
bool? blockSponsored,
bool? blockSuggested,
bool? blockVideos,
bool? blockAutoplay,
}) async {
final prefs = await SharedPreferences.getInstance();
final config = {
'blockAds': blockAds ?? prefs.getBool('fetch_block_ads') ?? false,
'blockSponsored':
blockSponsored ?? prefs.getBool('fetch_block_sponsored') ?? false,
'blockSuggested':
blockSuggested ?? prefs.getBool('fetch_block_suggested') ?? false,
'blockVideos':
blockVideos ?? prefs.getBool('fetch_block_videos') ?? false,
'blockAutoplay':
blockAutoplay ?? prefs.getBool('fetch_block_autoplay') ?? false,
};
// Save individual prefs
await prefs.setBool('fetch_block_ads', config['blockAds']!);
await prefs.setBool('fetch_block_sponsored', config['blockSponsored']!);
await prefs.setBool('fetch_block_suggested', config['blockSuggested']!);
await prefs.setBool('fetch_block_videos', config['blockVideos']!);
await prefs.setBool('fetch_block_autoplay', config['blockAutoplay']!);
// Apply to webview
await controller.evaluateJavascript(
source: 'window.__fgSetFilterConfig?.(${jsonEncode(config)})',
);
}
Future<void> _updateFetchInterceptorConfig() async {
final prefs = await SharedPreferences.getInstance();
await setFetchInterceptorConfig(
blockAds: prefs.getBool('fetch_block_ads'),
blockSponsored: prefs.getBool('fetch_block_sponsored'),
blockSuggested: prefs.getBool('fetch_block_suggested'),
blockVideos: prefs.getBool('fetch_block_videos'),
blockAutoplay: prefs.getBool('fetch_block_autoplay'),
);
}
// Autoplay blocker configuration
Future<void> setAutoplayBlockerEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('autoplay_blocker_enabled', enabled);
// Apply to webview
await controller.evaluateJavascript(
source: 'window.__fgSetBlockAutoplay?.(${jsonEncode(enabled)})',
);
}
Future<void> _updateAutoplayBlockerConfig() async {
final prefs = await SharedPreferences.getInstance();
await setAutoplayBlockerEnabled(
prefs.getBool('autoplay_blocker_enabled') ?? false,
);
}
// Online status hide // Online status hide
Future<void> setOnlineHide(bool enabled) async { Future<void> setOnlineHide(bool enabled) async {
await prefs.setBool('ghost_online_hide', enabled); await prefs.setBool('ghost_online_hide', enabled);
if (enabled) { if (enabled) {
await controller.evaluateJavascript( await controller.evaluateJavascript(
source: 'window.__fgEnableOnlineHide?.()'); source: 'window.__fgEnableOnlineHide?.()',
);
} else { } else {
await controller.evaluateJavascript( await controller.evaluateJavascript(
source: 'window.__fgDisableOnlineHide?.()'); source: 'window.__fgDisableOnlineHide?.()',
);
} }
} }
+3 -21
View File
@@ -3,7 +3,6 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
enum ScriptId { enum ScriptId {
ghostMode, ghostMode,
themeDetector, themeDetector,
adBlockerDom,
contentHider, contentHider,
fetchInterceptor, fetchInterceptor,
autoplayBlocker, autoplayBlocker,
@@ -32,18 +31,11 @@ class InstaScript {
class ScriptRegistry { class ScriptRegistry {
static final List<InstaScript> all = [ static final List<InstaScript> all = [
// DOCUMENT_START must be before IG's JS loads ── // 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( InstaScript(
id: ScriptId.fetchInterceptor, id: ScriptId.fetchInterceptor,
name: 'Fetch Interceptor', name: 'Ad & Content Blocker',
description: 'Unified feed filter: blocks ads, sponsored, suggested, videos via GraphQL interception.', description:
'Blocks ads, sponsored, suggested content, videos, and prevents autoplay via GraphQL interception.',
assetPath: 'assets/scripts/fetch_interceptor.js', assetPath: 'assets/scripts/fetch_interceptor.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false, enabled: false,
@@ -66,14 +58,6 @@ class ScriptRegistry {
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: true, // always on needed for native feel 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( InstaScript(
id: ScriptId.contentHider, id: ScriptId.contentHider,
name: 'Content Hider', name: 'Content Hider',
@@ -100,6 +84,4 @@ class ScriptRegistry {
enabled: false, enabled: false,
), ),
]; ];
static InstaScript byId(ScriptId id) => all.firstWhere((s) => s.id == id);
} }
+25 -19
View File
@@ -8,7 +8,8 @@ class SystemUiManager {
try { try {
final data = jsonDecode(jsonPayload) as Map<String, dynamic>; final data = jsonDecode(jsonPayload) as Map<String, dynamic>;
final isDark = data['isDark'] as bool? ?? false; final isDark = data['isDark'] as bool? ?? false;
final bodyHex = data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff'); final bodyHex =
data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
final navHex = data['navHex'] as String? ?? bodyHex; final navHex = data['navHex'] as String? ?? bodyHex;
final bodyColor = _parseHex(bodyHex); final bodyColor = _parseHex(bodyHex);
@@ -20,8 +21,9 @@ class SystemUiManager {
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark, statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
statusBarBrightness: isDark ? Brightness.dark : Brightness.light, statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
systemNavigationBarColor: navColor, systemNavigationBarColor: navColor,
systemNavigationBarIconBrightness: systemNavigationBarIconBrightness: isDark
isDark ? Brightness.light : Brightness.dark, ? Brightness.light
: Brightness.dark,
systemNavigationBarDividerColor: Colors.transparent, systemNavigationBarDividerColor: Colors.transparent,
), ),
); );
@@ -33,25 +35,29 @@ class SystemUiManager {
// Fallback presets // Fallback presets
static void applyLight() { static void applyLight() {
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
statusBarColor: Color(0xFFFFFFFF), const SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.dark, statusBarColor: Color(0xFFFFFFFF),
statusBarBrightness: Brightness.light, statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: Color(0xFFFFFFFF), statusBarBrightness: Brightness.light,
systemNavigationBarIconBrightness: Brightness.dark, systemNavigationBarColor: Color(0xFFFFFFFF),
systemNavigationBarDividerColor: Colors.transparent, systemNavigationBarIconBrightness: Brightness.dark,
)); systemNavigationBarDividerColor: Colors.transparent,
),
);
} }
static void applyDark() { static void applyDark() {
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
statusBarColor: Color(0xFF000000), const SystemUiOverlayStyle(
statusBarIconBrightness: Brightness.light, statusBarColor: Color(0xFF000000),
statusBarBrightness: Brightness.dark, statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Color(0xFF000000), statusBarBrightness: Brightness.dark,
systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarColor: Color(0xFF000000),
systemNavigationBarDividerColor: Colors.transparent, systemNavigationBarIconBrightness: Brightness.light,
)); systemNavigationBarDividerColor: Colors.transparent,
),
);
} }
// Edge-to-edge setup call once in main() // Edge-to-edge setup call once in main()
+80 -83
View File
@@ -11,110 +11,107 @@ class WebViewConfig {
// Base InAppWebView settings // Base InAppWebView settings
static InAppWebViewSettings get settings => InAppWebViewSettings( static InAppWebViewSettings get settings => InAppWebViewSettings(
// Identity // Identity
userAgent: userAgent, userAgent: userAgent,
// Performance // Performance
hardwareAcceleration: true, hardwareAcceleration: true,
// useHybridComposition: false breaks some Android 12+ devices keep true // useHybridComposition: false breaks some Android 12+ devices keep true
useHybridComposition: true, useHybridComposition: true,
cacheEnabled: true, cacheEnabled: true,
cacheMode: CacheMode.LOAD_DEFAULT, cacheMode: CacheMode.LOAD_DEFAULT,
// Media // Media
mediaPlaybackRequiresUserGesture: false, mediaPlaybackRequiresUserGesture: false,
allowsInlineMediaPlayback: true, allowsInlineMediaPlayback: true,
allowsPictureInPictureMediaPlayback: true, allowsPictureInPictureMediaPlayback: true,
// UX feel like native, not browser // UX feel like native, not browser
overScrollMode: OverScrollMode.NEVER, overScrollMode: OverScrollMode.NEVER,
verticalScrollBarEnabled: false, verticalScrollBarEnabled: false,
horizontalScrollBarEnabled: false, horizontalScrollBarEnabled: false,
supportZoom: false, supportZoom: false,
builtInZoomControls: false, builtInZoomControls: false,
displayZoomControls: false, displayZoomControls: false,
scrollsToTop: true, scrollsToTop: true,
// JS & storage IG needs all of these // JS & storage IG needs all of these
javaScriptEnabled: true, javaScriptEnabled: true,
javaScriptCanOpenWindowsAutomatically: false, javaScriptCanOpenWindowsAutomatically: false,
domStorageEnabled: true, domStorageEnabled: true,
databaseEnabled: true, databaseEnabled: true,
allowFileAccessFromFileURLs: false, allowFileAccessFromFileURLs: false,
allowUniversalAccessFromFileURLs: false, allowUniversalAccessFromFileURLs: false,
// Compat // Compat
mixedContentMode: MixedContentMode.COMPATIBILITY_MODE, mixedContentMode: MixedContentMode.COMPATIBILITY_MODE,
safeBrowsingEnabled: false, // IG known-safe domain, no need for extra latency safeBrowsingEnabled:
false, // IG known-safe domain, no need for extra latency
// Disable Chrome custom tabs popup (links open in WebView)
suppressesIncrementalRendering: false,
// Disable Chrome custom tabs popup (links open in WebView) // iOS specific
suppressesIncrementalRendering: false, allowsBackForwardNavigationGestures: true,
allowsLinkPreview: false,
isFraudulentWebsiteWarningEnabled: false,
// iOS specific // Android specific
allowsBackForwardNavigationGestures: true, forceDark: ForceDark.AUTO, // respect system dark mode
allowsLinkPreview: false, algorithmicDarkeningAllowed: true,
isFraudulentWebsiteWarningEnabled: false, );
// Android specific
forceDark: ForceDark.AUTO, // respect system dark mode
algorithmicDarkeningAllowed: true,
);
// ContentBlocker rules ad network blocking // ContentBlocker rules ad network blocking
// These are baked-in rules targeting known ad/tracking domains. // These are baked-in rules targeting known ad/tracking domains.
// Full EasyList parsing is handled separately and merged at runtime. // Full EasyList parsing is handled separately and merged at runtime.
// This set is always-on regardless of user toggle. // This set is always-on regardless of user toggle.
static List<ContentBlocker> get baseContentBlockers => [ static List<ContentBlocker> get baseContentBlockers => [
// Meta ad infrastructure // Meta ad infrastructure
_block('.*connect\\.facebook\\.net.*'), _block('.*connect\\.facebook\\.net.*'),
_block('.*graph\\.facebook\\.com.*ads.*'), _block('.*graph\\.facebook\\.com.*ads.*'),
_block('.*an\\.facebook\\.com.*'), _block('.*an\\.facebook\\.com.*'),
// Google ad networks // Google ad networks
_block('.*doubleclick\\.net.*'), _block('.*doubleclick\\.net.*'),
_block('.*googleadservices\\.com.*'), _block('.*googleadservices\\.com.*'),
_block('.*googlesyndication\\.com.*'), _block('.*googlesyndication\\.com.*'),
_block('.*adservice\\.google\\..*'), _block('.*adservice\\.google\\..*'),
// Common trackers // Common trackers
_block('.*scorecardresearch\\.com.*'), _block('.*scorecardresearch\\.com.*'),
_block('.*quantserve\\.com.*'), _block('.*quantserve\\.com.*'),
_block('.*chartbeat\\.com.*'), _block('.*chartbeat\\.com.*'),
_block('.*newrelic\\.com.*'), _block('.*newrelic\\.com.*'),
// Ad servers // Ad servers
_block('.*ads\\.yahoo\\.com.*'), _block('.*ads\\.yahoo\\.com.*'),
_block('.*advertising\\.com.*'), _block('.*advertising\\.com.*'),
_block('.*adnxs\\.com.*'), _block('.*adnxs\\.com.*'),
_block('.*adsrvr\\.org.*'), _block('.*adsrvr\\.org.*'),
_block('.*taboola\\.com.*'), _block('.*taboola\\.com.*'),
_block('.*outbrain\\.com.*'), _block('.*outbrain\\.com.*'),
_block('.*pubmatic\\.com.*'), _block('.*pubmatic\\.com.*'),
_block('.*rubiconproject\\.com.*'), _block('.*rubiconproject\\.com.*'),
_block('.*openx\\.net.*'), _block('.*openx\\.net.*'),
_block('.*casalemedia\\.com.*'), _block('.*casalemedia\\.com.*'),
_block('.*criteo\\.com.*'), _block('.*criteo\\.com.*'),
_block('.*criteo\\.net.*'), _block('.*criteo\\.net.*'),
// Pixel trackers // Pixel trackers
_block('.*pixel\\.quantserve\\.com.*'), _block('.*pixel\\.quantserve\\.com.*'),
_block('.*pixel\\.facebook\\.com.*'), _block('.*pixel\\.facebook\\.com.*'),
// IG-specific ad endpoints (safe to block don't affect core IG) // IG-specific ad endpoints (safe to block don't affect core IG)
_block('.*\\.instagram\\.com.*\\/ads\\/.*'), _block('.*\\.instagram\\.com.*\\/ads\\/.*'),
]; ];
static ContentBlocker _block(String pattern) => ContentBlocker( static ContentBlocker _block(String pattern) => ContentBlocker(
trigger: ContentBlockerTrigger(urlFilter: pattern), trigger: ContentBlockerTrigger(urlFilter: pattern),
action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK), action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
); );
// URLRequest for initial load // URLRequest for initial load
static URLRequest get initialRequest => URLRequest( static URLRequest get initialRequest => URLRequest(
url: WebUri(instagramUrl), url: WebUri(instagramUrl),
headers: { headers: {'Accept-Language': 'en-US,en;q=0.9', 'DNT': '1'},
'Accept-Language': 'en-US,en;q=0.9', );
'DNT': '1',
},
);
} }