mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-06-10 15:43:53 +02:00
Progress SAve- downloader,blur,ghost mode(Partially) works
This commit is contained in:
-45
@@ -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
@@ -14,6 +14,9 @@ migrate_working_dir/
|
||||
PRD.md
|
||||
.agents/
|
||||
TODO.md
|
||||
v2/FOCUSGRAM_V2_PLAN.md
|
||||
v2/FocusGram_Feed_Filtering_Reference.docx
|
||||
.codex
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
@@ -27,7 +30,6 @@ TODO.md
|
||||
#.vscode/
|
||||
RELEASE_GUIDE.md
|
||||
android/key.properties
|
||||
android/fdroid-config.properties
|
||||
android/app/*.jks
|
||||
upload-keystore.jks
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- v2/**
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
|
||||
+190
@@ -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
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* FocusGram Content Hider
|
||||
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
|
||||
* Flutter controls via window.__fgContent.*
|
||||
* Injected at DOCUMENT_END.
|
||||
*
|
||||
* Key fixes applied:
|
||||
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
|
||||
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
|
||||
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
|
||||
* - Stories tray detection strengthened for fresh SPA navigations
|
||||
* - Suggested posts detection uses multiple text-node matching strategies
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (window.__fgContent && window.__fgContent.__focusgramReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const STYLE_ID = 'fg-content-hider';
|
||||
let hideStories = false;
|
||||
let hidePosts = false;
|
||||
let hideSuggested = false;
|
||||
let hideReels = false;
|
||||
|
||||
// ─── CSS rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildCSS() {
|
||||
const selectors = [];
|
||||
|
||||
if (hideStories) {
|
||||
selectors.push(
|
||||
'[role="list"]:has([aria-label*="tory"])',
|
||||
'[role="listbox"]:has([aria-label*="tory"])',
|
||||
'[role="menu"] > ul',
|
||||
'section > div > div:first-child [style*="overflow"]',
|
||||
'[role="list"] [style*="overflow"]',
|
||||
);
|
||||
}
|
||||
|
||||
if (hidePosts) {
|
||||
selectors.push(
|
||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
|
||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
|
||||
);
|
||||
}
|
||||
|
||||
// hideReels CSS is intentionally NOT added here.
|
||||
// We use DOM removal instead (see removeReels()) so that room is never left
|
||||
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
|
||||
|
||||
return selectors.length
|
||||
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
|
||||
: '';
|
||||
}
|
||||
|
||||
function applyCSS() {
|
||||
if (document.body) {
|
||||
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
|
||||
}
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = buildCSS();
|
||||
}
|
||||
|
||||
// ─── Story tray JS ─────────────────────────────────────────────────────────
|
||||
|
||||
function hideStoryTray() {
|
||||
if (!hideStories) return;
|
||||
|
||||
// Strategy 1: <ul> children of a named list or menu
|
||||
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
|
||||
try {
|
||||
const items = ul.querySelectorAll('li, button, a');
|
||||
if (items.length < 2) return;
|
||||
ul.style.setProperty('display', 'none', 'important');
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// Strategy 2: horizontally scrolling container with circle items
|
||||
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
|
||||
try {
|
||||
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
|
||||
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
|
||||
if (cands.length < 2) return;
|
||||
const s0 = window.getComputedStyle(cands[0]);
|
||||
if (s0.width && parseFloat(s0.width) <= 90) {
|
||||
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Suggested posts ───────────────────────────────────────────────────────
|
||||
|
||||
function removeSuggested() {
|
||||
if (!hideSuggested) return;
|
||||
|
||||
var SIGNALS = [
|
||||
'suggested for you',
|
||||
'suggested posts',
|
||||
'suggested reels',
|
||||
'suggested',
|
||||
'because you watched',
|
||||
'because you follow',
|
||||
'you might like',
|
||||
'posts you might like',
|
||||
'accounts you might like',
|
||||
'recommendations',
|
||||
];
|
||||
|
||||
function norm(s) {
|
||||
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hasSignal(s) {
|
||||
var t = norm(s);
|
||||
if (!t) return false;
|
||||
return SIGNALS.some(function (signal) {
|
||||
if (signal === 'suggested') return t === signal;
|
||||
return t.indexOf(signal) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hideContainer(from) {
|
||||
var parent = from;
|
||||
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
|
||||
var role = parent.getAttribute && parent.getAttribute('role');
|
||||
var tag = parent.tagName;
|
||||
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
|
||||
if (
|
||||
tag === 'ARTICLE' ||
|
||||
tag === 'SECTION' ||
|
||||
role === 'listitem' ||
|
||||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
|
||||
) {
|
||||
parent.style.setProperty('display', 'none', 'important');
|
||||
parent.setAttribute('data-fg-hidden-suggested', '1');
|
||||
return true;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
|
||||
try {
|
||||
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
|
||||
var ownLabel = node.getAttribute('aria-label');
|
||||
if (hasSignal(ownLabel)) { hideContainer(node); return; }
|
||||
var text = norm(node.innerText || node.textContent || '');
|
||||
if (
|
||||
text.indexOf('suggested for you') >= 0 ||
|
||||
text.indexOf('suggested posts') >= 0 ||
|
||||
text.indexOf('suggested reels') >= 0 ||
|
||||
text.indexOf('because you watched') >= 0 ||
|
||||
text.indexOf('because you follow') >= 0
|
||||
) {
|
||||
hideContainer(node);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
|
||||
try {
|
||||
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
|
||||
hideContainer(el);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Reels – DOM REMOVE (not display:none) ─────────────────────────────────
|
||||
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
|
||||
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
|
||||
// gap cleanly and lets the feed flow naturally.
|
||||
function removeReels() {
|
||||
if (!hideReels) return;
|
||||
|
||||
var toRemove = [];
|
||||
document.querySelectorAll('article').forEach(function (el) {
|
||||
try {
|
||||
// Fast path: check for a reel-signal attribute first
|
||||
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
|
||||
if (mt === '2') { toRemove.push(el); return; }
|
||||
|
||||
// Fallback: text-node scan for /reels/ markers
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
var n;
|
||||
while ((n = walker.nextNode())) {
|
||||
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
|
||||
toRemove.push(el); break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
window.__fgContent = {
|
||||
__focusgramReady: true,
|
||||
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
|
||||
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
|
||||
setHideSuggested: function (val) {
|
||||
hideSuggested = !!val;
|
||||
applyCSS();
|
||||
if (val) removeSuggested();
|
||||
},
|
||||
setHideReels: function (val) {
|
||||
hideReels = !!val;
|
||||
applyCSS();
|
||||
if (val) removeReels();
|
||||
},
|
||||
applyAll: function (flags) {
|
||||
hideStories = !!flags.stories;
|
||||
hidePosts = !!flags.posts;
|
||||
hideReels = !!flags.reels;
|
||||
hideSuggested = !!flags.suggested;
|
||||
applyCSS();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideReels) removeReels();
|
||||
},
|
||||
};
|
||||
|
||||
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
|
||||
// pushState/replaceState don't fire any DOM event we can listen for.
|
||||
// Hook the methods themselves so we know a navigation happened, then debounce
|
||||
// re-apply. This also catches the case where the MutationObserver was on `body`
|
||||
// and that node got replaced by Instagram's SPA re-render.
|
||||
|
||||
function scheduleReapply() {
|
||||
clearTimeout(window.__fg_applyTimer);
|
||||
window.__fg_applyTimer = setTimeout(function () {
|
||||
applyCSS();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideReels) removeReels();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
var _origPush = history.pushState;
|
||||
var _origReplace = history.replaceState;
|
||||
|
||||
history.pushState = function () {
|
||||
_origPush.apply(this, arguments);
|
||||
scheduleReapply();
|
||||
};
|
||||
|
||||
history.replaceState = function () {
|
||||
_origReplace.apply(this, arguments);
|
||||
scheduleReapply();
|
||||
};
|
||||
|
||||
// Reinforce on popstate too (user hits back/forward)
|
||||
window.addEventListener('popstate', scheduleReapply, { passive: true });
|
||||
// For pushState on the same URL (rare but possible) – poll path briefly
|
||||
window.addEventListener('pageshow', scheduleReapply, { passive: true });
|
||||
window.addEventListener('focus', scheduleReapply, { passive: true });
|
||||
|
||||
// ─── MutationObserver ───────────────────────────────────────────────────────
|
||||
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
|
||||
// re-applies everything on each cycle. Does NOT guard on a per-element timer
|
||||
// that would never re-fire after the body is replaced by SPA re-render.
|
||||
|
||||
if (!window.__fgContentObserver) {
|
||||
window.__fgContentObserver = new MutationObserver(function () {
|
||||
clearTimeout(window.__fg_moTimer);
|
||||
window.__fg_moTimer = setTimeout(function () {
|
||||
applyCSS();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideReels) removeReels();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// `document.documentElement` survives SPA navigations (body gets replaced
|
||||
// but <html> stays). Observing it catches both subtree mutations and, via
|
||||
// the SPA heartbeat above, re-applies after pushState.
|
||||
window.__fgContentObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Initial run ────────────────────────────────────────────────────────────
|
||||
applyCSS();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideReels) removeReels();
|
||||
|
||||
// Signal ready — Flutter will call applyAll() with stored prefs
|
||||
if (window.ContentChannel) {
|
||||
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,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;
|
||||
})();
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* FocusGram Ghost Mode
|
||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
||||
* Blocks story-seen, message-seen, and online-presence signals.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ─── Seen API patterns ────────────────────────────────────────────────────
|
||||
const SEEN_PATTERNS = [
|
||||
/\/api\/v1\/media\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/stories\/reel\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
|
||||
];
|
||||
|
||||
// ─── Activity patterns (like, comment) — intercepted for local history ────
|
||||
const ACTIVITY_PATTERNS = [
|
||||
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
|
||||
/\/api\/v1\/web\/comments\/add\//,
|
||||
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
|
||||
];
|
||||
|
||||
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
|
||||
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
|
||||
|
||||
const fakeOkResponse = () =>
|
||||
new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// ─── Fetch override ───────────────────────────────────────────────────────
|
||||
const _fetch = window.fetch.bind(window);
|
||||
|
||||
const patchedFetch = async function (input, init) {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input?.url ?? '';
|
||||
|
||||
// Block seen
|
||||
if (isSeen(url)) {
|
||||
if (window.GhostChannel) {
|
||||
window.GhostChannel.postMessage(
|
||||
JSON.stringify({ type: 'seen_blocked', url })
|
||||
);
|
||||
}
|
||||
return fakeOkResponse();
|
||||
}
|
||||
|
||||
// Intercept activity for local history
|
||||
if (isActivity(url) && window.ActivityChannel) {
|
||||
const body = init?.body;
|
||||
const bodyText =
|
||||
body instanceof URLSearchParams
|
||||
? body.toString()
|
||||
: typeof body === 'string'
|
||||
? body
|
||||
: '';
|
||||
window.ActivityChannel.postMessage(
|
||||
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
|
||||
);
|
||||
}
|
||||
|
||||
return _fetch(input, init);
|
||||
};
|
||||
|
||||
// Disguise as native
|
||||
Object.defineProperty(window, 'fetch', {
|
||||
value: patchedFetch,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
||||
window.fetch[Symbol.toStringTag] = 'fetch';
|
||||
|
||||
// ─── XMLHttpRequest override ──────────────────────────────────────────────
|
||||
const _XHROpen = XMLHttpRequest.prototype.open;
|
||||
const _XHRSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
||||
this._fg_url = url ?? '';
|
||||
this._fg_method = (method ?? '').toUpperCase();
|
||||
return _XHROpen.call(this, method, url, ...args);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function (body) {
|
||||
if (this._fg_url && isSeen(this._fg_url)) {
|
||||
// Fire readyState 4 with fake success without actually sending
|
||||
const self = this;
|
||||
setTimeout(() => {
|
||||
Object.defineProperty(self, 'readyState', { get: () => 4 });
|
||||
Object.defineProperty(self, 'status', { get: () => 200 });
|
||||
Object.defineProperty(self, 'responseText', {
|
||||
get: () => '{"status":"ok"}',
|
||||
});
|
||||
Object.defineProperty(self, 'response', {
|
||||
get: () => '{"status":"ok"}',
|
||||
});
|
||||
self.dispatchEvent(new Event('readystatechange'));
|
||||
self.dispatchEvent(new Event('load'));
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
return _XHRSend.call(this, body);
|
||||
};
|
||||
|
||||
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
|
||||
const _WS = window.WebSocket;
|
||||
|
||||
function PatchedWebSocket(url, protocols) {
|
||||
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
||||
const _send = ws.send.bind(ws);
|
||||
|
||||
ws.send = function (data) {
|
||||
if (typeof data === 'string') {
|
||||
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (
|
||||
parsed?.op === '4' ||
|
||||
parsed?.op === 'seen' ||
|
||||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
|
||||
) {
|
||||
return; // drop
|
||||
}
|
||||
} catch (_) {}
|
||||
// Text-based seen signal check
|
||||
if (data.includes('"seen"') && data.includes('"thread_id"')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return _send(data);
|
||||
};
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
// Preserve WebSocket prototype chain so IG's ws checks pass
|
||||
PatchedWebSocket.prototype = _WS.prototype;
|
||||
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
|
||||
PatchedWebSocket.OPEN = _WS.OPEN;
|
||||
PatchedWebSocket.CLOSING = _WS.CLOSING;
|
||||
PatchedWebSocket.CLOSED = _WS.CLOSED;
|
||||
window.WebSocket = PatchedWebSocket;
|
||||
|
||||
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
|
||||
// Only applied if user enables online-status hiding
|
||||
// Wrapped in a named fn so Flutter can call it:
|
||||
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
|
||||
window.__fgEnableOnlineHide = function () {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
get: () => 'hidden',
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
get: () => true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
|
||||
window.__fgDisableOnlineHide = function () {
|
||||
// Restore by deleting the overrides (falls back to native getter)
|
||||
delete document.visibilityState;
|
||||
delete document.hidden;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
|
||||
// Signal to Flutter that ghost mode JS is active
|
||||
if (window.GhostChannel) {
|
||||
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,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
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -145,6 +145,7 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||
// Step 3: Breath gate
|
||||
if (settings.showBreathGate && !_breathCompleted) {
|
||||
return BreathGateScreen(
|
||||
durationSeconds: settings.breathGateSeconds,
|
||||
onFinish: () => setState(() => _breathCompleted = true),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,25 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
||||
55,
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -118,12 +136,10 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
||||
perspective: 0.003,
|
||||
squeeze: 1.1,
|
||||
diameterRatio: 2.5,
|
||||
controller: _scrollController,
|
||||
onSelectedItemChanged: (i) {
|
||||
setState(() => _selectedIndex = i);
|
||||
},
|
||||
controller: FixedExtentScrollController(
|
||||
initialItem: _selectedIndex,
|
||||
),
|
||||
childDelegate: ListWheelChildListDelegate(
|
||||
children: _minuteOptions.asMap().entries.map((entry) {
|
||||
final isSelected = entry.key == _selectedIndex;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// A mindfulness screen shown before the app opens.
|
||||
/// Forces the user to take a deep 10-second breath.
|
||||
/// A mindfulness screen shown before Instagram opens.
|
||||
class BreathGateScreen extends StatefulWidget {
|
||||
final VoidCallback onFinish;
|
||||
final int durationSeconds;
|
||||
|
||||
const BreathGateScreen({super.key, required this.onFinish});
|
||||
const BreathGateScreen({
|
||||
super.key,
|
||||
required this.onFinish,
|
||||
this.durationSeconds = 10,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
||||
@@ -16,15 +20,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
int _secondsRemaining = 10;
|
||||
late int _secondsRemaining;
|
||||
Timer? _timer;
|
||||
bool _canContinue = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
|
||||
|
||||
// 10-second breathing animation: 5s in, 5s out
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 5),
|
||||
@@ -71,7 +75,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Are you sure you want to open FocusGram?',
|
||||
'Are you sure you want to open Instagram?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
@@ -131,7 +135,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
),
|
||||
child: const Text('Continue to FocusGram'),
|
||||
child: const Text('Continue to Instagram'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,11 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
|
||||
Future<void> Function() action,
|
||||
) async {
|
||||
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;
|
||||
}
|
||||
await action();
|
||||
@@ -321,7 +325,8 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
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 (widget.title.contains('Daily Reel Limit') &&
|
||||
sm.dailyRemainingSeconds <= 0) {
|
||||
|
||||
+848
-252
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final PageController _pageController = PageController();
|
||||
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 _kBlurPage = 3;
|
||||
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final List<Widget> slides = [
|
||||
// ── Page 0: Welcome ─────────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.auto_awesome,
|
||||
color: Colors.blue,
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
color: const Color(0xFF4F8DFF),
|
||||
title: 'Welcome to FocusGram',
|
||||
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(
|
||||
icon: Icons.timer,
|
||||
color: Colors.orange,
|
||||
title: 'Session Management',
|
||||
icon: Icons.timer_outlined,
|
||||
color: const Color(0xFFFFB74D),
|
||||
title: 'Time With Intent',
|
||||
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 ───────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.link,
|
||||
color: Colors.cyan,
|
||||
icon: Icons.link_rounded,
|
||||
color: const Color(0xFF35C2D6),
|
||||
title: 'Open Links in FocusGram',
|
||||
description:
|
||||
'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 ────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.notifications_active,
|
||||
color: Colors.green,
|
||||
title: 'Stay Notified',
|
||||
icon: Icons.notifications_active_outlined,
|
||||
color: const Color(0xFF5DD18A),
|
||||
title: 'Useful Alerts Only',
|
||||
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,
|
||||
permission: Permission.notification,
|
||||
),
|
||||
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 28),
|
||||
// CTA button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final isBlur = _currentPage == _kBlurPage;
|
||||
|
||||
String label;
|
||||
if (isLast) {
|
||||
label = 'Get Started';
|
||||
if (isNotif) {
|
||||
label = 'Allow & Start';
|
||||
} else if (isLink) {
|
||||
label = 'Configure';
|
||||
} else if (isNotif) {
|
||||
label = 'Allow Notifications';
|
||||
} else if (isBlur) {
|
||||
label = 'Save & Continue';
|
||||
} else if (isLast) {
|
||||
label = 'Get Started';
|
||||
} else {
|
||||
label = 'Next';
|
||||
}
|
||||
@@ -143,7 +143,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
);
|
||||
} else if (isNotif) {
|
||||
await Permission.notification.request();
|
||||
await NotificationService().init();
|
||||
await NotificationService()
|
||||
.requestPermissionsNow();
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
@@ -178,9 +179,19 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
// Skip button (available on all pages except last)
|
||||
if (_currentPage < _kTotalPages - 1)
|
||||
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(
|
||||
'Skip',
|
||||
'Skip setup',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 14),
|
||||
),
|
||||
),
|
||||
@@ -222,18 +233,27 @@ class _StaticSlide extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
|
||||
padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 120, color: color),
|
||||
const SizedBox(height: 48),
|
||||
Container(
|
||||
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(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -243,10 +263,28 @@ class _StaticSlide extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 18,
|
||||
fontSize: 16,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -115,6 +115,7 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
||||
hideReelsTab: false,
|
||||
hideShopTab: false,
|
||||
disableReelsEntirely: false,
|
||||
blockHomeFeedScroll: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/discipline_challenge.dart';
|
||||
|
||||
class SessionModal extends StatefulWidget {
|
||||
@@ -63,23 +64,22 @@ class _SessionModalState extends State<SessionModal> {
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [1, 5, 10, 15].map((m) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||
? null
|
||||
: () => _start(m),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white12,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text('${m}m'),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [1, 3, 5, 10, 15, 20, 30].map((m) {
|
||||
return SizedBox(
|
||||
width: 72,
|
||||
child: ElevatedButton(
|
||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||
? null
|
||||
: () => _start(m),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white12,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text('${m}m'),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
|
||||
Slider(
|
||||
value: _customMinutes,
|
||||
min: 1,
|
||||
max: 30,
|
||||
divisions: 29,
|
||||
max: 60,
|
||||
divisions: 59,
|
||||
label: '${_customMinutes.toInt()}m',
|
||||
onChanged: (v) => setState(() => _customMinutes = v),
|
||||
),
|
||||
@@ -126,10 +126,15 @@ class _SessionModalState extends State<SessionModal> {
|
||||
|
||||
void _start(int minutes) async {
|
||||
final sm = context.read<SessionManager>();
|
||||
final settings = context.read<SettingsService>();
|
||||
|
||||
// Always require word challenge for reel sessions (User request)
|
||||
final success = await DisciplineChallenge.show(context);
|
||||
if (!success) return;
|
||||
if (settings.requireWordChallenge) {
|
||||
final success = await DisciplineChallenge.show(
|
||||
context,
|
||||
count: settings.resolvedWordChallengeCount(),
|
||||
);
|
||||
if (!success) return;
|
||||
}
|
||||
|
||||
if (sm.startSession(minutes)) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
+447
-80
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.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 '../features/screen_time/screen_time_screen.dart';
|
||||
import 'guardrails_page.dart';
|
||||
import 'extras_settings_page.dart';
|
||||
|
||||
// ─── Main Settings Page ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,6 +36,7 @@ class SettingsPage extends StatelessWidget {
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
const _DonateTile(),
|
||||
_buildStatsRow(sm),
|
||||
|
||||
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'),
|
||||
_SubmoduleTile(
|
||||
icon: Icons.palette_outlined,
|
||||
@@ -264,6 +280,7 @@ class FocusSettingsPage extends StatelessWidget {
|
||||
body: ListView(
|
||||
children: [
|
||||
const _SectionHeader(title: 'BLOCKING'),
|
||||
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -293,12 +310,23 @@ class FocusSettingsPage extends StatelessWidget {
|
||||
color: Colors.redAccent.withValues(alpha: 0.12),
|
||||
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)),
|
||||
subtitle: Text(
|
||||
settings.minimalModeEnabled ? 'Enabled - tap to customize' : 'Disabled - tap to configure',
|
||||
style: TextStyle(fontSize: 12, color: settings.minimalModeEnabled ? Colors.greenAccent : Colors.grey),
|
||||
settings.minimalModeEnabled
|
||||
? 'Enabled - tap to customize'
|
||||
: 'Disabled - tap to configure',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: settings.minimalModeEnabled
|
||||
? Colors.greenAccent
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
trailing: Switch(
|
||||
value: settings.minimalModeEnabled,
|
||||
@@ -307,22 +335,49 @@ class FocusSettingsPage extends StatelessWidget {
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
),
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage())),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage()),
|
||||
),
|
||||
),
|
||||
|
||||
const _SectionHeader(title: 'FRICTION'),
|
||||
_SwitchTile(
|
||||
title: 'Mindfulness Gate',
|
||||
subtitle: 'Breath screen before opening Instagram',
|
||||
subtitle: '${settings.breathGateSeconds}s before opening Instagram',
|
||||
value: settings.showBreathGate,
|
||||
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(
|
||||
title: 'Strict Mode (Word Challenge)',
|
||||
subtitle: 'Must type a phrase before starting a Reel session',
|
||||
title: 'Typing Challenge',
|
||||
subtitle: settings.wordChallengeCount == 0
|
||||
? 'Random: 10-35 words'
|
||||
: '${settings.wordChallengeCount} words',
|
||||
value: settings.requireWordChallenge,
|
||||
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'),
|
||||
_SwitchTile(
|
||||
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),
|
||||
],
|
||||
),
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
class MinimalModeSubmenuPage extends StatefulWidget {
|
||||
@@ -368,6 +509,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
late bool _blurExplore;
|
||||
late bool _disableReelsEntirely;
|
||||
late bool _disableExploreEntirely;
|
||||
late bool _blockHomeFeedScroll;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -376,26 +518,51 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
_blurExplore = settings.blurExplore;
|
||||
_disableReelsEntirely = settings.disableReelsEntirely;
|
||||
_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>();
|
||||
setState(() {
|
||||
switch (key) {
|
||||
case 'blurExplore':
|
||||
_blurExplore = value;
|
||||
settings.setBlurExplore(value);
|
||||
break;
|
||||
case 'disableReelsEntirely':
|
||||
_disableReelsEntirely = value;
|
||||
settings.setDisableReelsEntirelyInternal(value);
|
||||
break;
|
||||
case 'disableExploreEntirely':
|
||||
_disableExploreEntirely = value;
|
||||
settings.setDisableExploreEntirelyInternal(value);
|
||||
break;
|
||||
case 'blockHomeFeedScroll':
|
||||
_blockHomeFeedScroll = value;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -406,6 +573,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
_blurExplore = true;
|
||||
_disableReelsEntirely = true;
|
||||
_disableExploreEntirely = true;
|
||||
_blockHomeFeedScroll = true;
|
||||
});
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
@@ -418,6 +586,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
_blurExplore = settings.blurExplore;
|
||||
_disableReelsEntirely = settings.disableReelsEntirely;
|
||||
_disableExploreEntirely = settings.disableExploreEntirely;
|
||||
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
||||
});
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
@@ -437,61 +606,88 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
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: 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),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
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(
|
||||
children: [
|
||||
Icon(
|
||||
isMinimalModeEnabled ? Icons.shield_rounded : Icons.shield_outlined,
|
||||
isMinimalModeEnabled
|
||||
? Icons.shield_rounded
|
||||
: Icons.shield_outlined,
|
||||
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
isMinimalModeEnabled ? 'Minimal Mode Active' : 'Minimal Mode Disabled',
|
||||
isMinimalModeEnabled
|
||||
? 'Minimal Mode Active'
|
||||
: 'Minimal Mode Disabled',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
|
||||
color: isMinimalModeEnabled
|
||||
? Colors.redAccent
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isMinimalModeEnabled
|
||||
isMinimalModeEnabled
|
||||
? 'Distractions are blocked. Customize which features stay enabled below.'
|
||||
: 'Turn on to block all distractions at once, or customize individual settings below.',
|
||||
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),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: isMinimalModeEnabled ? _turnOffMinimalMode : _turnOnMinimalMode,
|
||||
onPressed: isMinimalModeEnabled
|
||||
? _turnOffMinimalMode
|
||||
: _turnOnMinimalMode,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isMinimalModeEnabled ? Colors.grey : Colors.redAccent,
|
||||
backgroundColor: isMinimalModeEnabled
|
||||
? Colors.grey
|
||||
: Colors.redAccent,
|
||||
foregroundColor: Colors.white,
|
||||
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'),
|
||||
|
||||
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -502,7 +698,11 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.touch_app_rounded, size: 14, color: Colors.blueAccent),
|
||||
Icon(
|
||||
Icons.touch_app_rounded,
|
||||
size: 14,
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -513,13 +713,19 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
_SwitchTile(
|
||||
title: 'Blur Feed & Explore',
|
||||
subtitle: 'Blurs post thumbnails until tapped',
|
||||
value: _blurExplore,
|
||||
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(
|
||||
title: 'Disable Reels Entirely',
|
||||
subtitle: 'Block all Reels with no session option',
|
||||
@@ -532,7 +738,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
value: _disableExploreEntirely,
|
||||
onChanged: (v) => _updateSetting('disableExploreEntirely', v),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
@@ -550,43 +756,52 @@ class AppearancePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
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(
|
||||
context: context,
|
||||
initialTime: const TimeOfDay(hour: 21, minute: 0),
|
||||
helpText: 'Select start time',
|
||||
);
|
||||
|
||||
|
||||
if (startTime == null || !context.mounted) return;
|
||||
|
||||
|
||||
TimeOfDay? endTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: const TimeOfDay(hour: 6, minute: 0),
|
||||
helpText: 'Select end time',
|
||||
);
|
||||
|
||||
|
||||
if (endTime == null || !context.mounted) return;
|
||||
|
||||
|
||||
final newSchedule = {
|
||||
'enabled': true,
|
||||
'startTime': '${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')}',
|
||||
'startTime':
|
||||
'${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);
|
||||
}
|
||||
|
||||
Future<void> _editSchedule(BuildContext context, SettingsService settings, int index) async {
|
||||
Future<void> _editSchedule(
|
||||
BuildContext context,
|
||||
SettingsService settings,
|
||||
int index,
|
||||
) async {
|
||||
final schedules = settings.grayscaleSchedules;
|
||||
if (index >= schedules.length) return;
|
||||
|
||||
|
||||
final current = schedules[index];
|
||||
final startParts = (current['startTime'] as String).split(':');
|
||||
final endParts = (current['endTime'] as String).split(':');
|
||||
|
||||
|
||||
// Capture context before async gap
|
||||
final capturedContext = context;
|
||||
|
||||
|
||||
TimeOfDay? startTime = await showTimePicker(
|
||||
context: capturedContext,
|
||||
initialTime: TimeOfDay(
|
||||
@@ -595,9 +810,9 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
),
|
||||
helpText: 'Select start time',
|
||||
);
|
||||
|
||||
|
||||
if (startTime == null || !capturedContext.mounted) return;
|
||||
|
||||
|
||||
TimeOfDay? endTime = await showTimePicker(
|
||||
context: capturedContext,
|
||||
initialTime: TimeOfDay(
|
||||
@@ -606,27 +821,31 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
),
|
||||
helpText: 'Select end time',
|
||||
);
|
||||
|
||||
|
||||
if (endTime == null || !capturedContext.mounted) return;
|
||||
|
||||
|
||||
final updatedSchedule = {
|
||||
...current,
|
||||
'startTime': '${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')}',
|
||||
'startTime':
|
||||
'${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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
schedules[index] = {
|
||||
...schedules[index],
|
||||
'enabled': !(schedules[index]['enabled'] as bool),
|
||||
};
|
||||
|
||||
|
||||
await settings.setGrayscaleSchedules(schedules);
|
||||
}
|
||||
|
||||
@@ -648,7 +867,7 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
if (confirmed == true) {
|
||||
await settings.removeGrayscaleSchedule(index);
|
||||
}
|
||||
@@ -669,7 +888,8 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
const _SectionHeader(title: 'DISPLAY'),
|
||||
_SwitchTile(
|
||||
title: 'Grayscale Mode',
|
||||
subtitle: 'Makes Instagram black & white — reduces dopamine response',
|
||||
subtitle:
|
||||
'Makes Instagram black & white — reduces dopamine response',
|
||||
value: settings.grayscaleEnabled,
|
||||
onChanged: (v) => settings.setGrayscaleEnabled(v),
|
||||
),
|
||||
@@ -687,7 +907,7 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
style: TextStyle(fontSize: 12, height: 1.5),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Status indicator
|
||||
if (settings.grayscaleSchedules.isNotEmpty)
|
||||
Padding(
|
||||
@@ -695,26 +915,38 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
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),
|
||||
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(
|
||||
children: [
|
||||
Icon(
|
||||
settings.isGrayscaleActiveNow ? Icons.check_circle : Icons.schedule,
|
||||
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent,
|
||||
size: 20
|
||||
settings.isGrayscaleActiveNow
|
||||
? Icons.check_circle
|
||||
: Icons.schedule,
|
||||
color: settings.isGrayscaleActiveNow
|
||||
? Colors.greenAccent
|
||||
: Colors.orangeAccent,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
settings.isGrayscaleActiveNow
|
||||
? 'Grayscale is active now'
|
||||
settings.isGrayscaleActiveNow
|
||||
? 'Grayscale is active now'
|
||||
: 'Grayscale is currently inactive',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent
|
||||
fontSize: 13,
|
||||
color: settings.isGrayscaleActiveNow
|
||||
? Colors.greenAccent
|
||||
: Colors.orangeAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -722,7 +954,7 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Schedule list
|
||||
...List.generate(settings.grayscaleSchedules.length, (index) {
|
||||
final schedule = settings.grayscaleSchedules[index];
|
||||
@@ -732,11 +964,14 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
width: 36,
|
||||
height: 36,
|
||||
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),
|
||||
),
|
||||
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,
|
||||
size: 20,
|
||||
),
|
||||
@@ -750,7 +985,10 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
),
|
||||
subtitle: Text(
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -795,7 +1033,7 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
onTap: () => _editSchedule(context, settings, index),
|
||||
);
|
||||
}),
|
||||
|
||||
|
||||
// Add schedule button
|
||||
ListTile(
|
||||
leading: Container(
|
||||
@@ -805,16 +1043,26 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
color: Colors.green.withValues(alpha: 0.12),
|
||||
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(
|
||||
'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),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
@@ -966,15 +1214,9 @@ class _SwitchTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile(
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
subtitle ?? '',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
)
|
||||
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
|
||||
: null,
|
||||
value: value,
|
||||
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 {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@@ -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"]';
|
||||
|
||||
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/') &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||
if (isDmReel) return 'dm_reel';
|
||||
// Only lock scroll when reel element is actually present on the page
|
||||
if (window.__fgDisableReelsEntirely === true &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled';
|
||||
if (window.__fgBlockHomeFeedScroll === true &&
|
||||
(window.location.pathname === '/' || window.location.pathname === '')) {
|
||||
return 'home_feed';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -338,8 +340,7 @@ const String kReelsMutationObserverJS = r'''
|
||||
try {
|
||||
const mode = lockMode();
|
||||
const hasReel = !!document.querySelector(REEL_SEL);
|
||||
// Apply lock for dm_reel or disabled modes when reel is present
|
||||
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
|
||||
if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
|
||||
if (__fgOrigHtmlOverflow === null) {
|
||||
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
||||
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(/&/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 can’t 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(),
|
||||
};
|
||||
}
|
||||
@@ -57,15 +57,15 @@ class InjectionController {
|
||||
required bool blurReels,
|
||||
required bool tapToUnblur,
|
||||
required bool enableTextSelection,
|
||||
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideSuggestedPosts,
|
||||
required bool hideSponsoredPosts,
|
||||
required bool hideLikeCounts,
|
||||
required bool hideFollowerCounts,
|
||||
// hideStoriesBar parameter removed per user request
|
||||
required bool hideExploreTab,
|
||||
required bool hideReelsTab,
|
||||
required bool hideShopTab,
|
||||
required bool disableReelsEntirely,
|
||||
required bool blockHomeFeedScroll,
|
||||
}) {
|
||||
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
||||
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
||||
@@ -75,18 +75,12 @@ class InjectionController {
|
||||
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 (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
||||
|
||||
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
||||
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
||||
// Stories hiding removed per user request
|
||||
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
||||
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
||||
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
||||
@@ -94,6 +88,7 @@ class InjectionController {
|
||||
return '''
|
||||
${buildSessionStateJS(sessionActive)}
|
||||
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
||||
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
|
||||
window.__fgTapToUnblur = $tapToUnblur;
|
||||
${scripts.kTrackPathJS}
|
||||
${_buildMutationObserver(css.toString())}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'injection_controller.dart';
|
||||
import '../scripts/grayscale.dart' as grayscale;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
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.
|
||||
//
|
||||
@@ -386,18 +387,39 @@ const String kLinkSanitizationJS = r'''
|
||||
|
||||
// ── InjectionManager class ─────────────────────────────────────────────────
|
||||
|
||||
class InjectionManager {
|
||||
abstract class JsEvaluator {
|
||||
Future<void> evaluateJavascript({required String source});
|
||||
}
|
||||
|
||||
class _WebViewJsEvaluator implements JsEvaluator {
|
||||
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 SessionManager sessionManager;
|
||||
|
||||
SettingsService? _settingsService;
|
||||
|
||||
InjectionManager({
|
||||
required this.controller,
|
||||
required InAppWebViewController controller,
|
||||
required this.prefs,
|
||||
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) {
|
||||
_settingsService = settingsService;
|
||||
@@ -415,18 +437,19 @@ class InjectionManager {
|
||||
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
|
||||
final tapToUnblur = settings.tapToUnblur;
|
||||
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 hideFollowerCounts = settings.hideFollowerCounts;
|
||||
// Stories hiding functionality removed per user request
|
||||
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
|
||||
// These are now only controllable via minimal mode submenu
|
||||
final disableExploreEntirely = settings.disableExploreEntirely;
|
||||
final disableReelsEntirely = settings.disableReelsEntirely;
|
||||
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
||||
final hideExploreTab = disableExploreEntirely;
|
||||
final hideReelsTab = disableReelsEntirely;
|
||||
final hideShopTab = settings.hideShopTab;
|
||||
final isGrayscaleActive = settings.isGrayscaleActiveNow;
|
||||
|
||||
final injectionJS = InjectionController.buildInjectionJS(
|
||||
sessionActive: sessionActive,
|
||||
@@ -434,33 +457,35 @@ class InjectionManager {
|
||||
blurReels: false, // Blur reels feature removed
|
||||
tapToUnblur: blurExplore && tapToUnblur,
|
||||
enableTextSelection: enableTextSelection,
|
||||
hideSuggestedPosts: false, // Feature removed
|
||||
hideSponsoredPosts: hideSponsoredPosts,
|
||||
hideSuggestedPosts: hideSuggestedPosts,
|
||||
hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
|
||||
hideLikeCounts: hideLikeCounts,
|
||||
hideFollowerCounts: hideFollowerCounts,
|
||||
// hideStoriesBar removed per user request
|
||||
hideExploreTab: hideExploreTab,
|
||||
hideReelsTab: hideReelsTab,
|
||||
hideShopTab: hideShopTab,
|
||||
disableReelsEntirely: disableReelsEntirely,
|
||||
blockHomeFeedScroll: blockHomeFeedScroll,
|
||||
);
|
||||
|
||||
try {
|
||||
await controller.evaluateJavascript(source: injectionJS);
|
||||
await _jsEvaluator.evaluateJavascript(source: injectionJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
|
||||
// Inject grayscale when active, remove when not active
|
||||
if (isGrayscaleActive) {
|
||||
if (settings.isGrayscaleActiveNow) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: grayscale.kGrayscaleOffJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
@@ -469,7 +494,9 @@ class InjectionManager {
|
||||
// Inject hide like counts JS when enabled
|
||||
if (hideLikeCounts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: ui_hider.kHideLikeCountsJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
@@ -478,11 +505,11 @@ class InjectionManager {
|
||||
// Stories hiding functionality removed per user request
|
||||
// No stories overlay injection needed
|
||||
|
||||
// Inject hide sponsored posts JS when enabled
|
||||
if (hideSponsoredPosts) {
|
||||
// Inject video downloader UI when enabled
|
||||
if (settings.videoDownloadEnabled) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: ui_hider.kHideSponsoredPostsJS,
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: video_downloader.kVideoDownloadJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
@@ -492,7 +519,7 @@ class InjectionManager {
|
||||
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
||||
if (disableReelsEntirely) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: content_disabling.kDmReelBlockerJS,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -9,16 +9,16 @@ class NotificationService {
|
||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> init() async {
|
||||
Future<void> init({bool requestPermissions = false}) async {
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// Request permissions for iOS
|
||||
final DarwinInitializationSettings initializationSettingsIOS =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
requestAlertPermission: requestPermissions,
|
||||
requestBadgePermission: requestPermissions,
|
||||
requestSoundPermission: requestPermissions,
|
||||
defaultPresentAlert: true,
|
||||
defaultPresentBadge: true,
|
||||
defaultPresentSound: true,
|
||||
@@ -37,7 +37,12 @@ class NotificationService {
|
||||
},
|
||||
);
|
||||
|
||||
// Request permissions after initialization
|
||||
if (requestPermissions) {
|
||||
await requestPermissionsNow();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestPermissionsNow() async {
|
||||
await _requestIOSPermissions();
|
||||
await _requestAndroidPermissions();
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
///
|
||||
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
||||
/// {
|
||||
/// "2026-02-26": 3420, // seconds
|
||||
/// "2026-02-25": 1800
|
||||
/// "2026-05-26": 3420, // seconds
|
||||
/// "2026-05-25": 1800
|
||||
/// }
|
||||
///
|
||||
/// All data stays on-device only.
|
||||
@@ -22,6 +22,8 @@ class ScreenTimeService extends ChangeNotifier {
|
||||
bool _tracking = false;
|
||||
|
||||
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
||||
int get totalSeconds =>
|
||||
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
@@ -37,9 +39,7 @@ class ScreenTimeService extends ChangeNotifier {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
_secondsByDate = decoded.map(
|
||||
(k, v) => MapEntry(k, (v as num).toInt()),
|
||||
);
|
||||
_secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
|
||||
}
|
||||
} catch (_) {
|
||||
_secondsByDate = {};
|
||||
@@ -104,4 +104,3 @@ class ScreenTimeService extends ChangeNotifier {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class SessionManager extends ChangeNotifier {
|
||||
static const _keyAppSessionEnd = 'app_sess_end_ts';
|
||||
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
|
||||
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
|
||||
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
|
||||
static const _keyDailyOpenCount = 'app_open_count';
|
||||
static const _keyScheduleEnabled = 'sched_enabled';
|
||||
static const _keyScheduleStartHour = 'sched_start_h';
|
||||
@@ -81,6 +82,7 @@ class SessionManager extends ChangeNotifier {
|
||||
bool _appSessionExpiredFlag =
|
||||
false; // set when time runs out, waiting for user action
|
||||
int _dailyOpenCount = 0;
|
||||
int _lastAppSessionMinutes = 5;
|
||||
|
||||
// ── Scheduled Blocking runtime ─────────────────────────────
|
||||
bool _scheduleEnabled = false;
|
||||
@@ -90,8 +92,10 @@ class SessionManager extends ChangeNotifier {
|
||||
int _schedEndMin = 0;
|
||||
List<FocusSchedule> _schedules = [];
|
||||
bool _lastScheduleState = false;
|
||||
bool _scheduleNotificationShown = 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 _scheduleNotificationShown =
|
||||
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
|
||||
int _cachedRemainingSessionSeconds = 0;
|
||||
@@ -175,6 +179,7 @@ class SessionManager extends ChangeNotifier {
|
||||
|
||||
/// How many times the user has opened the app today.
|
||||
int get dailyOpenCount => _dailyOpenCount;
|
||||
int get lastAppSessionMinutes => _lastAppSessionMinutes;
|
||||
|
||||
// ── Scheduled Blocking Getters ─────────────────────────────
|
||||
bool get scheduleEnabled => _scheduleEnabled;
|
||||
@@ -309,6 +314,7 @@ class SessionManager extends ChangeNotifier {
|
||||
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
|
||||
}
|
||||
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
|
||||
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
|
||||
|
||||
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 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 (DateTime.now().isAfter(_appSessionEnd!)) {
|
||||
_appSessionExpiredFlag = true;
|
||||
changed = true;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (isCooldownActive) {
|
||||
@@ -396,7 +402,7 @@ class SessionManager extends ChangeNotifier {
|
||||
if (sched != _lastScheduleState) {
|
||||
_lastScheduleState = sched;
|
||||
changed = true;
|
||||
|
||||
|
||||
// Show notification when schedule becomes active
|
||||
if (sched && !_scheduleNotificationShown) {
|
||||
_scheduleNotificationShown = true;
|
||||
@@ -420,10 +426,11 @@ class SessionManager extends ChangeNotifier {
|
||||
// (i.e., when loading an expired session from a previous app session)
|
||||
if (showNotification && !_sessionEndNotificationShown) {
|
||||
_sessionEndNotificationShown = true;
|
||||
|
||||
|
||||
// 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) {
|
||||
NotificationService().showNotification(
|
||||
id: 999,
|
||||
@@ -432,7 +439,7 @@ class SessionManager extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_isSessionActive = false;
|
||||
_sessionExpiry = null;
|
||||
_lastSessionEnd = DateTime.now();
|
||||
@@ -448,7 +455,8 @@ class SessionManager extends ChangeNotifier {
|
||||
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
|
||||
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
|
||||
_isSessionActive = true;
|
||||
_sessionEndNotificationShown = false; // Reset notification flag for new session
|
||||
_sessionEndNotificationShown =
|
||||
false; // Reset notification flag for new session
|
||||
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
|
||||
notifyListeners();
|
||||
return true;
|
||||
@@ -482,8 +490,10 @@ class SessionManager extends ChangeNotifier {
|
||||
_appSessionEnd = end;
|
||||
_appSessionExpiredFlag = false;
|
||||
_appExtensionUsed = false;
|
||||
_lastAppSessionMinutes = minutes;
|
||||
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
|
||||
_prefs?.setBool(_keyAppSessionExtUsed, false);
|
||||
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,18 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'notification_service.dart';
|
||||
|
||||
/// Stores and retrieves all user-configurable app settings.
|
||||
class SettingsService extends ChangeNotifier {
|
||||
static const _keyBlurExplore = 'set_blur_explore';
|
||||
static const _keyBlurReels = 'set_blur_reels';
|
||||
static const _keyTapToUnblur = 'set_tap_to_unblur';
|
||||
static const _keyRequireLongPress = 'set_require_long_press';
|
||||
static const _keyShowBreathGate = 'set_show_breath_gate';
|
||||
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 _keyEnabledTabs = 'set_enabled_tabs';
|
||||
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
||||
@@ -18,23 +22,42 @@ class SettingsService extends ChangeNotifier {
|
||||
// Focus / playback
|
||||
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
|
||||
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
||||
static const _keyGrayscaleSchedules = 'grayscale_schedules';
|
||||
|
||||
// Content filtering / UI hiding
|
||||
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
|
||||
static const _keyHideLikeCounts = 'hide_like_counts';
|
||||
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
||||
static const _keyHideShopTab = 'hide_shop_tab';
|
||||
|
||||
// Minimal mode
|
||||
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
||||
|
||||
|
||||
// Minimal mode state tracking for smart restore
|
||||
static const _keyMinimalModePrevDisableReels = 'minimal_mode_prev_disable_reels';
|
||||
static const _keyMinimalModePrevDisableExplore = 'minimal_mode_prev_disable_explore';
|
||||
static const _keyMinimalModePrevBlurExplore = 'minimal_mode_prev_blur_explore';
|
||||
static const _keyMinimalModePrevDisableReels =
|
||||
'minimal_mode_prev_disable_reels';
|
||||
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
|
||||
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
||||
@@ -46,6 +69,14 @@ class SettingsService extends ChangeNotifier {
|
||||
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
||||
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;
|
||||
|
||||
bool _blurExplore = true;
|
||||
@@ -54,19 +85,33 @@ class SettingsService extends ChangeNotifier {
|
||||
bool _requireLongPress = true;
|
||||
bool _showBreathGate = true;
|
||||
bool _requireWordChallenge = true;
|
||||
int _breathGateSeconds = 10;
|
||||
int _wordChallengeCount = 30;
|
||||
bool _enableTextSelection = false;
|
||||
bool _showInstaSettings = true;
|
||||
bool _isDarkMode = true; // Default to dark as per existing app theme
|
||||
|
||||
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;
|
||||
|
||||
// Grayscale schedules - list of {enabled, startTime, endTime}
|
||||
// startTime and endTime are in format "HH:MM"
|
||||
List<Map<String, dynamic>> _grayscaleSchedules = [];
|
||||
|
||||
bool _hideSponsoredPosts = false;
|
||||
// Content filtering / UI hiding
|
||||
bool _hideLikeCounts = false;
|
||||
bool _hideFollowerCounts = false;
|
||||
bool _hideShopTab = false;
|
||||
@@ -74,12 +119,14 @@ class SettingsService extends ChangeNotifier {
|
||||
// These are now controlled internally by minimal mode
|
||||
bool _disableReelsEntirely = false;
|
||||
bool _disableExploreEntirely = false;
|
||||
bool _blockHomeFeedScroll = false;
|
||||
bool _minimalModeEnabled = false;
|
||||
|
||||
// Tracking for smart restore
|
||||
bool _prevDisableReels = false;
|
||||
bool _prevDisableExplore = false;
|
||||
bool _prevBlurExplore = false;
|
||||
bool _prevBlockHomeFeedScroll = false;
|
||||
|
||||
bool _reelsHistoryEnabled = true;
|
||||
|
||||
@@ -90,6 +137,14 @@ class SettingsService extends ChangeNotifier {
|
||||
bool _notifySessionEnd = 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 = [
|
||||
'Home',
|
||||
'Search',
|
||||
@@ -105,12 +160,28 @@ class SettingsService extends ChangeNotifier {
|
||||
bool get requireLongPress => _requireLongPress;
|
||||
bool get showBreathGate => _showBreathGate;
|
||||
bool get requireWordChallenge => _requireWordChallenge;
|
||||
int get breathGateSeconds => _breathGateSeconds;
|
||||
int get wordChallengeCount => _wordChallengeCount;
|
||||
bool get enableTextSelection => _enableTextSelection;
|
||||
bool get showInstaSettings => _showInstaSettings;
|
||||
List<String> get enabledTabs => _enabledTabs;
|
||||
bool get isFirstRun => _isFirstRun;
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
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 notifyActivity => _notifyActivity;
|
||||
bool get notifySessionEnd => _notifySessionEnd;
|
||||
@@ -119,14 +190,22 @@ class SettingsService extends ChangeNotifier {
|
||||
bool get grayscaleEnabled => _grayscaleEnabled;
|
||||
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
|
||||
|
||||
bool get hideSponsoredPosts => _hideSponsoredPosts;
|
||||
bool get hideLikeCounts => _hideLikeCounts;
|
||||
bool get hideFollowerCounts => _hideFollowerCounts;
|
||||
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
|
||||
bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely;
|
||||
bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely;
|
||||
bool get disableReelsEntirely => _disableReelsEntirely;
|
||||
bool get disableExploreEntirely => _disableExploreEntirely;
|
||||
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
|
||||
bool get minimalModeEnabled => _minimalModeEnabled;
|
||||
|
||||
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
||||
@@ -136,22 +215,23 @@ class SettingsService extends ChangeNotifier {
|
||||
bool get isGrayscaleActiveNow {
|
||||
if (_grayscaleEnabled) return true;
|
||||
if (_grayscaleSchedules.isEmpty) return false;
|
||||
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentMinutes = now.hour * 60 + now.minute;
|
||||
|
||||
|
||||
for (final schedule in _grayscaleSchedules) {
|
||||
if (schedule['enabled'] != true) continue;
|
||||
|
||||
|
||||
try {
|
||||
final startParts = (schedule['startTime'] as String).split(':');
|
||||
final endParts = (schedule['endTime'] as String).split(':');
|
||||
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
// Handle overnight schedules (e.g., 21:00 to 06:00)
|
||||
if (endMinutes < startMinutes) {
|
||||
// Overnight: active if current time is >= start OR < end
|
||||
@@ -182,43 +262,80 @@ class SettingsService extends ChangeNotifier {
|
||||
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
||||
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? 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;
|
||||
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? 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
|
||||
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
|
||||
if (schedulesJson != null) {
|
||||
try {
|
||||
_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 (_) {
|
||||
_grayscaleSchedules = [];
|
||||
}
|
||||
}
|
||||
|
||||
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
|
||||
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
||||
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
||||
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
||||
|
||||
// Load minimal mode
|
||||
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
||||
|
||||
|
||||
// Load previous states for smart restore
|
||||
_prevDisableReels = _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
||||
_prevDisableExplore = _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
||||
_prevDisableReels =
|
||||
_prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
||||
_prevDisableExplore =
|
||||
_prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
||||
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
|
||||
_prevBlockHomeFeedScroll =
|
||||
_prefs!.getBool(_keyMinimalModePrevBlockHomeFeedScroll) ?? false;
|
||||
|
||||
// These are now internal states, not user-facing settings
|
||||
_disableReelsEntirely = _prefs!.getBool('internal_disable_reels_entirely') ?? false;
|
||||
_disableExploreEntirely = _prefs!.getBool('internal_disable_explore_entirely') ?? false;
|
||||
_disableReelsEntirely =
|
||||
_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;
|
||||
|
||||
// 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;
|
||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
||||
@@ -245,12 +362,12 @@ class SettingsService extends ChangeNotifier {
|
||||
|
||||
Future<void> setBlurExplore(bool v) async {
|
||||
_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);
|
||||
|
||||
if (_minimalModeEnabled) {
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -289,6 +406,30 @@ class SettingsService extends ChangeNotifier {
|
||||
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 {
|
||||
_enableTextSelection = v;
|
||||
await _prefs?.setBool(_keyEnableTextSelection, v);
|
||||
@@ -307,13 +448,29 @@ class SettingsService extends ChangeNotifier {
|
||||
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 {
|
||||
_grayscaleEnabled = v;
|
||||
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleSchedules(List<Map<String, dynamic>> schedules) async {
|
||||
Future<void> setGrayscaleSchedules(
|
||||
List<Map<String, dynamic>> schedules,
|
||||
) async {
|
||||
_grayscaleSchedules = schedules;
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
|
||||
notifyListeners();
|
||||
@@ -321,14 +478,23 @@ class SettingsService extends ChangeNotifier {
|
||||
|
||||
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
|
||||
_grayscaleSchedules.add(schedule);
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
await _prefs?.setString(
|
||||
_keyGrayscaleSchedules,
|
||||
jsonEncode(_grayscaleSchedules),
|
||||
);
|
||||
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) {
|
||||
_grayscaleSchedules[index] = schedule;
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
await _prefs?.setString(
|
||||
_keyGrayscaleSchedules,
|
||||
jsonEncode(_grayscaleSchedules),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -336,20 +502,76 @@ class SettingsService extends ChangeNotifier {
|
||||
Future<void> removeGrayscaleSchedule(int index) async {
|
||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
||||
_grayscaleSchedules.removeAt(index);
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
await _prefs?.setString(
|
||||
_keyGrayscaleSchedules,
|
||||
jsonEncode(_grayscaleSchedules),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setHideSponsoredPosts(bool v) async {
|
||||
_hideSponsoredPosts = v;
|
||||
await _prefs?.setBool(_keyHideSponsoredPosts, v);
|
||||
Future<void> setHideShopTab(bool v) async {
|
||||
_hideShopTab = v;
|
||||
await _prefs?.setBool(_keyHideShopTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideLikeCounts(bool v) async {
|
||||
_hideLikeCounts = v;
|
||||
await _prefs?.setBool(_keyHideLikeCounts, v);
|
||||
// ── FocusGram v2 overlay setters ──────────────────────────────────────────
|
||||
Future<void> setV2GhostModeEnabled(bool v) async {
|
||||
_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();
|
||||
}
|
||||
|
||||
@@ -359,62 +581,138 @@ class SettingsService extends ChangeNotifier {
|
||||
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)
|
||||
/// Auto-disables minimal mode if all features are turned off
|
||||
Future<void> setDisableReelsEntirelyInternal(bool v) async {
|
||||
_disableReelsEntirely = v;
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', v);
|
||||
|
||||
// Check if minimal mode should auto-disable
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
_disableExploreEntirely = v;
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', v);
|
||||
|
||||
// Check if minimal mode should auto-disable
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
|
||||
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
|
||||
Future<void> setMinimalModeEnabled(bool v) async {
|
||||
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;
|
||||
_prevDisableExplore = _disableExploreEntirely;
|
||||
_prevBlurExplore = _blurExplore;
|
||||
|
||||
_prevBlockHomeFeedScroll = _blockHomeFeedScroll;
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
|
||||
await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore);
|
||||
await _prefs?.setBool(
|
||||
_keyMinimalModePrevDisableExplore,
|
||||
_prevDisableExplore,
|
||||
);
|
||||
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
|
||||
|
||||
// Enable all minimal mode settings
|
||||
await _prefs?.setBool(
|
||||
_keyMinimalModePrevBlockHomeFeedScroll,
|
||||
_prevBlockHomeFeedScroll,
|
||||
);
|
||||
|
||||
_minimalModeEnabled = true;
|
||||
_disableReelsEntirely = 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('internal_disable_reels_entirely', true);
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', true);
|
||||
await _prefs?.setBool('internal_block_home_feed_scroll', true);
|
||||
await _prefs?.setBool(_keyBlurExplore, true);
|
||||
} 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;
|
||||
|
||||
// Simply restore to the states that were saved BEFORE minimal mode was enabled
|
||||
_disableReelsEntirely = _prevDisableReels;
|
||||
_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;
|
||||
|
||||
// Save the restored states
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', _disableReelsEntirely);
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', _disableExploreEntirely);
|
||||
await _prefs?.setBool(
|
||||
'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);
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -441,24 +739,69 @@ class SettingsService extends ChangeNotifier {
|
||||
Future<void> setNotifyDMs(bool v) async {
|
||||
_notifyDMs = v;
|
||||
await _prefs?.setBool(_keyNotifyDMs, v);
|
||||
if (v) await NotificationService().requestPermissionsNow();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifyActivity(bool v) async {
|
||||
_notifyActivity = v;
|
||||
await _prefs?.setBool(_keyNotifyActivity, v);
|
||||
if (v) await NotificationService().requestPermissionsNow();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifySessionEnd(bool v) async {
|
||||
_notifySessionEnd = v;
|
||||
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
||||
if (v) await NotificationService().requestPermissionsNow();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifyPersistent(bool v) async {
|
||||
_notifyPersistent = 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -517,7 +517,7 @@ class DisciplineChallenge {
|
||||
];
|
||||
|
||||
/// 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 challenge = list.take(count).join(' ');
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -37,10 +37,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_settings
|
||||
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
|
||||
sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.1"
|
||||
version: "7.0.0"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -213,10 +213,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
|
||||
sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.69.2"
|
||||
version: "0.71.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -290,10 +290,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.1"
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -372,10 +372,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
|
||||
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.2"
|
||||
version: "8.1.0"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -596,10 +596,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "9.0.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -668,18 +668,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.1"
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -764,10 +764,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+17
-11
@@ -11,11 +11,11 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely
|
||||
# WebView engine
|
||||
flutter_inappwebview: ^6.1.5
|
||||
|
||||
# Local key-value persistence — latest stable
|
||||
shared_preferences: ^2.5.4
|
||||
shared_preferences: ^2.5.5
|
||||
|
||||
# Date/time formatting for daily resets — latest stable
|
||||
intl: ^0.20.2
|
||||
@@ -28,26 +28,26 @@ dependencies:
|
||||
|
||||
# URL launcher for About page links — latest stable
|
||||
url_launcher: ^6.3.2
|
||||
package_info_plus: ^8.1.2
|
||||
package_info_plus: ^9.0.0
|
||||
# Handling Instagram deep links — latest stable
|
||||
app_links: ^6.3.2
|
||||
app_links: ^6.4.1
|
||||
# Open system settings — latest stable
|
||||
app_settings: ^6.1.1
|
||||
google_fonts: ^8.0.2
|
||||
http: ^1.3.0
|
||||
permission_handler: ^12.0.1
|
||||
app_settings: ^7.0.0
|
||||
google_fonts: ^8.1.0
|
||||
http: ^1.6.0
|
||||
permission_handler: ^11.4.0
|
||||
# Image/file picker for story uploads on Android
|
||||
image_picker: ^1.1.2
|
||||
image_picker: ^1.2.0
|
||||
flutter_windowmanager_plus: ^1.0.1
|
||||
|
||||
# Charts for on-device screen time dashboard (MIT)
|
||||
fl_chart: ^0.69.0
|
||||
fl_chart: ^0.71.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -55,6 +55,12 @@ flutter:
|
||||
assets:
|
||||
- assets/images/focusgram.png
|
||||
- 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:
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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')));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -1,41 +1,16 @@
|
||||
// android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt
|
||||
//
|
||||
// Adds:
|
||||
// 1. Platform channel for FLAG_SECURE (anti-screenshot at OS level)
|
||||
// 2. Ghost mode WebView integration notes
|
||||
// Ghost mode WebView integration notes
|
||||
|
||||
package com.focusgram.focusgram
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
private val CHANNEL = "com.focusgram/window_flags"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
CHANNEL
|
||||
).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"setSecure" -> {
|
||||
val secure = call.argument<Boolean>("secure") ?: false
|
||||
if (secure) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +31,6 @@ class MainActivity : FlutterActivity() {
|
||||
// super.initState();
|
||||
// _ghost = GhostModeService();
|
||||
// _ghost.load().then((_) {
|
||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// _ghost.applyWindowFlags(context);
|
||||
// });
|
||||
// setState(() {});
|
||||
// });
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* FocusGram DOM Ad Blocker
|
||||
* Removes sponsored posts, "Suggested for you" injections, and ad elements.
|
||||
* Uses structure-based selectors — NOT class names (those change weekly).
|
||||
* Injected at DOCUMENT_END.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ─── Sponsored text signals (Instagram localizes these) ───────────────────
|
||||
// We match the STRUCTURE not just English text.
|
||||
// In IG mobile web, sponsored label appears as a <span> or <div>
|
||||
// that is a direct sibling/child of the article header area.
|
||||
const SPONSORED_TEXTS = new Set([
|
||||
'sponsored', // en
|
||||
'gesponsert', // de
|
||||
'patrocinado', // es/pt
|
||||
'sponsorisé', // fr
|
||||
'sponsorizzato', // it
|
||||
'sponsrad', // sv
|
||||
'sponsoreret', // da
|
||||
'gesponsord', // nl
|
||||
'рекламa', // ru
|
||||
'विज्ञापन', // hi
|
||||
'广告', // zh
|
||||
'ad', // en short
|
||||
]);
|
||||
|
||||
const isSponsoredText = (text) =>
|
||||
SPONSORED_TEXTS.has(text.trim().toLowerCase());
|
||||
|
||||
// ─── Remove a single article element ──────────────────────────────────────
|
||||
const removeArticle = (el) => {
|
||||
// Walk up to find the article or main feed item container
|
||||
const target = el.closest('article') ?? el.closest('div[data-media-id]') ?? el;
|
||||
target.remove();
|
||||
};
|
||||
|
||||
// ─── Core ad scanner ──────────────────────────────────────────────────────
|
||||
const scanAndRemove = () => {
|
||||
// Strategy 1: <a href="/ads/..."> inside feed
|
||||
document.querySelectorAll('a[href*="/ads/"]').forEach((a) => {
|
||||
a.closest('article')?.remove();
|
||||
});
|
||||
|
||||
// Strategy 2: Sponsored text in article spans
|
||||
document.querySelectorAll('article').forEach((article) => {
|
||||
const spans = article.querySelectorAll('span, div');
|
||||
for (const span of spans) {
|
||||
if (
|
||||
span.children.length === 0 && // leaf node
|
||||
isSponsoredText(span.textContent)
|
||||
) {
|
||||
article.remove();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Strategy 3: "Suggested for you" feed injections
|
||||
document.querySelectorAll('article, section').forEach((el) => {
|
||||
const firstText = el.querySelector('span, div, h4')?.textContent?.trim();
|
||||
if (
|
||||
firstText &&
|
||||
(firstText.toLowerCase().startsWith('suggested') ||
|
||||
firstText.toLowerCase().startsWith('you might') ||
|
||||
firstText.toLowerCase() === 'posts you might like')
|
||||
) {
|
||||
el.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Strategy 4: Instagram marks some ad containers with aria-label
|
||||
document
|
||||
.querySelectorAll('[aria-label*="Sponsored"], [aria-label*="Ad"]')
|
||||
.forEach((el) => {
|
||||
el.closest('article')?.remove();
|
||||
});
|
||||
|
||||
// Strategy 5: Tracking pixel iframes / hidden images
|
||||
document.querySelectorAll('iframe[width="0"], iframe[height="0"]').forEach((el) => el.remove());
|
||||
document
|
||||
.querySelectorAll('img[width="1"][height="1"], img[width="0"][height="0"]')
|
||||
.forEach((el) => el.remove());
|
||||
};
|
||||
|
||||
// ─── Run on load + watch for new content ──────────────────────────────────
|
||||
scanAndRemove();
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
// Only scan if nodes were added (skip attribute/text changes)
|
||||
const hasAdditions = mutations.some((m) => m.addedNodes.length > 0);
|
||||
if (hasAdditions) scanAndRemove();
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* FocusGram Autoplay Blocker
|
||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
||||
* Prevents video autoplay by:
|
||||
* 1. Blocking play() calls on video elements
|
||||
* 2. Disabling autoplay attribute
|
||||
* 3. Removing preload attributes
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
window.__fgBlockAutoplay = false;
|
||||
|
||||
// Override HTMLMediaElement.play() to check our flag
|
||||
const _play = HTMLMediaElement.prototype.play;
|
||||
HTMLMediaElement.prototype.play = function () {
|
||||
if (window.__fgBlockAutoplay) {
|
||||
// Return a resolved promise to avoid breaking Instagram's code
|
||||
return Promise.resolve();
|
||||
}
|
||||
return _play.call(this);
|
||||
};
|
||||
|
||||
// Override autoplay property setter
|
||||
const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
|
||||
const _originalAutoplaySetter = _videoDescriptor.set;
|
||||
|
||||
Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
|
||||
set: function (value) {
|
||||
if (window.__fgBlockAutoplay && value) {
|
||||
// Silently ignore autoplay attempts when blocking is enabled
|
||||
return;
|
||||
}
|
||||
if (_originalAutoplaySetter) {
|
||||
_originalAutoplaySetter.call(this, value);
|
||||
}
|
||||
},
|
||||
get: function () {
|
||||
if (_videoDescriptor.get) {
|
||||
return _videoDescriptor.get.call(this);
|
||||
}
|
||||
return this.getAttribute('autoplay') !== null;
|
||||
},
|
||||
enumerable: _videoDescriptor.enumerable,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// On page load and SPA navigation, scan for video elements and remove autoplay
|
||||
const removeAutoplayFromVideos = () => {
|
||||
document.querySelectorAll('video, [role="video"]').forEach(el => {
|
||||
if (window.__fgBlockAutoplay) {
|
||||
el.autoplay = false;
|
||||
el.removeAttribute('autoplay');
|
||||
if (el.paused === false) {
|
||||
el.pause();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Run on load and when document changes
|
||||
removeAutoplayFromVideos();
|
||||
|
||||
if (!window.__fgAutoplayObserver) {
|
||||
let _timer = null;
|
||||
window.__fgAutoplayObserver = new MutationObserver(() => {
|
||||
clearTimeout(_timer);
|
||||
_timer = setTimeout(removeAutoplayFromVideos, 500);
|
||||
});
|
||||
window.__fgAutoplayObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Allow Flutter to toggle
|
||||
window.__fgSetBlockAutoplay = function (enabled) {
|
||||
window.__fgBlockAutoplay = !!enabled;
|
||||
if (enabled) {
|
||||
removeAutoplayFromVideos();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* FocusGram Content Hider
|
||||
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
|
||||
* Flutter controls via window.__fgContent.*
|
||||
* Injected at DOCUMENT_END.
|
||||
*
|
||||
* Key fixes applied:
|
||||
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
|
||||
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
|
||||
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
|
||||
* - Stories tray detection strengthened for fresh SPA navigations
|
||||
* - Suggested posts detection uses multiple text-node matching strategies
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (window.__fgContent && window.__fgContent.__focusgramReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const STYLE_ID = 'fg-content-hider';
|
||||
let hideStories = false;
|
||||
let hidePosts = false;
|
||||
let hideSuggested = false;
|
||||
let hideReels = false;
|
||||
|
||||
// ─── CSS rules ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildCSS() {
|
||||
const selectors = [];
|
||||
|
||||
if (hideStories) {
|
||||
selectors.push(
|
||||
'[role="list"]:has([aria-label*="tory"])',
|
||||
'[role="listbox"]:has([aria-label*="tory"])',
|
||||
'[role="menu"] > ul',
|
||||
'section > div > div:first-child [style*="overflow"]',
|
||||
'[role="list"] [style*="overflow"]',
|
||||
);
|
||||
}
|
||||
|
||||
if (hidePosts) {
|
||||
selectors.push(
|
||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
|
||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
|
||||
);
|
||||
}
|
||||
|
||||
// hideReels CSS is intentionally NOT added here.
|
||||
// We use DOM removal instead (see removeReels()) so that room is never left
|
||||
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
|
||||
|
||||
return selectors.length
|
||||
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
|
||||
: '';
|
||||
}
|
||||
|
||||
function applyCSS() {
|
||||
if (document.body) {
|
||||
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
|
||||
}
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = buildCSS();
|
||||
}
|
||||
|
||||
// ─── Story tray JS ─────────────────────────────────────────────────────────
|
||||
|
||||
function hideStoryTray() {
|
||||
if (!hideStories) return;
|
||||
|
||||
// Strategy 1: <ul> children of a named list or menu
|
||||
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
|
||||
try {
|
||||
const items = ul.querySelectorAll('li, button, a');
|
||||
if (items.length < 2) return;
|
||||
ul.style.setProperty('display', 'none', 'important');
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
// Strategy 2: horizontally scrolling container with circle items
|
||||
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
|
||||
try {
|
||||
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
|
||||
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
|
||||
if (cands.length < 2) return;
|
||||
const s0 = window.getComputedStyle(cands[0]);
|
||||
if (s0.width && parseFloat(s0.width) <= 90) {
|
||||
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Suggested posts ───────────────────────────────────────────────────────
|
||||
|
||||
function removeSuggested() {
|
||||
if (!hideSuggested) return;
|
||||
|
||||
var SIGNALS = [
|
||||
'suggested for you',
|
||||
'suggested posts',
|
||||
'suggested reels',
|
||||
'suggested',
|
||||
'because you watched',
|
||||
'because you follow',
|
||||
'you might like',
|
||||
'posts you might like',
|
||||
'accounts you might like',
|
||||
'recommendations',
|
||||
];
|
||||
|
||||
function norm(s) {
|
||||
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hasSignal(s) {
|
||||
var t = norm(s);
|
||||
if (!t) return false;
|
||||
return SIGNALS.some(function (signal) {
|
||||
if (signal === 'suggested') return t === signal;
|
||||
return t.indexOf(signal) >= 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hideContainer(from) {
|
||||
var parent = from;
|
||||
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
|
||||
var role = parent.getAttribute && parent.getAttribute('role');
|
||||
var tag = parent.tagName;
|
||||
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
|
||||
if (
|
||||
tag === 'ARTICLE' ||
|
||||
tag === 'SECTION' ||
|
||||
role === 'listitem' ||
|
||||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
|
||||
) {
|
||||
parent.style.setProperty('display', 'none', 'important');
|
||||
parent.setAttribute('data-fg-hidden-suggested', '1');
|
||||
return true;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
|
||||
try {
|
||||
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
|
||||
var ownLabel = node.getAttribute('aria-label');
|
||||
if (hasSignal(ownLabel)) { hideContainer(node); return; }
|
||||
var text = norm(node.innerText || node.textContent || '');
|
||||
if (
|
||||
text.indexOf('suggested for you') >= 0 ||
|
||||
text.indexOf('suggested posts') >= 0 ||
|
||||
text.indexOf('suggested reels') >= 0 ||
|
||||
text.indexOf('because you watched') >= 0 ||
|
||||
text.indexOf('because you follow') >= 0
|
||||
) {
|
||||
hideContainer(node);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
|
||||
try {
|
||||
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
|
||||
hideContainer(el);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Reels – DOM REMOVE (not display:none) ─────────────────────────────────
|
||||
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
|
||||
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
|
||||
// gap cleanly and lets the feed flow naturally.
|
||||
function removeReels() {
|
||||
if (!hideReels) return;
|
||||
|
||||
var toRemove = [];
|
||||
document.querySelectorAll('article').forEach(function (el) {
|
||||
try {
|
||||
// Fast path: check for a reel-signal attribute first
|
||||
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
|
||||
if (mt === '2') { toRemove.push(el); return; }
|
||||
|
||||
// Fallback: text-node scan for /reels/ markers
|
||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
var n;
|
||||
while ((n = walker.nextNode())) {
|
||||
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
|
||||
toRemove.push(el); break;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
window.__fgContent = {
|
||||
__focusgramReady: true,
|
||||
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
|
||||
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
|
||||
setHideSuggested: function (val) {
|
||||
hideSuggested = !!val;
|
||||
applyCSS();
|
||||
if (val) removeSuggested();
|
||||
},
|
||||
setHideReels: function (val) {
|
||||
hideReels = !!val;
|
||||
applyCSS();
|
||||
if (val) removeReels();
|
||||
},
|
||||
applyAll: function (flags) {
|
||||
hideStories = !!flags.stories;
|
||||
hidePosts = !!flags.posts;
|
||||
hideReels = !!flags.reels;
|
||||
hideSuggested = !!flags.suggested;
|
||||
applyCSS();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideReels) removeReels();
|
||||
},
|
||||
};
|
||||
|
||||
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
|
||||
// pushState/replaceState don't fire any DOM event we can listen for.
|
||||
// Hook the methods themselves so we know a navigation happened, then debounce
|
||||
// re-apply. This also catches the case where the MutationObserver was on `body`
|
||||
// and that node got replaced by Instagram's SPA re-render.
|
||||
|
||||
function scheduleReapply() {
|
||||
clearTimeout(window.__fg_applyTimer);
|
||||
window.__fg_applyTimer = setTimeout(function () {
|
||||
applyCSS();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideReels) removeReels();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
var _origPush = history.pushState;
|
||||
var _origReplace = history.replaceState;
|
||||
|
||||
history.pushState = function () {
|
||||
_origPush.apply(this, arguments);
|
||||
scheduleReapply();
|
||||
};
|
||||
|
||||
history.replaceState = function () {
|
||||
_origReplace.apply(this, arguments);
|
||||
scheduleReapply();
|
||||
};
|
||||
|
||||
// Reinforce on popstate too (user hits back/forward)
|
||||
window.addEventListener('popstate', scheduleReapply, { passive: true });
|
||||
// For pushState on the same URL (rare but possible) – poll path briefly
|
||||
window.addEventListener('pageshow', scheduleReapply, { passive: true });
|
||||
window.addEventListener('focus', scheduleReapply, { passive: true });
|
||||
|
||||
// ─── MutationObserver ───────────────────────────────────────────────────────
|
||||
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
|
||||
// re-applies everything on each cycle. Does NOT guard on a per-element timer
|
||||
// that would never re-fire after the body is replaced by SPA re-render.
|
||||
|
||||
if (!window.__fgContentObserver) {
|
||||
window.__fgContentObserver = new MutationObserver(function () {
|
||||
clearTimeout(window.__fg_moTimer);
|
||||
window.__fg_moTimer = setTimeout(function () {
|
||||
applyCSS();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideReels) removeReels();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// `document.documentElement` survives SPA navigations (body gets replaced
|
||||
// but <html> stays). Observing it catches both subtree mutations and, via
|
||||
// the SPA heartbeat above, re-applies after pushState.
|
||||
window.__fgContentObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Initial run ────────────────────────────────────────────────────────────
|
||||
applyCSS();
|
||||
if (hideStories) hideStoryTray();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideReels) removeReels();
|
||||
|
||||
// Signal ready — Flutter will call applyAll() with stored prefs
|
||||
if (window.ContentChannel) {
|
||||
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* FocusGram Unified Feed Filter via Fetch Interception
|
||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
||||
*
|
||||
* This script intercepts GraphQL fetch calls and filters feed content based on:
|
||||
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
|
||||
* - Sponsored posts (ad_action_link, ad_header_style)
|
||||
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
|
||||
* - Videos/Reels (is_video, media_type, clips_metadata)
|
||||
* - Autoplay blocking (video autoplay prevention)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Configuration flags (set by Flutter via prefs)
|
||||
window.__fgFilterConfig = {
|
||||
blockAds: false,
|
||||
blockSponsored: false,
|
||||
blockSuggested: false,
|
||||
blockVideos: false,
|
||||
blockAutoplay: false,
|
||||
blockGraphQLQueryWhenFeedPosts: false,
|
||||
};
|
||||
|
||||
// Helper: Check if a node is an ad
|
||||
const isAdNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
return !!(
|
||||
node.is_ad ||
|
||||
node.ad_action_link ||
|
||||
node.ad_id ||
|
||||
(node.product_type && node.product_type === 'ad') ||
|
||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
||||
(node.__typename && node.__typename === 'GraphAdStory')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is sponsored
|
||||
const isSponsoredNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
return !!(
|
||||
(node.ad_action_link && node.ad_action_link.href) ||
|
||||
(node.ad_header_style && node.ad_header_style !== 'none')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is suggested content
|
||||
const isSuggestedNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
const typename = String(node.__typename || '');
|
||||
const reason = JSON.stringify({
|
||||
reason: node.suggested_reason,
|
||||
social_context: node.social_context,
|
||||
title: node.title,
|
||||
header: node.header,
|
||||
label: node.label,
|
||||
}).toLowerCase();
|
||||
|
||||
return !!(
|
||||
node.is_suggested ||
|
||||
node.is_suggested_for_you ||
|
||||
node.is_recommendation ||
|
||||
node.suggested_users ||
|
||||
node.suggested_media ||
|
||||
node.suggested_content ||
|
||||
node.recommendation_source ||
|
||||
typename.includes('Suggested') ||
|
||||
typename.includes('Recommendation') ||
|
||||
reason.includes('suggested') ||
|
||||
reason.includes('recommend')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is a video/reel
|
||||
const isVideoNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
return !!(
|
||||
node.is_video ||
|
||||
(node.media_type === 2) ||
|
||||
node.clips_metadata ||
|
||||
(node.__typename && (
|
||||
node.__typename.includes('Clips') ||
|
||||
node.__typename.includes('Video')
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
const isFeedMediaNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
return !!(
|
||||
node.pk ||
|
||||
node.id ||
|
||||
node.code ||
|
||||
node.media_type ||
|
||||
node.image_versions2 ||
|
||||
node.video_versions ||
|
||||
node.carousel_media ||
|
||||
node.__typename?.includes('Media') ||
|
||||
node.__typename?.includes('Timeline')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check for media in carousel
|
||||
const hasVideoInCarousel = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
if (node.media_type === 8) {
|
||||
const edges = node.edge_sidecar_to_children?.edges || [];
|
||||
return edges.some(edge => isVideoNode(edge.node));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Main filter function for feed nodes
|
||||
const shouldFilterNode = (node) => {
|
||||
const config = window.__fgFilterConfig;
|
||||
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check ads
|
||||
if (config.blockAds && isAdNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check sponsored (separate from ads)
|
||||
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check suggested content
|
||||
if (config.blockSuggested && isSuggestedNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check videos/reels
|
||||
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Recursively filter GraphQL response edges
|
||||
const filterEdges = (edges, path = []) => {
|
||||
if (!Array.isArray(edges)) return edges;
|
||||
|
||||
return edges.filter(edge => {
|
||||
if (!edge || !edge.node) return true;
|
||||
const node = edge.node;
|
||||
|
||||
// Keep the edge if it doesn't match any filter
|
||||
if (!shouldFilterNode(node)) return true;
|
||||
|
||||
// Log filtered content for debugging
|
||||
if (window.__fgDebugFilter) {
|
||||
const type = node.__typename || 'Unknown';
|
||||
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Recursively walk GraphQL response and filter edges
|
||||
const walkAndFilter = (obj, visited = new Set()) => {
|
||||
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
|
||||
visited.add(obj);
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach(item => walkAndFilter(item, visited));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for edges array (common GraphQL pattern)
|
||||
if (obj.edges && Array.isArray(obj.edges)) {
|
||||
obj.edges = filterEdges(obj.edges);
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key) && key !== '__typename') {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === 'object') {
|
||||
walkAndFilter(val, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Override fetch
|
||||
const _fetch = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async function (input, init) {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input?.url ?? '';
|
||||
|
||||
// Call original fetch
|
||||
let response = await _fetch(input, init);
|
||||
|
||||
// Only intercept GraphQL feed queries
|
||||
if (!url.includes('/graphql/query') && !url.includes('/api/v1/feed')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone response to read body
|
||||
const cloned = response.clone();
|
||||
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = await cloned.json();
|
||||
|
||||
// Filter the response data
|
||||
walkAndFilter(data);
|
||||
|
||||
// Return modified response
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} catch (e) {
|
||||
// On error, return original response
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
// Preserve native function appearance
|
||||
Object.defineProperty(window, 'fetch', {
|
||||
value: window.fetch,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
||||
|
||||
const _xhrOpen = XMLHttpRequest.prototype.open;
|
||||
const _xhrSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
this.__fgUrl = typeof url === 'string' ? url : String(url || '');
|
||||
return _xhrOpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
if (
|
||||
window.__fgFilterConfig.blockVideos &&
|
||||
this.__fgUrl &&
|
||||
(this.__fgUrl.includes('/api/v1/clips/') ||
|
||||
this.__fgUrl.includes('/api/v1/discover/'))
|
||||
) {
|
||||
try { this.abort(); } catch (_) {}
|
||||
return;
|
||||
}
|
||||
return _xhrSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// Allow Flutter to update config flags
|
||||
window.__fgSetFilterConfig = function (config) {
|
||||
if (typeof config === 'object') {
|
||||
Object.assign(window.__fgFilterConfig, config);
|
||||
if (window.__fgDebugFilter) {
|
||||
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Enable debug logging
|
||||
window.__fgDebugFilter = false;
|
||||
})();
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* FocusGram Ghost Mode
|
||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
||||
* Blocks story-seen, message-seen, and online-presence signals.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ─── Seen API patterns ────────────────────────────────────────────────────
|
||||
const SEEN_PATTERNS = [
|
||||
/\/api\/v1\/media\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/stories\/reel\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
|
||||
];
|
||||
|
||||
// ─── Activity patterns (like, comment) — intercepted for local history ────
|
||||
const ACTIVITY_PATTERNS = [
|
||||
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
|
||||
/\/api\/v1\/web\/comments\/add\//,
|
||||
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
|
||||
];
|
||||
|
||||
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
|
||||
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
|
||||
|
||||
const fakeOkResponse = () =>
|
||||
new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// ─── Fetch override ───────────────────────────────────────────────────────
|
||||
const _fetch = window.fetch.bind(window);
|
||||
|
||||
const patchedFetch = async function (input, init) {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input?.url ?? '';
|
||||
|
||||
// Block seen
|
||||
if (isSeen(url)) {
|
||||
if (window.GhostChannel) {
|
||||
window.GhostChannel.postMessage(
|
||||
JSON.stringify({ type: 'seen_blocked', url })
|
||||
);
|
||||
}
|
||||
return fakeOkResponse();
|
||||
}
|
||||
|
||||
// Intercept activity for local history
|
||||
if (isActivity(url) && window.ActivityChannel) {
|
||||
const body = init?.body;
|
||||
const bodyText =
|
||||
body instanceof URLSearchParams
|
||||
? body.toString()
|
||||
: typeof body === 'string'
|
||||
? body
|
||||
: '';
|
||||
window.ActivityChannel.postMessage(
|
||||
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
|
||||
);
|
||||
}
|
||||
|
||||
return _fetch(input, init);
|
||||
};
|
||||
|
||||
// Disguise as native
|
||||
Object.defineProperty(window, 'fetch', {
|
||||
value: patchedFetch,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
||||
window.fetch[Symbol.toStringTag] = 'fetch';
|
||||
|
||||
// ─── XMLHttpRequest override ──────────────────────────────────────────────
|
||||
const _XHROpen = XMLHttpRequest.prototype.open;
|
||||
const _XHRSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
||||
this._fg_url = url ?? '';
|
||||
this._fg_method = (method ?? '').toUpperCase();
|
||||
return _XHROpen.call(this, method, url, ...args);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function (body) {
|
||||
if (this._fg_url && isSeen(this._fg_url)) {
|
||||
// Fire readyState 4 with fake success without actually sending
|
||||
const self = this;
|
||||
setTimeout(() => {
|
||||
Object.defineProperty(self, 'readyState', { get: () => 4 });
|
||||
Object.defineProperty(self, 'status', { get: () => 200 });
|
||||
Object.defineProperty(self, 'responseText', {
|
||||
get: () => '{"status":"ok"}',
|
||||
});
|
||||
Object.defineProperty(self, 'response', {
|
||||
get: () => '{"status":"ok"}',
|
||||
});
|
||||
self.dispatchEvent(new Event('readystatechange'));
|
||||
self.dispatchEvent(new Event('load'));
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
return _XHRSend.call(this, body);
|
||||
};
|
||||
|
||||
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
|
||||
// Strict WS URL blocking (ghost mode requirement)
|
||||
// sid/cid vary per user/chat; block by endpoint prefix, not exact query.
|
||||
const isBlockedWssUrl = (u) => {
|
||||
if (!u) return false;
|
||||
const urlStr = String(u);
|
||||
|
||||
return (
|
||||
urlStr.startsWith('wss://gateway.instagram.com/ws/streamcontroller') ||
|
||||
urlStr.startsWith('wss://edge-chat.instagram.com/chat?sid=')
|
||||
);
|
||||
};
|
||||
|
||||
// Signal to other injected scripts that ghost-mode is active
|
||||
window.__fgGhostModeActive = true;
|
||||
|
||||
const _WS = window.WebSocket;
|
||||
|
||||
function PatchedWebSocket(url, protocols) {
|
||||
const urlStr = typeof url === 'string' ? url : url?.toString?.() ?? '';
|
||||
|
||||
// If the WebSocket URL is one of the blocked endpoints, return an inert WS-like object
|
||||
if (isBlockedWssUrl(urlStr)) {
|
||||
return {
|
||||
send: () => {},
|
||||
close: () => {},
|
||||
readyState: 1,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
||||
const _send = ws.send.bind(ws);
|
||||
|
||||
ws.send = function (data) {
|
||||
if (typeof data === 'string') {
|
||||
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (
|
||||
parsed?.op === '4' ||
|
||||
parsed?.op === 'seen' ||
|
||||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
|
||||
) {
|
||||
return; // drop
|
||||
}
|
||||
} catch (_) {}
|
||||
// Text-based seen signal check
|
||||
if (data.includes('"seen"') && data.includes('"thread_id"')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return _send(data);
|
||||
};
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
// Preserve WebSocket prototype chain so IG's ws checks pass
|
||||
PatchedWebSocket.prototype = _WS.prototype;
|
||||
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
|
||||
PatchedWebSocket.OPEN = _WS.OPEN;
|
||||
PatchedWebSocket.CLOSING = _WS.CLOSING;
|
||||
PatchedWebSocket.CLOSED = _WS.CLOSED;
|
||||
window.WebSocket = PatchedWebSocket;
|
||||
|
||||
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
|
||||
// Only applied if user enables online-status hiding
|
||||
// Wrapped in a named fn so Flutter can call it:
|
||||
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
|
||||
window.__fgEnableOnlineHide = function () {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
get: () => 'hidden',
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
get: () => true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
|
||||
window.__fgDisableOnlineHide = function () {
|
||||
// Restore by deleting the overrides (falls back to native getter)
|
||||
delete document.visibilityState;
|
||||
delete document.hidden;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
|
||||
// Signal to Flutter that ghost mode JS is active
|
||||
if (window.GhostChannel) {
|
||||
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* FocusGram Theme Detector
|
||||
* Reads Instagram's background + bottom nav color and reports to Flutter.
|
||||
* Injected at DOCUMENT_END so DOM is ready.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const parseRgb = (str) => {
|
||||
// Parses "rgb(255, 255, 255)" or "rgba(0, 0, 0, 1)" → { r, g, b, a }
|
||||
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (!m) return null;
|
||||
return {
|
||||
r: parseInt(m[1]),
|
||||
g: parseInt(m[2]),
|
||||
b: parseInt(m[3]),
|
||||
a: m[4] !== undefined ? parseFloat(m[4]) : 1,
|
||||
};
|
||||
};
|
||||
|
||||
const toHex = ({ r, g, b }) =>
|
||||
'#' +
|
||||
[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
const detectColors = () => {
|
||||
// Background — Instagram sets it on <body> or a root div
|
||||
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
// Bottom nav — IG mobile web renders a fixed bottom bar
|
||||
// Target by role="navigation" or position:fixed at bottom
|
||||
let navBg = bodyBg;
|
||||
const navCandidates = document.querySelectorAll(
|
||||
'nav, [role="navigation"], div[style*="bottom"]'
|
||||
);
|
||||
for (const el of navCandidates) {
|
||||
const style = getComputedStyle(el);
|
||||
if (
|
||||
style.position === 'fixed' &&
|
||||
parseInt(style.bottom) <= 10 &&
|
||||
style.backgroundColor !== 'rgba(0, 0, 0, 0)'
|
||||
) {
|
||||
navBg = style.backgroundColor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bodyColor = parseRgb(bodyBg);
|
||||
const navColor = parseRgb(navBg);
|
||||
|
||||
if (!bodyColor) return;
|
||||
|
||||
// Determine dark/light
|
||||
const luminance = (0.299 * bodyColor.r + 0.587 * bodyColor.g + 0.114 * bodyColor.b) / 255;
|
||||
const isDark = luminance < 0.5;
|
||||
|
||||
const payload = {
|
||||
bodyHex: toHex(bodyColor),
|
||||
navHex: navColor ? toHex(navColor) : toHex(bodyColor),
|
||||
isDark,
|
||||
};
|
||||
|
||||
if (window.ThemeChannel) {
|
||||
window.ThemeChannel.postMessage(JSON.stringify(payload));
|
||||
}
|
||||
};
|
||||
|
||||
// Run on load
|
||||
detectColors();
|
||||
|
||||
// Watch for Instagram's dark mode toggle (adds/removes class on <html>)
|
||||
const observer = new MutationObserver(detectColors);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'color-scheme'],
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
});
|
||||
|
||||
// Also run after navigation (Instagram is SPA, URL changes without reload)
|
||||
let lastUrl = location.href;
|
||||
new MutationObserver(() => {
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
setTimeout(detectColors, 300); // small delay for IG to render new page
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
+31
-31
@@ -23,41 +23,41 @@ class ChannelRegistry {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
JavaScriptChannel _ghostChannel() => JavaScriptChannel(
|
||||
name: 'GhostChannel',
|
||||
onMessageReceived: (msg) {
|
||||
try {
|
||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
||||
if (kDebugMode) {
|
||||
debugPrint('[Ghost] ${data['type']} — ${data['url'] ?? ''}');
|
||||
}
|
||||
// In release: silent. Could surface to a debug overlay in dev builds.
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
name: 'GhostChannel',
|
||||
onMessageReceived: (msg) {
|
||||
try {
|
||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
||||
if (kDebugMode) {
|
||||
debugPrint('[Ghost] ${data['type']} — ${data['url'] ?? ''}');
|
||||
}
|
||||
// In release: silent. Could surface to a debug overlay in dev builds.
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
|
||||
JavaScriptChannel _themeChannel() => JavaScriptChannel(
|
||||
name: 'ThemeChannel',
|
||||
onMessageReceived: (msg) {
|
||||
SystemUiManager.applyFromThemePayload(msg.message);
|
||||
},
|
||||
);
|
||||
name: 'ThemeChannel',
|
||||
onMessageReceived: (msg) {
|
||||
SystemUiManager.applyFromThemePayload(msg.message);
|
||||
},
|
||||
);
|
||||
|
||||
JavaScriptChannel _contentChannel() => JavaScriptChannel(
|
||||
name: 'ContentChannel',
|
||||
onMessageReceived: (msg) {
|
||||
// 'ready' signal — engine pushes flags back via evaluateJavascript
|
||||
// handled in ScriptEngine.injectDocumentEndScripts()
|
||||
if (kDebugMode) debugPrint('[Content] ${msg.message}');
|
||||
},
|
||||
);
|
||||
name: 'ContentChannel',
|
||||
onMessageReceived: (msg) {
|
||||
// 'ready' signal — engine pushes flags back via evaluateJavascript
|
||||
// handled in ScriptEngine.injectDocumentEndScripts()
|
||||
if (kDebugMode) debugPrint('[Content] ${msg.message}');
|
||||
},
|
||||
);
|
||||
|
||||
JavaScriptChannel _activityChannel() => JavaScriptChannel(
|
||||
name: 'ActivityChannel',
|
||||
onMessageReceived: (msg) {
|
||||
try {
|
||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
||||
onActivityEvent?.call(data);
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
name: 'ActivityChannel',
|
||||
onMessageReceived: (msg) {
|
||||
try {
|
||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
||||
onActivityEvent?.call(data);
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+38
-54
@@ -3,7 +3,7 @@
|
||||
// Three-layer ghost mode:
|
||||
// 1. AT_DOCUMENT_START JS injection — overrides fetch/XHR/WS before IG code runs
|
||||
// 2. shouldInterceptRequest — native Android intercept (catches SW requests too)
|
||||
// 3. FLAG_SECURE — anti-screenshot at OS level
|
||||
// 3. FLAG_SECURE — anti-screenshot at OS level (disabled per user request)
|
||||
//
|
||||
// Usage:
|
||||
// final service = GhostModeService();
|
||||
@@ -15,8 +15,8 @@
|
||||
// shouldInterceptRequest: service.shouldInterceptRequest,
|
||||
// )
|
||||
//
|
||||
// // Anti-screenshot: call from initState after WidgetsBinding.instance.addPostFrameCallback
|
||||
// service.applyWindowFlags(context);
|
||||
// // Anti-screenshot: disabled per user request
|
||||
// // service.applyWindowFlags(context);
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -36,55 +36,50 @@ class GhostFeatures {
|
||||
bool hideVoiceListened;
|
||||
bool hideReplyImageViewed;
|
||||
bool disableAnalytics;
|
||||
bool antiScreenshot;
|
||||
|
||||
GhostFeatures({
|
||||
this.hideStoryViews = true,
|
||||
this.hideReadReceipts = true,
|
||||
this.hideLiveJoin = true,
|
||||
this.hideTypingIndicator = true,
|
||||
this.hideVoiceListened = true,
|
||||
this.hideStoryViews = true,
|
||||
this.hideReadReceipts = true,
|
||||
this.hideLiveJoin = true,
|
||||
this.hideTypingIndicator = true,
|
||||
this.hideVoiceListened = true,
|
||||
this.hideReplyImageViewed = true,
|
||||
this.disableAnalytics = true,
|
||||
this.antiScreenshot = false, // Off by default — user must opt in
|
||||
this.disableAnalytics = true,
|
||||
});
|
||||
|
||||
static const _keys = {
|
||||
'hideStoryViews': 'gm_story',
|
||||
'hideReadReceipts': 'gm_read',
|
||||
'hideLiveJoin': 'gm_live',
|
||||
'hideTypingIndicator': 'gm_typing',
|
||||
'hideVoiceListened': 'gm_voice',
|
||||
'hideStoryViews': 'gm_story',
|
||||
'hideReadReceipts': 'gm_read',
|
||||
'hideLiveJoin': 'gm_live',
|
||||
'hideTypingIndicator': 'gm_typing',
|
||||
'hideVoiceListened': 'gm_voice',
|
||||
'hideReplyImageViewed': 'gm_reply',
|
||||
'disableAnalytics': 'gm_analytics',
|
||||
'antiScreenshot': 'gm_screenshot',
|
||||
'disableAnalytics': 'gm_analytics',
|
||||
};
|
||||
|
||||
Future<void> save() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await Future.wait([
|
||||
p.setBool(_keys['hideStoryViews']!, hideStoryViews),
|
||||
p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
|
||||
p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
|
||||
p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
|
||||
p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
|
||||
p.setBool(_keys['hideStoryViews']!, hideStoryViews),
|
||||
p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
|
||||
p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
|
||||
p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
|
||||
p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
|
||||
p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed),
|
||||
p.setBool(_keys['disableAnalytics']!, disableAnalytics),
|
||||
p.setBool(_keys['antiScreenshot']!, antiScreenshot),
|
||||
p.setBool(_keys['disableAnalytics']!, disableAnalytics),
|
||||
]);
|
||||
}
|
||||
|
||||
static Future<GhostFeatures> load() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return GhostFeatures(
|
||||
hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
|
||||
hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
|
||||
hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
|
||||
hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
|
||||
hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
|
||||
hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
|
||||
hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
|
||||
hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
|
||||
hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
|
||||
hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
|
||||
hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true,
|
||||
disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
|
||||
antiScreenshot: p.getBool(_keys['antiScreenshot']!) ?? false,
|
||||
disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,23 +105,18 @@ final _nativeBlocklist = [
|
||||
RegExp(r'/ajax/logging/'),
|
||||
];
|
||||
|
||||
final Uint8List _fakeOkBody = Uint8List.fromList(
|
||||
'{"status":"ok"}'.codeUnits,
|
||||
);
|
||||
final Uint8List _fakeOkBody = Uint8List.fromList('{"status":"ok"}'.codeUnits);
|
||||
|
||||
// ─── Main service ─────────────────────────────────────────────────────────────
|
||||
class GhostModeService {
|
||||
GhostFeatures features = GhostFeatures();
|
||||
InAppWebViewController? _controller;
|
||||
|
||||
// Platform channel for FLAG_SECURE (anti-screenshot)
|
||||
static const _channel = MethodChannel('com.focusgram/window_flags');
|
||||
|
||||
Future<void> load() async {
|
||||
features = await GhostFeatures.load();
|
||||
}
|
||||
|
||||
// ─── WebView setup ──────────────────────────────────────────────────────────
|
||||
// ─── WebView setup ────────────────────────────────────────────────────────
|
||||
|
||||
/// Call from InAppWebView.onWebViewCreated
|
||||
void onWebViewCreated(InAppWebViewController controller) {
|
||||
@@ -170,34 +160,28 @@ class GhostModeService {
|
||||
/// InAppWebViewSettings required for shouldInterceptRequest to fire
|
||||
InAppWebViewSettings buildWebViewSettings() {
|
||||
return InAppWebViewSettings(
|
||||
useShouldInterceptRequest: true, // Enable native intercept callback
|
||||
useShouldInterceptRequest: true, // Enable native intercept callback
|
||||
useShouldOverrideUrlLoading: true,
|
||||
javaScriptEnabled: true,
|
||||
disableDefaultErrorPage: true,
|
||||
useHybridComposition: true, // Needed for FLAG_SECURE to work
|
||||
useHybridComposition:
|
||||
true, // Needed for FLAG_SECURE to work (though disabled)
|
||||
// Disable service worker cache that can replay seen-events offline
|
||||
cacheEnabled: false, // Start clean — optional, tradeoff vs perf
|
||||
cacheEnabled: false, // Start clean — optional, tradeoff vs perf
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Anti-screenshot ────────────────────────────────────────────────────────
|
||||
// Anti-screenshot disabled per user request
|
||||
|
||||
/// Call from initState → addPostFrameCallback
|
||||
Future<void> applyWindowFlags(BuildContext context) async {
|
||||
if (!features.antiScreenshot) return;
|
||||
try {
|
||||
await _channel.invokeMethod('setSecure', {'secure': true});
|
||||
} on MissingPluginException {
|
||||
// Platform channel not registered — use plugin fallback below
|
||||
debugPrint('[GhostMode] FLAG_SECURE: platform channel missing. '
|
||||
'Add flutter_windowmanager or implement MainActivity channel.');
|
||||
}
|
||||
// Anti-screenshot disabled per user request
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> clearWindowFlags() async {
|
||||
try {
|
||||
await _channel.invokeMethod('setSecure', {'secure': false});
|
||||
} catch (_) {}
|
||||
// Anti-screenshot disabled per user request
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Re-inject after page nav (SPA navigation doesn't re-run userScripts) ──
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../injection/script_engine.dart';
|
||||
import '../injection/script_registry.dart';
|
||||
import '../channels/channel_registry.dart';
|
||||
import '../webview/webview_config.dart';
|
||||
import '../services/ghost_mode_service.dart';
|
||||
|
||||
class InstagramWebView extends StatefulWidget {
|
||||
const InstagramWebView({super.key});
|
||||
@@ -17,9 +18,10 @@ class InstagramWebView extends StatefulWidget {
|
||||
class InstagramWebViewState extends State<InstagramWebView> {
|
||||
InAppWebViewController? _controller;
|
||||
ScriptEngine? _engine;
|
||||
GhostModeService? _ghostMode;
|
||||
bool _loading = true;
|
||||
|
||||
// ── Public API — call from Settings screen ────────────────────────────────
|
||||
// ── Public API — call from Settings screen ─────────────────────────────
|
||||
Future<void> toggleScript(ScriptId id, bool enabled) async {
|
||||
await _engine?.toggle(id, enabled);
|
||||
}
|
||||
@@ -32,6 +34,37 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
||||
await _engine?.setOnlineHide(enabled);
|
||||
}
|
||||
|
||||
// Ghost mode controls
|
||||
Future<void> setGhostModeEnabled(bool enabled) async {
|
||||
if (_ghostMode != null) {
|
||||
_ghostMode!.features.disableAnalytics = enabled;
|
||||
_ghostMode!.features.hideStoryViews = enabled;
|
||||
_ghostMode!.features.hideReadReceipts = enabled;
|
||||
_ghostMode!.features.hideLiveJoin = enabled;
|
||||
_ghostMode!.features.hideTypingIndicator = enabled;
|
||||
_ghostMode!.features.hideVoiceListened = enabled;
|
||||
_ghostMode!.features.hideReplyImageViewed = enabled;
|
||||
await _ghostMode!.features.save();
|
||||
// Reapply settings if webview exists
|
||||
if (_controller != null) {
|
||||
// Force reload to apply new settings
|
||||
await _controller!.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAntiScreenshot(bool enabled) async {
|
||||
if (_ghostMode != null) {
|
||||
_ghostMode!.features.antiScreenshot = enabled;
|
||||
await _ghostMode!.features.save();
|
||||
if (_ghostMode!.features.antiScreenshot) {
|
||||
await _ghostMode!.applyWindowFlags(context);
|
||||
} else {
|
||||
await _ghostMode!.clearWindowFlags();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
@@ -40,12 +73,18 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
||||
children: [
|
||||
InAppWebView(
|
||||
initialUrlRequest: WebViewConfig.initialRequest,
|
||||
initialSettings: WebViewConfig.settings,
|
||||
initialSettings:
|
||||
_ghostMode?.buildWebViewSettings() ?? WebViewConfig.settings,
|
||||
|
||||
// ── ContentBlockers — merged base + EasyList rules ──────────────
|
||||
contentBlockers: WebViewConfig.baseContentBlockers,
|
||||
// TODO Phase 1.5: merge EasyListParser.load() here at startup
|
||||
|
||||
// ── User Scripts — AT_DOCUMENT_START critical for ghost mode ─────
|
||||
initialUserScripts: UnmodifiableListView(
|
||||
_ghostMode?.buildUserScripts() ?? [],
|
||||
),
|
||||
|
||||
// ── JavaScript channels ─────────────────────────────────────────
|
||||
javascriptChannels: ChannelRegistry(
|
||||
onActivityEvent: (event) {
|
||||
@@ -56,6 +95,12 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
||||
|
||||
onWebViewCreated: (controller) async {
|
||||
_controller = controller;
|
||||
|
||||
// Initialize GhostModeService
|
||||
_ghostMode = GhostModeService();
|
||||
await _ghostMode!.load();
|
||||
|
||||
// Initialize existing script engine for other scripts
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_engine = ScriptEngine(controller: controller, prefs: prefs);
|
||||
|
||||
@@ -66,6 +111,10 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
||||
onLoadStop: (controller, url) async {
|
||||
// Inject DOCUMENT_END scripts
|
||||
await _engine?.injectDocumentEndScripts();
|
||||
|
||||
// Re-inject ghost mode scripts on SPA navigation
|
||||
await _ghostMode?.onPageLoaded(url?.uriValue);
|
||||
|
||||
setState(() => _loading = false);
|
||||
},
|
||||
|
||||
@@ -103,6 +152,15 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
||||
await _engine?.injectDocumentEndScripts();
|
||||
}
|
||||
},
|
||||
|
||||
// ── Native intercept for service worker requests ────────────────
|
||||
shouldInterceptRequest: (controller, request) async {
|
||||
return await _ghostMode?.shouldInterceptRequest(
|
||||
controller,
|
||||
request,
|
||||
) ??
|
||||
null;
|
||||
},
|
||||
),
|
||||
|
||||
// ── Subtle loading indicator ──────────────────────────────────────
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'core/theme/system_ui_manager.dart';
|
||||
import 'core/webview/instagram_webview.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Enable web contents debugging for ghost mode verification
|
||||
if (kDebugMode) {
|
||||
InAppWebViewController.setWebContentsDebuggingEnabled(true);
|
||||
}
|
||||
|
||||
await SystemUiManager.enableEdgeToEdge();
|
||||
runApp(const FocusGramApp());
|
||||
}
|
||||
|
||||
+96
-4
@@ -35,12 +35,36 @@ class ScriptEngine {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize script configurations after scripts are loaded
|
||||
await _initializeScriptConfigs();
|
||||
}
|
||||
|
||||
// ── Initialize script configurations from saved preferences ────────────────
|
||||
Future<void> _initializeScriptConfigs() async {
|
||||
// Fetch interceptor config
|
||||
final fetchInterceptor = ScriptRegistry.byId(ScriptId.fetchInterceptor);
|
||||
if (fetchInterceptor.enabled) {
|
||||
await _updateFetchInterceptorConfig();
|
||||
}
|
||||
|
||||
// Autoplay blocker config
|
||||
final autoplayBlocker = ScriptRegistry.byId(ScriptId.autoplayBlocker);
|
||||
if (autoplayBlocker.enabled) {
|
||||
await _updateAutoplayBlockerConfig();
|
||||
}
|
||||
|
||||
// Content hider flags
|
||||
await _pushContentFlags();
|
||||
}
|
||||
|
||||
// ── Called from onLoadStop: inject all DOCUMENT_END enabled scripts ────────
|
||||
Future<void> injectDocumentEndScripts() async {
|
||||
for (final script in ScriptRegistry.all
|
||||
.where((s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END && s.enabled)) {
|
||||
for (final script in ScriptRegistry.all.where(
|
||||
(s) =>
|
||||
s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END &&
|
||||
s.enabled,
|
||||
)) {
|
||||
await _inject(script);
|
||||
}
|
||||
// After content_hider is injected, push saved content flags
|
||||
@@ -77,6 +101,9 @@ class ScriptEngine {
|
||||
} else {
|
||||
await _inject(script);
|
||||
}
|
||||
|
||||
// Re-initialize configurations after toggle
|
||||
await _initializeScriptConfigs();
|
||||
}
|
||||
|
||||
// ── Content hider flags ────────────────────────────────────────────────────
|
||||
@@ -100,15 +127,80 @@ class ScriptEngine {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fetch interceptor configuration ────────────────────────────────────────
|
||||
Future<void> setFetchInterceptorConfig({
|
||||
bool? blockAds,
|
||||
bool? blockSponsored,
|
||||
bool? blockSuggested,
|
||||
bool? blockVideos,
|
||||
bool? blockAutoplay,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final config = {
|
||||
'blockAds': blockAds ?? prefs.getBool('fetch_block_ads') ?? false,
|
||||
'blockSponsored':
|
||||
blockSponsored ?? prefs.getBool('fetch_block_sponsored') ?? false,
|
||||
'blockSuggested':
|
||||
blockSuggested ?? prefs.getBool('fetch_block_suggested') ?? false,
|
||||
'blockVideos':
|
||||
blockVideos ?? prefs.getBool('fetch_block_videos') ?? false,
|
||||
'blockAutoplay':
|
||||
blockAutoplay ?? prefs.getBool('fetch_block_autoplay') ?? false,
|
||||
};
|
||||
|
||||
// Save individual prefs
|
||||
await prefs.setBool('fetch_block_ads', config['blockAds']!);
|
||||
await prefs.setBool('fetch_block_sponsored', config['blockSponsored']!);
|
||||
await prefs.setBool('fetch_block_suggested', config['blockSuggested']!);
|
||||
await prefs.setBool('fetch_block_videos', config['blockVideos']!);
|
||||
await prefs.setBool('fetch_block_autoplay', config['blockAutoplay']!);
|
||||
|
||||
// Apply to webview
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgSetFilterConfig?.(${jsonEncode(config)})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateFetchInterceptorConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await setFetchInterceptorConfig(
|
||||
blockAds: prefs.getBool('fetch_block_ads'),
|
||||
blockSponsored: prefs.getBool('fetch_block_sponsored'),
|
||||
blockSuggested: prefs.getBool('fetch_block_suggested'),
|
||||
blockVideos: prefs.getBool('fetch_block_videos'),
|
||||
blockAutoplay: prefs.getBool('fetch_block_autoplay'),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Autoplay blocker configuration ─────────────────────────────────────────
|
||||
Future<void> setAutoplayBlockerEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('autoplay_blocker_enabled', enabled);
|
||||
|
||||
// Apply to webview
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgSetBlockAutoplay?.(${jsonEncode(enabled)})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateAutoplayBlockerConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await setAutoplayBlockerEnabled(
|
||||
prefs.getBool('autoplay_blocker_enabled') ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Online status hide ─────────────────────────────────────────────────────
|
||||
Future<void> setOnlineHide(bool enabled) async {
|
||||
await prefs.setBool('ghost_online_hide', enabled);
|
||||
if (enabled) {
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgEnableOnlineHide?.()');
|
||||
source: 'window.__fgEnableOnlineHide?.()',
|
||||
);
|
||||
} else {
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgDisableOnlineHide?.()');
|
||||
source: 'window.__fgDisableOnlineHide?.()',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-21
@@ -3,7 +3,6 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
enum ScriptId {
|
||||
ghostMode,
|
||||
themeDetector,
|
||||
adBlockerDom,
|
||||
contentHider,
|
||||
fetchInterceptor,
|
||||
autoplayBlocker,
|
||||
@@ -32,18 +31,11 @@ class InstaScript {
|
||||
class ScriptRegistry {
|
||||
static final List<InstaScript> all = [
|
||||
// ── DOCUMENT_START — must be before IG's JS loads ──
|
||||
InstaScript(
|
||||
id: ScriptId.ghostMode,
|
||||
name: 'Ghost Mode',
|
||||
description: 'Blocks story seen, message seen, and online status signals.',
|
||||
assetPath: 'assets/scripts/ghost_mode.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
),
|
||||
InstaScript(
|
||||
id: ScriptId.fetchInterceptor,
|
||||
name: 'Fetch Interceptor',
|
||||
description: 'Unified feed filter: blocks ads, sponsored, suggested, videos via GraphQL interception.',
|
||||
name: 'Ad & Content Blocker',
|
||||
description:
|
||||
'Blocks ads, sponsored, suggested content, videos, and prevents autoplay via GraphQL interception.',
|
||||
assetPath: 'assets/scripts/fetch_interceptor.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
@@ -66,14 +58,6 @@ class ScriptRegistry {
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: true, // always on — needed for native feel
|
||||
),
|
||||
InstaScript(
|
||||
id: ScriptId.adBlockerDom,
|
||||
name: 'DOM Ad Blocker',
|
||||
description: 'Removes sponsored posts and tracking elements from feed (legacy - use Fetch Interceptor instead).',
|
||||
assetPath: 'assets/scripts/ad_blocker_dom.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: false,
|
||||
),
|
||||
InstaScript(
|
||||
id: ScriptId.contentHider,
|
||||
name: 'Content Hider',
|
||||
@@ -100,6 +84,4 @@ class ScriptRegistry {
|
||||
enabled: false,
|
||||
),
|
||||
];
|
||||
|
||||
static InstaScript byId(ScriptId id) => all.firstWhere((s) => s.id == id);
|
||||
}
|
||||
|
||||
+25
-19
@@ -8,7 +8,8 @@ class SystemUiManager {
|
||||
try {
|
||||
final data = jsonDecode(jsonPayload) as Map<String, dynamic>;
|
||||
final isDark = data['isDark'] as bool? ?? false;
|
||||
final bodyHex = data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
|
||||
final bodyHex =
|
||||
data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
|
||||
final navHex = data['navHex'] as String? ?? bodyHex;
|
||||
|
||||
final bodyColor = _parseHex(bodyHex);
|
||||
@@ -20,8 +21,9 @@ class SystemUiManager {
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
|
||||
systemNavigationBarColor: navColor,
|
||||
systemNavigationBarIconBrightness:
|
||||
isDark ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarIconBrightness: isDark
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
@@ -33,25 +35,29 @@ class SystemUiManager {
|
||||
|
||||
// ── Fallback presets ─────────────────────────────────────────────────────
|
||||
static void applyLight() {
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Color(0xFFFFFFFF),
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Color(0xFFFFFFFF),
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Color(0xFFFFFFFF),
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Color(0xFFFFFFFF),
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void applyDark() {
|
||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
||||
statusBarColor: Color(0xFF000000),
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
systemNavigationBarColor: Color(0xFF000000),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
));
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Color(0xFF000000),
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
systemNavigationBarColor: Color(0xFF000000),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Edge-to-edge setup — call once in main() ─────────────────────────────
|
||||
|
||||
+80
-83
@@ -11,110 +11,107 @@ class WebViewConfig {
|
||||
|
||||
// ── Base InAppWebView settings ────────────────────────────────────────────
|
||||
static InAppWebViewSettings get settings => InAppWebViewSettings(
|
||||
// Identity
|
||||
userAgent: userAgent,
|
||||
// Identity
|
||||
userAgent: userAgent,
|
||||
|
||||
// Performance
|
||||
hardwareAcceleration: true,
|
||||
// useHybridComposition: false breaks some Android 12+ devices — keep true
|
||||
useHybridComposition: true,
|
||||
cacheEnabled: true,
|
||||
cacheMode: CacheMode.LOAD_DEFAULT,
|
||||
// Performance
|
||||
hardwareAcceleration: true,
|
||||
// useHybridComposition: false breaks some Android 12+ devices — keep true
|
||||
useHybridComposition: true,
|
||||
cacheEnabled: true,
|
||||
cacheMode: CacheMode.LOAD_DEFAULT,
|
||||
|
||||
// Media
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
allowsPictureInPictureMediaPlayback: true,
|
||||
// Media
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
allowsPictureInPictureMediaPlayback: true,
|
||||
|
||||
// UX — feel like native, not browser
|
||||
overScrollMode: OverScrollMode.NEVER,
|
||||
verticalScrollBarEnabled: false,
|
||||
horizontalScrollBarEnabled: false,
|
||||
supportZoom: false,
|
||||
builtInZoomControls: false,
|
||||
displayZoomControls: false,
|
||||
scrollsToTop: true,
|
||||
// UX — feel like native, not browser
|
||||
overScrollMode: OverScrollMode.NEVER,
|
||||
verticalScrollBarEnabled: false,
|
||||
horizontalScrollBarEnabled: false,
|
||||
supportZoom: false,
|
||||
builtInZoomControls: false,
|
||||
displayZoomControls: false,
|
||||
scrollsToTop: true,
|
||||
|
||||
// JS & storage — IG needs all of these
|
||||
javaScriptEnabled: true,
|
||||
javaScriptCanOpenWindowsAutomatically: false,
|
||||
domStorageEnabled: true,
|
||||
databaseEnabled: true,
|
||||
allowFileAccessFromFileURLs: false,
|
||||
allowUniversalAccessFromFileURLs: false,
|
||||
// JS & storage — IG needs all of these
|
||||
javaScriptEnabled: true,
|
||||
javaScriptCanOpenWindowsAutomatically: false,
|
||||
domStorageEnabled: true,
|
||||
databaseEnabled: true,
|
||||
allowFileAccessFromFileURLs: false,
|
||||
allowUniversalAccessFromFileURLs: false,
|
||||
|
||||
// Compat
|
||||
mixedContentMode: MixedContentMode.COMPATIBILITY_MODE,
|
||||
safeBrowsingEnabled: false, // IG known-safe domain, no need for extra latency
|
||||
// Compat
|
||||
mixedContentMode: MixedContentMode.COMPATIBILITY_MODE,
|
||||
safeBrowsingEnabled:
|
||||
false, // IG known-safe domain, no need for extra latency
|
||||
// Disable Chrome custom tabs popup (links open in WebView)
|
||||
suppressesIncrementalRendering: false,
|
||||
|
||||
// Disable Chrome custom tabs popup (links open in WebView)
|
||||
suppressesIncrementalRendering: false,
|
||||
// iOS specific
|
||||
allowsBackForwardNavigationGestures: true,
|
||||
allowsLinkPreview: false,
|
||||
isFraudulentWebsiteWarningEnabled: false,
|
||||
|
||||
// iOS specific
|
||||
allowsBackForwardNavigationGestures: true,
|
||||
allowsLinkPreview: false,
|
||||
isFraudulentWebsiteWarningEnabled: false,
|
||||
|
||||
// Android specific
|
||||
forceDark: ForceDark.AUTO, // respect system dark mode
|
||||
algorithmicDarkeningAllowed: true,
|
||||
);
|
||||
// Android specific
|
||||
forceDark: ForceDark.AUTO, // respect system dark mode
|
||||
algorithmicDarkeningAllowed: true,
|
||||
);
|
||||
|
||||
// ── ContentBlocker rules — ad network blocking ─────────────────────────
|
||||
// These are baked-in rules targeting known ad/tracking domains.
|
||||
// Full EasyList parsing is handled separately and merged at runtime.
|
||||
// This set is always-on regardless of user toggle.
|
||||
static List<ContentBlocker> get baseContentBlockers => [
|
||||
// Meta ad infrastructure
|
||||
_block('.*connect\\.facebook\\.net.*'),
|
||||
_block('.*graph\\.facebook\\.com.*ads.*'),
|
||||
_block('.*an\\.facebook\\.com.*'),
|
||||
// Meta ad infrastructure
|
||||
_block('.*connect\\.facebook\\.net.*'),
|
||||
_block('.*graph\\.facebook\\.com.*ads.*'),
|
||||
_block('.*an\\.facebook\\.com.*'),
|
||||
|
||||
// Google ad networks
|
||||
_block('.*doubleclick\\.net.*'),
|
||||
_block('.*googleadservices\\.com.*'),
|
||||
_block('.*googlesyndication\\.com.*'),
|
||||
_block('.*adservice\\.google\\..*'),
|
||||
// Google ad networks
|
||||
_block('.*doubleclick\\.net.*'),
|
||||
_block('.*googleadservices\\.com.*'),
|
||||
_block('.*googlesyndication\\.com.*'),
|
||||
_block('.*adservice\\.google\\..*'),
|
||||
|
||||
// Common trackers
|
||||
_block('.*scorecardresearch\\.com.*'),
|
||||
_block('.*quantserve\\.com.*'),
|
||||
_block('.*chartbeat\\.com.*'),
|
||||
_block('.*newrelic\\.com.*'),
|
||||
// Common trackers
|
||||
_block('.*scorecardresearch\\.com.*'),
|
||||
_block('.*quantserve\\.com.*'),
|
||||
_block('.*chartbeat\\.com.*'),
|
||||
_block('.*newrelic\\.com.*'),
|
||||
|
||||
// Ad servers
|
||||
_block('.*ads\\.yahoo\\.com.*'),
|
||||
_block('.*advertising\\.com.*'),
|
||||
_block('.*adnxs\\.com.*'),
|
||||
_block('.*adsrvr\\.org.*'),
|
||||
_block('.*taboola\\.com.*'),
|
||||
_block('.*outbrain\\.com.*'),
|
||||
_block('.*pubmatic\\.com.*'),
|
||||
_block('.*rubiconproject\\.com.*'),
|
||||
_block('.*openx\\.net.*'),
|
||||
_block('.*casalemedia\\.com.*'),
|
||||
_block('.*criteo\\.com.*'),
|
||||
_block('.*criteo\\.net.*'),
|
||||
// Ad servers
|
||||
_block('.*ads\\.yahoo\\.com.*'),
|
||||
_block('.*advertising\\.com.*'),
|
||||
_block('.*adnxs\\.com.*'),
|
||||
_block('.*adsrvr\\.org.*'),
|
||||
_block('.*taboola\\.com.*'),
|
||||
_block('.*outbrain\\.com.*'),
|
||||
_block('.*pubmatic\\.com.*'),
|
||||
_block('.*rubiconproject\\.com.*'),
|
||||
_block('.*openx\\.net.*'),
|
||||
_block('.*casalemedia\\.com.*'),
|
||||
_block('.*criteo\\.com.*'),
|
||||
_block('.*criteo\\.net.*'),
|
||||
|
||||
// Pixel trackers
|
||||
_block('.*pixel\\.quantserve\\.com.*'),
|
||||
_block('.*pixel\\.facebook\\.com.*'),
|
||||
// Pixel trackers
|
||||
_block('.*pixel\\.quantserve\\.com.*'),
|
||||
_block('.*pixel\\.facebook\\.com.*'),
|
||||
|
||||
// IG-specific ad endpoints (safe to block — don't affect core IG)
|
||||
_block('.*\\.instagram\\.com.*\\/ads\\/.*'),
|
||||
];
|
||||
// IG-specific ad endpoints (safe to block — don't affect core IG)
|
||||
_block('.*\\.instagram\\.com.*\\/ads\\/.*'),
|
||||
];
|
||||
|
||||
static ContentBlocker _block(String pattern) => ContentBlocker(
|
||||
trigger: ContentBlockerTrigger(urlFilter: pattern),
|
||||
action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
|
||||
);
|
||||
trigger: ContentBlockerTrigger(urlFilter: pattern),
|
||||
action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
|
||||
);
|
||||
|
||||
// ── URLRequest for initial load ───────────────────────────────────────────
|
||||
static URLRequest get initialRequest => URLRequest(
|
||||
url: WebUri(instagramUrl),
|
||||
headers: {
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'DNT': '1',
|
||||
},
|
||||
);
|
||||
url: WebUri(instagramUrl),
|
||||
headers: {'Accept-Language': 'en-US,en;q=0.9', 'DNT': '1'},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user