mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-06-10 23:47:47 +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
|
PRD.md
|
||||||
.agents/
|
.agents/
|
||||||
TODO.md
|
TODO.md
|
||||||
|
v2/FOCUSGRAM_V2_PLAN.md
|
||||||
|
v2/FocusGram_Feed_Filtering_Reference.docx
|
||||||
|
.codex
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
@@ -27,7 +30,6 @@ TODO.md
|
|||||||
#.vscode/
|
#.vscode/
|
||||||
RELEASE_GUIDE.md
|
RELEASE_GUIDE.md
|
||||||
android/key.properties
|
android/key.properties
|
||||||
android/fdroid-config.properties
|
|
||||||
android/app/*.jks
|
android/app/*.jks
|
||||||
upload-keystore.jks
|
upload-keystore.jks
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- v2/**
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
|||||||
+190
@@ -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
|
// Step 3: Breath gate
|
||||||
if (settings.showBreathGate && !_breathCompleted) {
|
if (settings.showBreathGate && !_breathCompleted) {
|
||||||
return BreathGateScreen(
|
return BreathGateScreen(
|
||||||
|
durationSeconds: settings.breathGateSeconds,
|
||||||
onFinish: () => setState(() => _breathCompleted = true),
|
onFinish: () => setState(() => _breathCompleted = true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,25 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
|||||||
55,
|
55,
|
||||||
60,
|
60,
|
||||||
];
|
];
|
||||||
int _selectedIndex = 2; // default: 15 min
|
int _selectedIndex = 0; // default: 5 min unless a previous choice exists
|
||||||
|
late final FixedExtentScrollController _scrollController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final lastMinutes = context.read<SessionManager>().lastAppSessionMinutes;
|
||||||
|
final lastIndex = _minuteOptions.indexOf(lastMinutes);
|
||||||
|
_selectedIndex = lastIndex >= 0 ? lastIndex : 0;
|
||||||
|
_scrollController = FixedExtentScrollController(
|
||||||
|
initialItem: _selectedIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -118,12 +136,10 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
|||||||
perspective: 0.003,
|
perspective: 0.003,
|
||||||
squeeze: 1.1,
|
squeeze: 1.1,
|
||||||
diameterRatio: 2.5,
|
diameterRatio: 2.5,
|
||||||
|
controller: _scrollController,
|
||||||
onSelectedItemChanged: (i) {
|
onSelectedItemChanged: (i) {
|
||||||
setState(() => _selectedIndex = i);
|
setState(() => _selectedIndex = i);
|
||||||
},
|
},
|
||||||
controller: FixedExtentScrollController(
|
|
||||||
initialItem: _selectedIndex,
|
|
||||||
),
|
|
||||||
childDelegate: ListWheelChildListDelegate(
|
childDelegate: ListWheelChildListDelegate(
|
||||||
children: _minuteOptions.asMap().entries.map((entry) {
|
children: _minuteOptions.asMap().entries.map((entry) {
|
||||||
final isSelected = entry.key == _selectedIndex;
|
final isSelected = entry.key == _selectedIndex;
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
/// A mindfulness screen shown before the app opens.
|
/// A mindfulness screen shown before Instagram opens.
|
||||||
/// Forces the user to take a deep 10-second breath.
|
|
||||||
class BreathGateScreen extends StatefulWidget {
|
class BreathGateScreen extends StatefulWidget {
|
||||||
final VoidCallback onFinish;
|
final VoidCallback onFinish;
|
||||||
|
final int durationSeconds;
|
||||||
|
|
||||||
const BreathGateScreen({super.key, required this.onFinish});
|
const BreathGateScreen({
|
||||||
|
super.key,
|
||||||
|
required this.onFinish,
|
||||||
|
this.durationSeconds = 10,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
||||||
@@ -16,15 +20,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
|||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
late AnimationController _controller;
|
late AnimationController _controller;
|
||||||
late Animation<double> _scaleAnimation;
|
late Animation<double> _scaleAnimation;
|
||||||
int _secondsRemaining = 10;
|
late int _secondsRemaining;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _canContinue = false;
|
bool _canContinue = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
|
||||||
|
|
||||||
// 10-second breathing animation: 5s in, 5s out
|
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
@@ -71,7 +75,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Are you sure you want to open FocusGram?',
|
'Are you sure you want to open Instagram?',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -131,7 +135,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
|||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: BorderRadius.circular(25),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text('Continue to FocusGram'),
|
child: const Text('Continue to Instagram'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
Future<void> Function() action,
|
||||||
) async {
|
) async {
|
||||||
if (sm.isScheduledBlockActive) {
|
if (sm.isScheduledBlockActive) {
|
||||||
final ok = await DisciplineChallenge.show(context, count: 35);
|
final settings = context.read<SettingsService>();
|
||||||
|
final ok = await DisciplineChallenge.show(
|
||||||
|
context,
|
||||||
|
count: settings.resolvedWordChallengeCount(),
|
||||||
|
);
|
||||||
if (!context.mounted || !ok) return;
|
if (!context.mounted || !ok) return;
|
||||||
}
|
}
|
||||||
await action();
|
await action();
|
||||||
@@ -321,7 +325,8 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final sm = context.read<SessionManager>();
|
final sm = context.read<SessionManager>();
|
||||||
int wordCount = 15;
|
final settings = context.read<SettingsService>();
|
||||||
|
int wordCount = settings.resolvedWordChallengeCount();
|
||||||
// If we are at 0 quota, increase difficulty to 35 words
|
// If we are at 0 quota, increase difficulty to 35 words
|
||||||
if (widget.title.contains('Daily Reel Limit') &&
|
if (widget.title.contains('Daily Reel Limit') &&
|
||||||
sm.dailyRemainingSeconds <= 0) {
|
sm.dailyRemainingSeconds <= 0) {
|
||||||
|
|||||||
+845
-249
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
|
|
||||||
// Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications
|
// Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications
|
||||||
static const int _kTotalPages = 5;
|
static const int _kTotalPages = 5;
|
||||||
|
|
||||||
static const int _kBlurPage = 3;
|
static const int _kBlurPage = 3;
|
||||||
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
final List<Widget> slides = [
|
final List<Widget> slides = [
|
||||||
// ── Page 0: Welcome ─────────────────────────────────────────────────
|
// ── Page 0: Welcome ─────────────────────────────────────────────────
|
||||||
_StaticSlide(
|
_StaticSlide(
|
||||||
icon: Icons.auto_awesome,
|
icon: Icons.auto_awesome_rounded,
|
||||||
color: Colors.blue,
|
color: const Color(0xFF4F8DFF),
|
||||||
title: 'Welcome to FocusGram',
|
title: 'Welcome to FocusGram',
|
||||||
description:
|
description:
|
||||||
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
|
'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.',
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Page 1: Session Management ───────────────────────────────────────
|
// ── Page 1: Focus controls ───────────────────────────────────────────
|
||||||
_StaticSlide(
|
_StaticSlide(
|
||||||
icon: Icons.timer,
|
icon: Icons.timer_outlined,
|
||||||
color: Colors.orange,
|
color: const Color(0xFFFFB74D),
|
||||||
title: 'Session Management',
|
title: 'Time With Intent',
|
||||||
description:
|
description:
|
||||||
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
|
'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.',
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Page 2: Open links ───────────────────────────────────────────────
|
// ── Page 2: Open links ───────────────────────────────────────────────
|
||||||
_StaticSlide(
|
_StaticSlide(
|
||||||
icon: Icons.link,
|
icon: Icons.link_rounded,
|
||||||
color: Colors.cyan,
|
color: const Color(0xFF35C2D6),
|
||||||
title: 'Open Links in FocusGram',
|
title: 'Open Links in FocusGram',
|
||||||
description:
|
description:
|
||||||
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
|
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
|
||||||
@@ -63,11 +63,11 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
|
|
||||||
// ── Page 4: Notifications ────────────────────────────────────────────
|
// ── Page 4: Notifications ────────────────────────────────────────────
|
||||||
_StaticSlide(
|
_StaticSlide(
|
||||||
icon: Icons.notifications_active,
|
icon: Icons.notifications_active_outlined,
|
||||||
color: Colors.green,
|
color: const Color(0xFF5DD18A),
|
||||||
title: 'Stay Notified',
|
title: 'Useful Alerts Only',
|
||||||
description:
|
description:
|
||||||
'We need notification permissions to alert you when your session is over or a new message arrives.',
|
'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.',
|
||||||
isPermissionPage: true,
|
isPermissionPage: true,
|
||||||
permission: Permission.notification,
|
permission: Permission.notification,
|
||||||
),
|
),
|
||||||
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 28),
|
||||||
// CTA button
|
// CTA button
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
final isBlur = _currentPage == _kBlurPage;
|
final isBlur = _currentPage == _kBlurPage;
|
||||||
|
|
||||||
String label;
|
String label;
|
||||||
if (isLast) {
|
if (isNotif) {
|
||||||
label = 'Get Started';
|
label = 'Allow & Start';
|
||||||
} else if (isLink) {
|
} else if (isLink) {
|
||||||
label = 'Configure';
|
label = 'Configure';
|
||||||
} else if (isNotif) {
|
|
||||||
label = 'Allow Notifications';
|
|
||||||
} else if (isBlur) {
|
} else if (isBlur) {
|
||||||
label = 'Save & Continue';
|
label = 'Save & Continue';
|
||||||
|
} else if (isLast) {
|
||||||
|
label = 'Get Started';
|
||||||
} else {
|
} else {
|
||||||
label = 'Next';
|
label = 'Next';
|
||||||
}
|
}
|
||||||
@@ -143,7 +143,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
);
|
);
|
||||||
} else if (isNotif) {
|
} else if (isNotif) {
|
||||||
await Permission.notification.request();
|
await Permission.notification.request();
|
||||||
await NotificationService().init();
|
await NotificationService()
|
||||||
|
.requestPermissionsNow();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -178,9 +179,19 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
// Skip button (available on all pages except last)
|
// Skip button (available on all pages except last)
|
||||||
if (_currentPage < _kTotalPages - 1)
|
if (_currentPage < _kTotalPages - 1)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _finish(context),
|
onPressed: () {
|
||||||
|
if (_currentPage == _kNotifPage) {
|
||||||
|
_finish(context);
|
||||||
|
} else {
|
||||||
|
_pageController.animateToPage(
|
||||||
|
_kTotalPages - 1,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Skip',
|
'Skip setup',
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 14),
|
style: TextStyle(color: Colors.white38, fontSize: 14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -222,18 +233,27 @@ class _StaticSlide extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
|
padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 120, color: color),
|
Container(
|
||||||
const SizedBox(height: 48),
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.28)),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 54, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 32,
|
fontSize: 30,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -243,10 +263,28 @@ class _StaticSlide extends StatelessWidget {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (isPermissionPage || isAppSettingsPage) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.06),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isPermissionPage
|
||||||
|
? 'Permission is optional and can be changed later.'
|
||||||
|
: 'This opens Android settings; return here when done.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
|||||||
hideReelsTab: false,
|
hideReelsTab: false,
|
||||||
hideShopTab: false,
|
hideShopTab: false,
|
||||||
disableReelsEntirely: false,
|
disableReelsEntirely: false,
|
||||||
|
blockHomeFeedScroll: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/session_manager.dart';
|
import '../services/session_manager.dart';
|
||||||
|
import '../services/settings_service.dart';
|
||||||
import '../utils/discipline_challenge.dart';
|
import '../utils/discipline_challenge.dart';
|
||||||
|
|
||||||
class SessionModal extends StatefulWidget {
|
class SessionModal extends StatefulWidget {
|
||||||
@@ -63,23 +64,22 @@ class _SessionModalState extends State<SessionModal> {
|
|||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
spacing: 8,
|
||||||
children: [1, 5, 10, 15].map((m) {
|
runSpacing: 8,
|
||||||
return Expanded(
|
children: [1, 3, 5, 10, 15, 20, 30].map((m) {
|
||||||
child: Padding(
|
return SizedBox(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
width: 72,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||||
? null
|
? null
|
||||||
: () => _start(m),
|
: () => _start(m),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: Colors.white12,
|
backgroundColor: Colors.white12,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
),
|
|
||||||
child: Text('${m}m'),
|
|
||||||
),
|
),
|
||||||
|
child: Text('${m}m'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
|
|||||||
Slider(
|
Slider(
|
||||||
value: _customMinutes,
|
value: _customMinutes,
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 30,
|
max: 60,
|
||||||
divisions: 29,
|
divisions: 59,
|
||||||
label: '${_customMinutes.toInt()}m',
|
label: '${_customMinutes.toInt()}m',
|
||||||
onChanged: (v) => setState(() => _customMinutes = v),
|
onChanged: (v) => setState(() => _customMinutes = v),
|
||||||
),
|
),
|
||||||
@@ -126,10 +126,15 @@ class _SessionModalState extends State<SessionModal> {
|
|||||||
|
|
||||||
void _start(int minutes) async {
|
void _start(int minutes) async {
|
||||||
final sm = context.read<SessionManager>();
|
final sm = context.read<SessionManager>();
|
||||||
|
final settings = context.read<SettingsService>();
|
||||||
|
|
||||||
// Always require word challenge for reel sessions (User request)
|
if (settings.requireWordChallenge) {
|
||||||
final success = await DisciplineChallenge.show(context);
|
final success = await DisciplineChallenge.show(
|
||||||
if (!success) return;
|
context,
|
||||||
|
count: settings.resolvedWordChallengeCount(),
|
||||||
|
);
|
||||||
|
if (!success) return;
|
||||||
|
}
|
||||||
|
|
||||||
if (sm.startSession(minutes)) {
|
if (sm.startSession(minutes)) {
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
|
|||||||
+418
-51
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
@@ -8,6 +9,7 @@ import '../services/settings_service.dart';
|
|||||||
import '../services/focusgram_router.dart';
|
import '../services/focusgram_router.dart';
|
||||||
import '../features/screen_time/screen_time_screen.dart';
|
import '../features/screen_time/screen_time_screen.dart';
|
||||||
import 'guardrails_page.dart';
|
import 'guardrails_page.dart';
|
||||||
|
import 'extras_settings_page.dart';
|
||||||
|
|
||||||
// ─── Main Settings Page ───────────────────────────────────────────────────────
|
// ─── Main Settings Page ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ class SettingsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
|
const _DonateTile(),
|
||||||
_buildStatsRow(sm),
|
_buildStatsRow(sm),
|
||||||
|
|
||||||
const _SectionHeader(title: 'FOCUS & BLOCKING'),
|
const _SectionHeader(title: 'FOCUS & BLOCKING'),
|
||||||
@@ -63,6 +66,19 @@ class SettingsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const _SectionHeader(title: 'EXTRAS'),
|
||||||
|
_SubmoduleTile(
|
||||||
|
icon: Icons.download_rounded,
|
||||||
|
iconColor: Colors.orangeAccent,
|
||||||
|
title: 'Extras',
|
||||||
|
subtitle: 'Download media, Ghost Mode, Ad Blocker',
|
||||||
|
enabled: true,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const _SectionHeader(title: 'APPEARANCE'),
|
const _SectionHeader(title: 'APPEARANCE'),
|
||||||
_SubmoduleTile(
|
_SubmoduleTile(
|
||||||
icon: Icons.palette_outlined,
|
icon: Icons.palette_outlined,
|
||||||
@@ -264,6 +280,7 @@ class FocusSettingsPage extends StatelessWidget {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
const _SectionHeader(title: 'BLOCKING'),
|
const _SectionHeader(title: 'BLOCKING'),
|
||||||
|
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
@@ -293,12 +310,23 @@ class FocusSettingsPage extends StatelessWidget {
|
|||||||
color: Colors.redAccent.withValues(alpha: 0.12),
|
color: Colors.redAccent.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.shield_rounded, color: Colors.redAccent, size: 20),
|
child: const Icon(
|
||||||
|
Icons.shield_rounded,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: const Text('Minimal Mode', style: TextStyle(fontSize: 15)),
|
title: const Text('Minimal Mode', style: TextStyle(fontSize: 15)),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
settings.minimalModeEnabled ? 'Enabled - tap to customize' : 'Disabled - tap to configure',
|
settings.minimalModeEnabled
|
||||||
style: TextStyle(fontSize: 12, color: settings.minimalModeEnabled ? Colors.greenAccent : Colors.grey),
|
? 'Enabled - tap to customize'
|
||||||
|
: 'Disabled - tap to configure',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: settings.minimalModeEnabled
|
||||||
|
? Colors.greenAccent
|
||||||
|
: Colors.grey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
trailing: Switch(
|
trailing: Switch(
|
||||||
value: settings.minimalModeEnabled,
|
value: settings.minimalModeEnabled,
|
||||||
@@ -307,22 +335,49 @@ class FocusSettingsPage extends StatelessWidget {
|
|||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage())),
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage()),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const _SectionHeader(title: 'FRICTION'),
|
const _SectionHeader(title: 'FRICTION'),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: 'Mindfulness Gate',
|
title: 'Mindfulness Gate',
|
||||||
subtitle: 'Breath screen before opening Instagram',
|
subtitle: '${settings.breathGateSeconds}s before opening Instagram',
|
||||||
value: settings.showBreathGate,
|
value: settings.showBreathGate,
|
||||||
onChanged: (v) => settings.setShowBreathGate(v),
|
onChanged: (v) => settings.setShowBreathGate(v),
|
||||||
),
|
),
|
||||||
|
if (settings.showBreathGate)
|
||||||
|
_NumberEditTile(
|
||||||
|
title: 'Gate Duration',
|
||||||
|
label: '${settings.breathGateSeconds} seconds',
|
||||||
|
initialValue: settings.breathGateSeconds,
|
||||||
|
min: 3,
|
||||||
|
max: 60,
|
||||||
|
suffix: 'seconds',
|
||||||
|
onSubmitted: (v) => settings.setBreathGateSeconds(v),
|
||||||
|
),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: 'Strict Mode (Word Challenge)',
|
title: 'Typing Challenge',
|
||||||
subtitle: 'Must type a phrase before starting a Reel session',
|
subtitle: settings.wordChallengeCount == 0
|
||||||
|
? 'Random: 10-35 words'
|
||||||
|
: '${settings.wordChallengeCount} words',
|
||||||
value: settings.requireWordChallenge,
|
value: settings.requireWordChallenge,
|
||||||
onChanged: (v) => settings.setRequireWordChallenge(v),
|
onChanged: (v) => settings.setRequireWordChallenge(v),
|
||||||
),
|
),
|
||||||
|
if (settings.requireWordChallenge)
|
||||||
|
_ChoiceTile<int>(
|
||||||
|
title: 'Typing Words',
|
||||||
|
value: settings.wordChallengeCount,
|
||||||
|
label: settings.wordChallengeCount == 0
|
||||||
|
? 'Random (10-35)'
|
||||||
|
: '${settings.wordChallengeCount} words',
|
||||||
|
options: const [20, 25, 30, 35, 0],
|
||||||
|
optionLabel: (v) => v == 0 ? 'Random (10-35)' : '$v words',
|
||||||
|
onSelected: (v) => settings.setWordChallengeCount(v),
|
||||||
|
),
|
||||||
|
|
||||||
const _SectionHeader(title: 'MEDIA'),
|
const _SectionHeader(title: 'MEDIA'),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: 'Block Autoplay Videos',
|
title: 'Block Autoplay Videos',
|
||||||
@@ -348,6 +403,48 @@ class FocusSettingsPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const _SectionHeader(title: 'FOCUSGRAM V2 OVERLAY'),
|
||||||
|
|
||||||
|
_SwitchTile(
|
||||||
|
title: 'Content Hider',
|
||||||
|
subtitle: 'Hide stories tray, feed posts, reels, suggested content',
|
||||||
|
value: settings.v2ContentHiderEnabled,
|
||||||
|
onChanged: (v) => settings.setV2ContentHiderEnabled(v),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (settings.v2ContentHiderEnabled)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 32),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_SwitchTile(
|
||||||
|
title: 'Hide Stories Tray',
|
||||||
|
subtitle: 'Story bubbles row',
|
||||||
|
value: settings.contentStories,
|
||||||
|
onChanged: (v) => settings.setContentStoriesEnabled(v),
|
||||||
|
),
|
||||||
|
_SwitchTile(
|
||||||
|
title: 'Hide Feed Posts',
|
||||||
|
subtitle: 'Home feed posts',
|
||||||
|
value: settings.contentPosts,
|
||||||
|
onChanged: (v) => settings.setContentPostsEnabled(v),
|
||||||
|
),
|
||||||
|
_SwitchTile(
|
||||||
|
title: 'Hide Reels (Feed)',
|
||||||
|
subtitle: 'Reels shown in the feed',
|
||||||
|
value: settings.contentReels,
|
||||||
|
onChanged: (v) => settings.setContentReelsEnabled(v),
|
||||||
|
),
|
||||||
|
_SwitchTile(
|
||||||
|
title: 'Hide Suggested Content',
|
||||||
|
subtitle: 'Suggested posts and recommendation units',
|
||||||
|
value: settings.contentSuggested,
|
||||||
|
onChanged: (v) => settings.setContentSuggestedEnabled(v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -355,6 +452,50 @@ class FocusSettingsPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DonateTile extends StatelessWidget {
|
||||||
|
const _DonateTile();
|
||||||
|
|
||||||
|
static final Uri _donateUri = Uri.parse('https://buymemomo.com/ujwal');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.pinkAccent.withValues(alpha: 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.pinkAccent.withValues(alpha: 0.22)),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.pinkAccent.withValues(alpha: 0.14),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.favorite_rounded,
|
||||||
|
color: Colors.pinkAccent,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'Please donate to support the development of this project.',
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Your support keeps FocusGram free and maintained.',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.open_in_new, size: 14),
|
||||||
|
onTap: () =>
|
||||||
|
launchUrl(_donateUri, mode: LaunchMode.externalApplication),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Minimal Mode Submenu ─────────────────────────────────────────────────────
|
// ─── Minimal Mode Submenu ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
class MinimalModeSubmenuPage extends StatefulWidget {
|
class MinimalModeSubmenuPage extends StatefulWidget {
|
||||||
@@ -368,6 +509,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
|||||||
late bool _blurExplore;
|
late bool _blurExplore;
|
||||||
late bool _disableReelsEntirely;
|
late bool _disableReelsEntirely;
|
||||||
late bool _disableExploreEntirely;
|
late bool _disableExploreEntirely;
|
||||||
|
late bool _blockHomeFeedScroll;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -376,26 +518,51 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
|||||||
_blurExplore = settings.blurExplore;
|
_blurExplore = settings.blurExplore;
|
||||||
_disableReelsEntirely = settings.disableReelsEntirely;
|
_disableReelsEntirely = settings.disableReelsEntirely;
|
||||||
_disableExploreEntirely = settings.disableExploreEntirely;
|
_disableExploreEntirely = settings.disableExploreEntirely;
|
||||||
|
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateSetting(String key, bool value) {
|
Future<void> _updateSetting(String key, bool value) async {
|
||||||
final settings = context.read<SettingsService>();
|
final settings = context.read<SettingsService>();
|
||||||
setState(() {
|
setState(() {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'blurExplore':
|
case 'blurExplore':
|
||||||
_blurExplore = value;
|
_blurExplore = value;
|
||||||
settings.setBlurExplore(value);
|
|
||||||
break;
|
break;
|
||||||
case 'disableReelsEntirely':
|
case 'disableReelsEntirely':
|
||||||
_disableReelsEntirely = value;
|
_disableReelsEntirely = value;
|
||||||
settings.setDisableReelsEntirelyInternal(value);
|
|
||||||
break;
|
break;
|
||||||
case 'disableExploreEntirely':
|
case 'disableExploreEntirely':
|
||||||
_disableExploreEntirely = value;
|
_disableExploreEntirely = value;
|
||||||
settings.setDisableExploreEntirelyInternal(value);
|
break;
|
||||||
|
case 'blockHomeFeedScroll':
|
||||||
|
_blockHomeFeedScroll = value;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'blurExplore':
|
||||||
|
await settings.setBlurExplore(value);
|
||||||
|
break;
|
||||||
|
case 'disableReelsEntirely':
|
||||||
|
await settings.setDisableReelsEntirelyInternal(value);
|
||||||
|
break;
|
||||||
|
case 'disableExploreEntirely':
|
||||||
|
await settings.setDisableExploreEntirelyInternal(value);
|
||||||
|
break;
|
||||||
|
case 'blockHomeFeedScroll':
|
||||||
|
await settings.setBlockHomeFeedScrollInternal(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
final latest = context.read<SettingsService>();
|
||||||
|
setState(() {
|
||||||
|
_blurExplore = latest.blurExplore;
|
||||||
|
_disableReelsEntirely = latest.disableReelsEntirely;
|
||||||
|
_disableExploreEntirely = latest.disableExploreEntirely;
|
||||||
|
_blockHomeFeedScroll = latest.blockHomeFeedScroll;
|
||||||
|
});
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +573,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
|||||||
_blurExplore = true;
|
_blurExplore = true;
|
||||||
_disableReelsEntirely = true;
|
_disableReelsEntirely = true;
|
||||||
_disableExploreEntirely = true;
|
_disableExploreEntirely = true;
|
||||||
|
_blockHomeFeedScroll = true;
|
||||||
});
|
});
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
}
|
}
|
||||||
@@ -418,6 +586,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
|||||||
_blurExplore = settings.blurExplore;
|
_blurExplore = settings.blurExplore;
|
||||||
_disableReelsEntirely = settings.disableReelsEntirely;
|
_disableReelsEntirely = settings.disableReelsEntirely;
|
||||||
_disableExploreEntirely = settings.disableExploreEntirely;
|
_disableExploreEntirely = settings.disableExploreEntirely;
|
||||||
|
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
||||||
});
|
});
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
}
|
}
|
||||||
@@ -438,30 +607,44 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: isMinimalModeEnabled
|
colors: isMinimalModeEnabled
|
||||||
? [Colors.redAccent.withValues(alpha: 0.2), Colors.red.withValues(alpha: 0.1)]
|
? [
|
||||||
: [Colors.grey.withValues(alpha: 0.1), Colors.grey.withValues(alpha: 0.05)],
|
Colors.redAccent.withValues(alpha: 0.2),
|
||||||
|
Colors.red.withValues(alpha: 0.1),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
Colors.grey.withValues(alpha: 0.1),
|
||||||
|
Colors.grey.withValues(alpha: 0.05),
|
||||||
|
],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isMinimalModeEnabled ? Colors.redAccent.withValues(alpha: 0.3) : Colors.grey.withValues(alpha: 0.2),
|
color: isMinimalModeEnabled
|
||||||
|
? Colors.redAccent.withValues(alpha: 0.3)
|
||||||
|
: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isMinimalModeEnabled ? Icons.shield_rounded : Icons.shield_outlined,
|
isMinimalModeEnabled
|
||||||
|
? Icons.shield_rounded
|
||||||
|
: Icons.shield_outlined,
|
||||||
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
|
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
|
||||||
size: 48,
|
size: 48,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
isMinimalModeEnabled ? 'Minimal Mode Active' : 'Minimal Mode Disabled',
|
isMinimalModeEnabled
|
||||||
|
? 'Minimal Mode Active'
|
||||||
|
: 'Minimal Mode Disabled',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
|
color: isMinimalModeEnabled
|
||||||
|
? Colors.redAccent
|
||||||
|
: Colors.grey,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -470,20 +653,33 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
|||||||
? 'Distractions are blocked. Customize which features stay enabled below.'
|
? 'Distractions are blocked. Customize which features stay enabled below.'
|
||||||
: 'Turn on to block all distractions at once, or customize individual settings below.',
|
: 'Turn on to block all distractions at once, or customize individual settings below.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontSize: 13, color: isDark ? Colors.white54 : Colors.black54),
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: isDark ? Colors.white54 : Colors.black54,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: isMinimalModeEnabled ? _turnOffMinimalMode : _turnOnMinimalMode,
|
onPressed: isMinimalModeEnabled
|
||||||
|
? _turnOffMinimalMode
|
||||||
|
: _turnOnMinimalMode,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: isMinimalModeEnabled ? Colors.grey : Colors.redAccent,
|
backgroundColor: isMinimalModeEnabled
|
||||||
|
? Colors.grey
|
||||||
|
: Colors.redAccent,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isMinimalModeEnabled
|
||||||
|
? 'Turn Off Minimal Mode'
|
||||||
|
: 'Turn On Minimal Mode',
|
||||||
),
|
),
|
||||||
child: Text(isMinimalModeEnabled ? 'Turn Off Minimal Mode' : 'Turn On Minimal Mode'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -502,7 +698,11 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
|||||||
),
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.touch_app_rounded, size: 14, color: Colors.blueAccent),
|
Icon(
|
||||||
|
Icons.touch_app_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -520,6 +720,12 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
|||||||
value: _blurExplore,
|
value: _blurExplore,
|
||||||
onChanged: (v) => _updateSetting('blurExplore', v),
|
onChanged: (v) => _updateSetting('blurExplore', v),
|
||||||
),
|
),
|
||||||
|
_SwitchTile(
|
||||||
|
title: 'Block Home Feed Scroll',
|
||||||
|
subtitle: 'Freeze vertical scrolling on the home feed only',
|
||||||
|
value: _blockHomeFeedScroll,
|
||||||
|
onChanged: (v) => _updateSetting('blockHomeFeedScroll', v),
|
||||||
|
),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: 'Disable Reels Entirely',
|
title: 'Disable Reels Entirely',
|
||||||
subtitle: 'Block all Reels with no session option',
|
subtitle: 'Block all Reels with no session option',
|
||||||
@@ -550,7 +756,10 @@ class AppearancePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppearancePageState extends State<AppearancePage> {
|
class _AppearancePageState extends State<AppearancePage> {
|
||||||
Future<void> _addSchedule(BuildContext context, SettingsService settings) async {
|
Future<void> _addSchedule(
|
||||||
|
BuildContext context,
|
||||||
|
SettingsService settings,
|
||||||
|
) async {
|
||||||
TimeOfDay? startTime = await showTimePicker(
|
TimeOfDay? startTime = await showTimePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialTime: const TimeOfDay(hour: 21, minute: 0),
|
initialTime: const TimeOfDay(hour: 21, minute: 0),
|
||||||
@@ -569,14 +778,20 @@ class _AppearancePageState extends State<AppearancePage> {
|
|||||||
|
|
||||||
final newSchedule = {
|
final newSchedule = {
|
||||||
'enabled': true,
|
'enabled': true,
|
||||||
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
|
'startTime':
|
||||||
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
|
'${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
|
||||||
|
'endTime':
|
||||||
|
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
|
||||||
};
|
};
|
||||||
|
|
||||||
await settings.addGrayscaleSchedule(newSchedule);
|
await settings.addGrayscaleSchedule(newSchedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _editSchedule(BuildContext context, SettingsService settings, int index) async {
|
Future<void> _editSchedule(
|
||||||
|
BuildContext context,
|
||||||
|
SettingsService settings,
|
||||||
|
int index,
|
||||||
|
) async {
|
||||||
final schedules = settings.grayscaleSchedules;
|
final schedules = settings.grayscaleSchedules;
|
||||||
if (index >= schedules.length) return;
|
if (index >= schedules.length) return;
|
||||||
|
|
||||||
@@ -611,15 +826,19 @@ class _AppearancePageState extends State<AppearancePage> {
|
|||||||
|
|
||||||
final updatedSchedule = {
|
final updatedSchedule = {
|
||||||
...current,
|
...current,
|
||||||
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
|
'startTime':
|
||||||
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
|
'${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
|
||||||
|
'endTime':
|
||||||
|
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
|
||||||
};
|
};
|
||||||
|
|
||||||
await settings.updateGrayscaleSchedule(index, updatedSchedule);
|
await settings.updateGrayscaleSchedule(index, updatedSchedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleSchedule(SettingsService settings, int index) async {
|
Future<void> _toggleSchedule(SettingsService settings, int index) async {
|
||||||
final schedules = List<Map<String, dynamic>>.from(settings.grayscaleSchedules);
|
final schedules = List<Map<String, dynamic>>.from(
|
||||||
|
settings.grayscaleSchedules,
|
||||||
|
);
|
||||||
if (index >= schedules.length) return;
|
if (index >= schedules.length) return;
|
||||||
|
|
||||||
schedules[index] = {
|
schedules[index] = {
|
||||||
@@ -669,7 +888,8 @@ class _AppearancePageState extends State<AppearancePage> {
|
|||||||
const _SectionHeader(title: 'DISPLAY'),
|
const _SectionHeader(title: 'DISPLAY'),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: 'Grayscale Mode',
|
title: 'Grayscale Mode',
|
||||||
subtitle: 'Makes Instagram black & white — reduces dopamine response',
|
subtitle:
|
||||||
|
'Makes Instagram black & white — reduces dopamine response',
|
||||||
value: settings.grayscaleEnabled,
|
value: settings.grayscaleEnabled,
|
||||||
onChanged: (v) => settings.setGrayscaleEnabled(v),
|
onChanged: (v) => settings.setGrayscaleEnabled(v),
|
||||||
),
|
),
|
||||||
@@ -695,16 +915,26 @@ class _AppearancePageState extends State<AppearancePage> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.1) : Colors.orange.withValues(alpha: 0.1),
|
color: settings.isGrayscaleActiveNow
|
||||||
|
? Colors.green.withValues(alpha: 0.1)
|
||||||
|
: Colors.orange.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.3) : Colors.orange.withValues(alpha: 0.3)),
|
border: Border.all(
|
||||||
|
color: settings.isGrayscaleActiveNow
|
||||||
|
? Colors.green.withValues(alpha: 0.3)
|
||||||
|
: Colors.orange.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
settings.isGrayscaleActiveNow ? Icons.check_circle : Icons.schedule,
|
settings.isGrayscaleActiveNow
|
||||||
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent,
|
? Icons.check_circle
|
||||||
size: 20
|
: Icons.schedule,
|
||||||
|
color: settings.isGrayscaleActiveNow
|
||||||
|
? Colors.greenAccent
|
||||||
|
: Colors.orangeAccent,
|
||||||
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -714,7 +944,9 @@ class _AppearancePageState extends State<AppearancePage> {
|
|||||||
: 'Grayscale is currently inactive',
|
: 'Grayscale is currently inactive',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent
|
color: settings.isGrayscaleActiveNow
|
||||||
|
? Colors.greenAccent
|
||||||
|
: Colors.orangeAccent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -732,11 +964,14 @@ class _AppearancePageState extends State<AppearancePage> {
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: (isEnabled ? Colors.purpleAccent : Colors.grey).withValues(alpha: 0.12),
|
color: (isEnabled ? Colors.purpleAccent : Colors.grey)
|
||||||
|
.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
isEnabled ? Icons.play_circle_outline : Icons.pause_circle_outline,
|
isEnabled
|
||||||
|
? Icons.play_circle_outline
|
||||||
|
: Icons.pause_circle_outline,
|
||||||
color: isEnabled ? Colors.purpleAccent : Colors.grey,
|
color: isEnabled ? Colors.purpleAccent : Colors.grey,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
@@ -750,7 +985,10 @@ class _AppearancePageState extends State<AppearancePage> {
|
|||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
isEnabled ? 'Active' : 'Disabled',
|
isEnabled ? 'Active' : 'Disabled',
|
||||||
style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isDark ? Colors.white54 : Colors.black45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -805,12 +1043,22 @@ class _AppearancePageState extends State<AppearancePage> {
|
|||||||
color: Colors.green.withValues(alpha: 0.12),
|
color: Colors.green.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.add_circle_outline, color: Colors.green, size: 20),
|
child: const Icon(
|
||||||
|
Icons.add_circle_outline,
|
||||||
|
color: Colors.green,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'Add Schedule',
|
||||||
|
style: TextStyle(color: Colors.green),
|
||||||
),
|
),
|
||||||
title: const Text('Add Schedule', style: TextStyle(color: Colors.green)),
|
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'Add a new grayscale schedule',
|
'Add a new grayscale schedule',
|
||||||
style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: isDark ? Colors.white54 : Colors.black45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onTap: () => _addSchedule(context, settings),
|
onTap: () => _addSchedule(context, settings),
|
||||||
),
|
),
|
||||||
@@ -966,15 +1214,9 @@ class _SwitchTile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SwitchListTile(
|
return SwitchListTile(
|
||||||
title: Text(
|
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||||
title,
|
|
||||||
style: const TextStyle(fontSize: 15),
|
|
||||||
),
|
|
||||||
subtitle: subtitle != null
|
subtitle: subtitle != null
|
||||||
? Text(
|
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
|
||||||
subtitle ?? '',
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
value: value,
|
value: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
@@ -982,6 +1224,131 @@ class _SwitchTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ChoiceTile<T> extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final T value;
|
||||||
|
final String label;
|
||||||
|
final List<T> options;
|
||||||
|
final String Function(T value) optionLabel;
|
||||||
|
final ValueChanged<T> onSelected;
|
||||||
|
|
||||||
|
const _ChoiceTile({
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.label,
|
||||||
|
required this.options,
|
||||||
|
required this.optionLabel,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||||
|
subtitle: Text(label, style: const TextStyle(fontSize: 12)),
|
||||||
|
trailing: PopupMenuButton<T>(
|
||||||
|
initialValue: value,
|
||||||
|
onSelected: onSelected,
|
||||||
|
itemBuilder: (context) => options
|
||||||
|
.map(
|
||||||
|
(option) => PopupMenuItem<T>(
|
||||||
|
value: option,
|
||||||
|
child: Text(optionLabel(option)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
child: const Icon(Icons.expand_more_rounded, size: 22),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final selected = await showModalBottomSheet<T>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: options
|
||||||
|
.map(
|
||||||
|
(option) => ListTile(
|
||||||
|
title: Text(optionLabel(option)),
|
||||||
|
trailing: option == value
|
||||||
|
? const Icon(Icons.check_rounded)
|
||||||
|
: null,
|
||||||
|
onTap: () => Navigator.pop(context, option),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selected != null) onSelected(selected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NumberEditTile extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String label;
|
||||||
|
final int initialValue;
|
||||||
|
final int min;
|
||||||
|
final int max;
|
||||||
|
final String suffix;
|
||||||
|
final ValueChanged<int> onSubmitted;
|
||||||
|
|
||||||
|
const _NumberEditTile({
|
||||||
|
required this.title,
|
||||||
|
required this.label,
|
||||||
|
required this.initialValue,
|
||||||
|
required this.min,
|
||||||
|
required this.max,
|
||||||
|
required this.suffix,
|
||||||
|
required this.onSubmitted,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||||
|
subtitle: Text(label, style: const TextStyle(fontSize: 12)),
|
||||||
|
trailing: const Icon(Icons.edit_outlined, size: 20),
|
||||||
|
onTap: () async {
|
||||||
|
final controller = TextEditingController(text: '$initialValue');
|
||||||
|
final result = await showDialog<int>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
suffixText: suffix,
|
||||||
|
helperText: '$min-$max $suffix',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final parsed = int.tryParse(controller.text.trim());
|
||||||
|
if (parsed == null) return;
|
||||||
|
Navigator.pop(dialogContext, parsed.clamp(min, max).toInt());
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
controller.dispose();
|
||||||
|
if (result != null) onSubmitted(result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
class _SectionHeader extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
const _SectionHeader({required this.title});
|
const _SectionHeader({required this.title});
|
||||||
|
|||||||
@@ -277,13 +277,15 @@ const String kReelsMutationObserverJS = r'''
|
|||||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||||
|
|
||||||
function lockMode() {
|
function lockMode() {
|
||||||
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled with reel present
|
// Lock DM reels to prevent swipe-to-next, and optionally lock the home
|
||||||
|
// feed as a separate Minimal Mode control.
|
||||||
const isDmReel = window.location.pathname.includes('/direct/') &&
|
const isDmReel = window.location.pathname.includes('/direct/') &&
|
||||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||||
if (isDmReel) return 'dm_reel';
|
if (isDmReel) return 'dm_reel';
|
||||||
// Only lock scroll when reel element is actually present on the page
|
if (window.__fgBlockHomeFeedScroll === true &&
|
||||||
if (window.__fgDisableReelsEntirely === true &&
|
(window.location.pathname === '/' || window.location.pathname === '')) {
|
||||||
!!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled';
|
return 'home_feed';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,8 +340,7 @@ const String kReelsMutationObserverJS = r'''
|
|||||||
try {
|
try {
|
||||||
const mode = lockMode();
|
const mode = lockMode();
|
||||||
const hasReel = !!document.querySelector(REEL_SEL);
|
const hasReel = !!document.querySelector(REEL_SEL);
|
||||||
// Apply lock for dm_reel or disabled modes when reel is present
|
if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
|
||||||
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
|
|
||||||
if (__fgOrigHtmlOverflow === null) {
|
if (__fgOrigHtmlOverflow === null) {
|
||||||
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
||||||
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
||||||
|
|||||||
@@ -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 blurReels,
|
||||||
required bool tapToUnblur,
|
required bool tapToUnblur,
|
||||||
required bool enableTextSelection,
|
required bool enableTextSelection,
|
||||||
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
|
required bool hideSuggestedPosts,
|
||||||
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
|
required bool hideSponsoredPosts,
|
||||||
required bool hideLikeCounts,
|
required bool hideLikeCounts,
|
||||||
required bool hideFollowerCounts,
|
required bool hideFollowerCounts,
|
||||||
// hideStoriesBar parameter removed per user request
|
|
||||||
required bool hideExploreTab,
|
required bool hideExploreTab,
|
||||||
required bool hideReelsTab,
|
required bool hideReelsTab,
|
||||||
required bool hideShopTab,
|
required bool hideShopTab,
|
||||||
required bool disableReelsEntirely,
|
required bool disableReelsEntirely,
|
||||||
|
required bool blockHomeFeedScroll,
|
||||||
}) {
|
}) {
|
||||||
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
||||||
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
||||||
@@ -75,18 +75,12 @@ class InjectionController {
|
|||||||
css.writeln(scripts.kHideReelsFeedContentCSS);
|
css.writeln(scripts.kHideReelsFeedContentCSS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
|
|
||||||
// Previously it was inside that block alongside display:none on the parent —
|
|
||||||
// you cannot blur children of a display:none element, making it dead code.
|
|
||||||
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
|
|
||||||
// when sessionActive=false, reels are hidden anyway (blur harmless).
|
|
||||||
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
|
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
|
||||||
|
|
||||||
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
||||||
|
|
||||||
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
||||||
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
||||||
// Stories hiding removed per user request
|
|
||||||
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
||||||
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
||||||
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
||||||
@@ -94,6 +88,7 @@ class InjectionController {
|
|||||||
return '''
|
return '''
|
||||||
${buildSessionStateJS(sessionActive)}
|
${buildSessionStateJS(sessionActive)}
|
||||||
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
||||||
|
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
|
||||||
window.__fgTapToUnblur = $tapToUnblur;
|
window.__fgTapToUnblur = $tapToUnblur;
|
||||||
${scripts.kTrackPathJS}
|
${scripts.kTrackPathJS}
|
||||||
${_buildMutationObserver(css.toString())}
|
${_buildMutationObserver(css.toString())}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'injection_controller.dart';
|
|||||||
import '../scripts/grayscale.dart' as grayscale;
|
import '../scripts/grayscale.dart' as grayscale;
|
||||||
import '../scripts/ui_hider.dart' as ui_hider;
|
import '../scripts/ui_hider.dart' as ui_hider;
|
||||||
import '../scripts/content_disabling.dart' as content_disabling;
|
import '../scripts/content_disabling.dart' as content_disabling;
|
||||||
|
import '../scripts/video_downloader.dart' as video_downloader;
|
||||||
|
|
||||||
// Core JS and CSS payloads injected into the Instagram WebView.
|
// Core JS and CSS payloads injected into the Instagram WebView.
|
||||||
//
|
//
|
||||||
@@ -386,18 +387,39 @@ const String kLinkSanitizationJS = r'''
|
|||||||
|
|
||||||
// ── InjectionManager class ─────────────────────────────────────────────────
|
// ── InjectionManager class ─────────────────────────────────────────────────
|
||||||
|
|
||||||
class InjectionManager {
|
abstract class JsEvaluator {
|
||||||
|
Future<void> evaluateJavascript({required String source});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebViewJsEvaluator implements JsEvaluator {
|
||||||
final InAppWebViewController controller;
|
final InAppWebViewController controller;
|
||||||
|
_WebViewJsEvaluator(this.controller);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> evaluateJavascript({required String source}) {
|
||||||
|
return controller.evaluateJavascript(source: source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InjectionManager {
|
||||||
|
final JsEvaluator _jsEvaluator;
|
||||||
final SharedPreferences prefs;
|
final SharedPreferences prefs;
|
||||||
final SessionManager sessionManager;
|
final SessionManager sessionManager;
|
||||||
|
|
||||||
SettingsService? _settingsService;
|
SettingsService? _settingsService;
|
||||||
|
|
||||||
InjectionManager({
|
InjectionManager({
|
||||||
required this.controller,
|
required InAppWebViewController controller,
|
||||||
required this.prefs,
|
required this.prefs,
|
||||||
required this.sessionManager,
|
required this.sessionManager,
|
||||||
});
|
JsEvaluator? jsEvaluator,
|
||||||
|
}) : _jsEvaluator = jsEvaluator ?? _WebViewJsEvaluator(controller);
|
||||||
|
|
||||||
|
InjectionManager.forTest({
|
||||||
|
required JsEvaluator jsEvaluator,
|
||||||
|
required this.prefs,
|
||||||
|
required this.sessionManager,
|
||||||
|
}) : _jsEvaluator = jsEvaluator;
|
||||||
|
|
||||||
void setSettingsService(SettingsService settingsService) {
|
void setSettingsService(SettingsService settingsService) {
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
@@ -415,18 +437,19 @@ class InjectionManager {
|
|||||||
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
|
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
|
||||||
final tapToUnblur = settings.tapToUnblur;
|
final tapToUnblur = settings.tapToUnblur;
|
||||||
final enableTextSelection = settings.enableTextSelection;
|
final enableTextSelection = settings.enableTextSelection;
|
||||||
final hideSponsoredPosts = settings.hideSponsoredPosts;
|
|
||||||
|
// Per request: remove ALL “Hide Suggested Posts” behavior/UI/JS injection.
|
||||||
|
final hideSuggestedPosts = false;
|
||||||
final hideLikeCounts = settings.hideLikeCounts;
|
final hideLikeCounts = settings.hideLikeCounts;
|
||||||
final hideFollowerCounts = settings.hideFollowerCounts;
|
final hideFollowerCounts = settings.hideFollowerCounts;
|
||||||
// Stories hiding functionality removed per user request
|
|
||||||
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
|
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
|
||||||
// These are now only controllable via minimal mode submenu
|
// These are now only controllable via minimal mode submenu
|
||||||
final disableExploreEntirely = settings.disableExploreEntirely;
|
final disableExploreEntirely = settings.disableExploreEntirely;
|
||||||
final disableReelsEntirely = settings.disableReelsEntirely;
|
final disableReelsEntirely = settings.disableReelsEntirely;
|
||||||
|
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
||||||
final hideExploreTab = disableExploreEntirely;
|
final hideExploreTab = disableExploreEntirely;
|
||||||
final hideReelsTab = disableReelsEntirely;
|
final hideReelsTab = disableReelsEntirely;
|
||||||
final hideShopTab = settings.hideShopTab;
|
final hideShopTab = settings.hideShopTab;
|
||||||
final isGrayscaleActive = settings.isGrayscaleActiveNow;
|
|
||||||
|
|
||||||
final injectionJS = InjectionController.buildInjectionJS(
|
final injectionJS = InjectionController.buildInjectionJS(
|
||||||
sessionActive: sessionActive,
|
sessionActive: sessionActive,
|
||||||
@@ -434,33 +457,35 @@ class InjectionManager {
|
|||||||
blurReels: false, // Blur reels feature removed
|
blurReels: false, // Blur reels feature removed
|
||||||
tapToUnblur: blurExplore && tapToUnblur,
|
tapToUnblur: blurExplore && tapToUnblur,
|
||||||
enableTextSelection: enableTextSelection,
|
enableTextSelection: enableTextSelection,
|
||||||
hideSuggestedPosts: false, // Feature removed
|
hideSuggestedPosts: hideSuggestedPosts,
|
||||||
hideSponsoredPosts: hideSponsoredPosts,
|
hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
|
||||||
hideLikeCounts: hideLikeCounts,
|
hideLikeCounts: hideLikeCounts,
|
||||||
hideFollowerCounts: hideFollowerCounts,
|
hideFollowerCounts: hideFollowerCounts,
|
||||||
// hideStoriesBar removed per user request
|
|
||||||
hideExploreTab: hideExploreTab,
|
hideExploreTab: hideExploreTab,
|
||||||
hideReelsTab: hideReelsTab,
|
hideReelsTab: hideReelsTab,
|
||||||
hideShopTab: hideShopTab,
|
hideShopTab: hideShopTab,
|
||||||
disableReelsEntirely: disableReelsEntirely,
|
disableReelsEntirely: disableReelsEntirely,
|
||||||
|
blockHomeFeedScroll: blockHomeFeedScroll,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await controller.evaluateJavascript(source: injectionJS);
|
await _jsEvaluator.evaluateJavascript(source: injectionJS);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject grayscale when active, remove when not active
|
// Inject grayscale when active, remove when not active
|
||||||
if (isGrayscaleActive) {
|
if (settings.isGrayscaleActiveNow) {
|
||||||
try {
|
try {
|
||||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
|
await _jsEvaluator.evaluateJavascript(
|
||||||
|
source: grayscale.kGrayscaleOffJS,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
}
|
}
|
||||||
@@ -469,7 +494,9 @@ class InjectionManager {
|
|||||||
// Inject hide like counts JS when enabled
|
// Inject hide like counts JS when enabled
|
||||||
if (hideLikeCounts) {
|
if (hideLikeCounts) {
|
||||||
try {
|
try {
|
||||||
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
|
await _jsEvaluator.evaluateJavascript(
|
||||||
|
source: ui_hider.kHideLikeCountsJS,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
}
|
}
|
||||||
@@ -478,11 +505,11 @@ class InjectionManager {
|
|||||||
// Stories hiding functionality removed per user request
|
// Stories hiding functionality removed per user request
|
||||||
// No stories overlay injection needed
|
// No stories overlay injection needed
|
||||||
|
|
||||||
// Inject hide sponsored posts JS when enabled
|
// Inject video downloader UI when enabled
|
||||||
if (hideSponsoredPosts) {
|
if (settings.videoDownloadEnabled) {
|
||||||
try {
|
try {
|
||||||
await controller.evaluateJavascript(
|
await _jsEvaluator.evaluateJavascript(
|
||||||
source: ui_hider.kHideSponsoredPostsJS,
|
source: video_downloader.kVideoDownloadJS,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
@@ -492,7 +519,7 @@ class InjectionManager {
|
|||||||
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
||||||
if (disableReelsEntirely) {
|
if (disableReelsEntirely) {
|
||||||
try {
|
try {
|
||||||
await controller.evaluateJavascript(
|
await _jsEvaluator.evaluateJavascript(
|
||||||
source: content_disabling.kDmReelBlockerJS,
|
source: content_disabling.kDmReelBlockerJS,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ class NotificationService {
|
|||||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init({bool requestPermissions = false}) async {
|
||||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
|
|
||||||
// Request permissions for iOS
|
// Request permissions for iOS
|
||||||
final DarwinInitializationSettings initializationSettingsIOS =
|
final DarwinInitializationSettings initializationSettingsIOS =
|
||||||
DarwinInitializationSettings(
|
DarwinInitializationSettings(
|
||||||
requestAlertPermission: true,
|
requestAlertPermission: requestPermissions,
|
||||||
requestBadgePermission: true,
|
requestBadgePermission: requestPermissions,
|
||||||
requestSoundPermission: true,
|
requestSoundPermission: requestPermissions,
|
||||||
defaultPresentAlert: true,
|
defaultPresentAlert: true,
|
||||||
defaultPresentBadge: true,
|
defaultPresentBadge: true,
|
||||||
defaultPresentSound: true,
|
defaultPresentSound: true,
|
||||||
@@ -37,7 +37,12 @@ class NotificationService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Request permissions after initialization
|
if (requestPermissions) {
|
||||||
|
await requestPermissionsNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> requestPermissionsNow() async {
|
||||||
await _requestIOSPermissions();
|
await _requestIOSPermissions();
|
||||||
await _requestAndroidPermissions();
|
await _requestAndroidPermissions();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
///
|
///
|
||||||
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
||||||
/// {
|
/// {
|
||||||
/// "2026-02-26": 3420, // seconds
|
/// "2026-05-26": 3420, // seconds
|
||||||
/// "2026-02-25": 1800
|
/// "2026-05-25": 1800
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// All data stays on-device only.
|
/// All data stays on-device only.
|
||||||
@@ -22,6 +22,8 @@ class ScreenTimeService extends ChangeNotifier {
|
|||||||
bool _tracking = false;
|
bool _tracking = false;
|
||||||
|
|
||||||
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
||||||
|
int get totalSeconds =>
|
||||||
|
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
_prefs = await SharedPreferences.getInstance();
|
_prefs = await SharedPreferences.getInstance();
|
||||||
@@ -37,9 +39,7 @@ class ScreenTimeService extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(raw);
|
final decoded = jsonDecode(raw);
|
||||||
if (decoded is Map<String, dynamic>) {
|
if (decoded is Map<String, dynamic>) {
|
||||||
_secondsByDate = decoded.map(
|
_secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
|
||||||
(k, v) => MapEntry(k, (v as num).toInt()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_secondsByDate = {};
|
_secondsByDate = {};
|
||||||
@@ -104,4 +104,3 @@ class ScreenTimeService extends ChangeNotifier {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class SessionManager extends ChangeNotifier {
|
|||||||
static const _keyAppSessionEnd = 'app_sess_end_ts';
|
static const _keyAppSessionEnd = 'app_sess_end_ts';
|
||||||
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
|
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
|
||||||
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
|
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
|
||||||
|
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
|
||||||
static const _keyDailyOpenCount = 'app_open_count';
|
static const _keyDailyOpenCount = 'app_open_count';
|
||||||
static const _keyScheduleEnabled = 'sched_enabled';
|
static const _keyScheduleEnabled = 'sched_enabled';
|
||||||
static const _keyScheduleStartHour = 'sched_start_h';
|
static const _keyScheduleStartHour = 'sched_start_h';
|
||||||
@@ -81,6 +82,7 @@ class SessionManager extends ChangeNotifier {
|
|||||||
bool _appSessionExpiredFlag =
|
bool _appSessionExpiredFlag =
|
||||||
false; // set when time runs out, waiting for user action
|
false; // set when time runs out, waiting for user action
|
||||||
int _dailyOpenCount = 0;
|
int _dailyOpenCount = 0;
|
||||||
|
int _lastAppSessionMinutes = 5;
|
||||||
|
|
||||||
// ── Scheduled Blocking runtime ─────────────────────────────
|
// ── Scheduled Blocking runtime ─────────────────────────────
|
||||||
bool _scheduleEnabled = false;
|
bool _scheduleEnabled = false;
|
||||||
@@ -90,8 +92,10 @@ class SessionManager extends ChangeNotifier {
|
|||||||
int _schedEndMin = 0;
|
int _schedEndMin = 0;
|
||||||
List<FocusSchedule> _schedules = [];
|
List<FocusSchedule> _schedules = [];
|
||||||
bool _lastScheduleState = false;
|
bool _lastScheduleState = false;
|
||||||
bool _scheduleNotificationShown = false; // Track if schedule notification was shown
|
bool _scheduleNotificationShown =
|
||||||
bool _sessionEndNotificationShown = true; // Default to true to prevent notification on app startup (will be reset when new session starts)
|
false; // Track if schedule notification was shown
|
||||||
|
bool _sessionEndNotificationShown =
|
||||||
|
true; // Default to true to prevent notification on app startup (will be reset when new session starts)
|
||||||
|
|
||||||
bool _isInForeground = true; // Tracking app lifecycle state
|
bool _isInForeground = true; // Tracking app lifecycle state
|
||||||
int _cachedRemainingSessionSeconds = 0;
|
int _cachedRemainingSessionSeconds = 0;
|
||||||
@@ -175,6 +179,7 @@ class SessionManager extends ChangeNotifier {
|
|||||||
|
|
||||||
/// How many times the user has opened the app today.
|
/// How many times the user has opened the app today.
|
||||||
int get dailyOpenCount => _dailyOpenCount;
|
int get dailyOpenCount => _dailyOpenCount;
|
||||||
|
int get lastAppSessionMinutes => _lastAppSessionMinutes;
|
||||||
|
|
||||||
// ── Scheduled Blocking Getters ─────────────────────────────
|
// ── Scheduled Blocking Getters ─────────────────────────────
|
||||||
bool get scheduleEnabled => _scheduleEnabled;
|
bool get scheduleEnabled => _scheduleEnabled;
|
||||||
@@ -309,6 +314,7 @@ class SessionManager extends ChangeNotifier {
|
|||||||
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
|
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
|
||||||
}
|
}
|
||||||
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
|
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
|
||||||
|
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
|
||||||
|
|
||||||
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
|
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
|
||||||
if (lastAppEndMs > 0) {
|
if (lastAppEndMs > 0) {
|
||||||
@@ -375,12 +381,12 @@ class SessionManager extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// App session expiry check
|
// App session countdown / expiry check
|
||||||
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
|
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
|
||||||
if (DateTime.now().isAfter(_appSessionEnd!)) {
|
if (DateTime.now().isAfter(_appSessionEnd!)) {
|
||||||
_appSessionExpiredFlag = true;
|
_appSessionExpiredFlag = true;
|
||||||
changed = true;
|
|
||||||
}
|
}
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCooldownActive) {
|
if (isCooldownActive) {
|
||||||
@@ -422,7 +428,8 @@ class SessionManager extends ChangeNotifier {
|
|||||||
_sessionEndNotificationShown = true;
|
_sessionEndNotificationShown = true;
|
||||||
|
|
||||||
// Check if user wants session end notifications
|
// Check if user wants session end notifications
|
||||||
final notifySessionEnd = _prefs?.getBool('set_notify_session_end') ?? false;
|
final notifySessionEnd =
|
||||||
|
_prefs?.getBool('set_notify_session_end') ?? false;
|
||||||
|
|
||||||
if (notifySessionEnd) {
|
if (notifySessionEnd) {
|
||||||
NotificationService().showNotification(
|
NotificationService().showNotification(
|
||||||
@@ -448,7 +455,8 @@ class SessionManager extends ChangeNotifier {
|
|||||||
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
|
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
|
||||||
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
|
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
|
||||||
_isSessionActive = true;
|
_isSessionActive = true;
|
||||||
_sessionEndNotificationShown = false; // Reset notification flag for new session
|
_sessionEndNotificationShown =
|
||||||
|
false; // Reset notification flag for new session
|
||||||
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
|
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
@@ -482,8 +490,10 @@ class SessionManager extends ChangeNotifier {
|
|||||||
_appSessionEnd = end;
|
_appSessionEnd = end;
|
||||||
_appSessionExpiredFlag = false;
|
_appSessionExpiredFlag = false;
|
||||||
_appExtensionUsed = false;
|
_appExtensionUsed = false;
|
||||||
|
_lastAppSessionMinutes = minutes;
|
||||||
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
|
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
|
||||||
_prefs?.setBool(_keyAppSessionExtUsed, false);
|
_prefs?.setBool(_keyAppSessionExtUsed, false);
|
||||||
|
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'notification_service.dart';
|
||||||
|
|
||||||
/// Stores and retrieves all user-configurable app settings.
|
/// Stores and retrieves all user-configurable app settings.
|
||||||
class SettingsService extends ChangeNotifier {
|
class SettingsService extends ChangeNotifier {
|
||||||
static const _keyBlurExplore = 'set_blur_explore';
|
static const _keyBlurExplore = 'set_blur_explore';
|
||||||
static const _keyBlurReels = 'set_blur_reels';
|
static const _keyBlurReels = 'set_blur_reels';
|
||||||
static const _keyTapToUnblur = 'set_tap_to_unblur';
|
static const _keyTapToUnblur = 'set_tap_to_unblur';
|
||||||
static const _keyRequireLongPress = 'set_require_long_press';
|
|
||||||
static const _keyShowBreathGate = 'set_show_breath_gate';
|
static const _keyShowBreathGate = 'set_show_breath_gate';
|
||||||
static const _keyRequireWordChallenge = 'set_require_word_challenge';
|
static const _keyRequireWordChallenge = 'set_require_word_challenge';
|
||||||
|
static const _keyRequireLongPress = 'set_require_long_press';
|
||||||
|
static const _keyBreathGateSeconds = 'breath_gate_seconds';
|
||||||
|
static const _keyWordChallengeCount = 'word_challenge_count';
|
||||||
static const _keyEnableTextSelection = 'set_enable_text_selection';
|
static const _keyEnableTextSelection = 'set_enable_text_selection';
|
||||||
static const _keyEnabledTabs = 'set_enabled_tabs';
|
static const _keyEnabledTabs = 'set_enabled_tabs';
|
||||||
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
||||||
@@ -18,12 +22,26 @@ class SettingsService extends ChangeNotifier {
|
|||||||
// Focus / playback
|
// Focus / playback
|
||||||
static const _keyBlockAutoplay = 'block_autoplay';
|
static const _keyBlockAutoplay = 'block_autoplay';
|
||||||
|
|
||||||
|
// Extras (Phase 2)
|
||||||
|
static const _keyVideoDownloadEnabled = 'video_download_enabled';
|
||||||
|
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
|
||||||
|
|
||||||
|
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||||
|
static const _keyV2GhostModeEnabled = 'v2_ghost_mode_enabled';
|
||||||
|
static const _keyV2AdBlockerDomEnabled = 'v2_adblock_dom_enabled';
|
||||||
|
static const _keyV2ContentHiderEnabled = 'v2_content_hider_enabled';
|
||||||
|
|
||||||
|
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
|
||||||
|
static const _keyContentStories = 'content_stories';
|
||||||
|
static const _keyContentPosts = 'content_posts';
|
||||||
|
static const _keyContentReels = 'content_reels';
|
||||||
|
static const _keyContentSuggested = 'content_suggested';
|
||||||
|
|
||||||
// Grayscale mode - now supports multiple schedules
|
// Grayscale mode - now supports multiple schedules
|
||||||
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
||||||
static const _keyGrayscaleSchedules = 'grayscale_schedules';
|
static const _keyGrayscaleSchedules = 'grayscale_schedules';
|
||||||
|
|
||||||
// Content filtering / UI hiding
|
// Content filtering / UI hiding
|
||||||
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
|
|
||||||
static const _keyHideLikeCounts = 'hide_like_counts';
|
static const _keyHideLikeCounts = 'hide_like_counts';
|
||||||
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
||||||
static const _keyHideShopTab = 'hide_shop_tab';
|
static const _keyHideShopTab = 'hide_shop_tab';
|
||||||
@@ -32,9 +50,14 @@ class SettingsService extends ChangeNotifier {
|
|||||||
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
||||||
|
|
||||||
// Minimal mode state tracking for smart restore
|
// Minimal mode state tracking for smart restore
|
||||||
static const _keyMinimalModePrevDisableReels = 'minimal_mode_prev_disable_reels';
|
static const _keyMinimalModePrevDisableReels =
|
||||||
static const _keyMinimalModePrevDisableExplore = 'minimal_mode_prev_disable_explore';
|
'minimal_mode_prev_disable_reels';
|
||||||
static const _keyMinimalModePrevBlurExplore = 'minimal_mode_prev_blur_explore';
|
static const _keyMinimalModePrevDisableExplore =
|
||||||
|
'minimal_mode_prev_disable_explore';
|
||||||
|
static const _keyMinimalModePrevBlurExplore =
|
||||||
|
'minimal_mode_prev_blur_explore';
|
||||||
|
static const _keyMinimalModePrevBlockHomeFeedScroll =
|
||||||
|
'minimal_mode_prev_block_home_feed_scroll';
|
||||||
|
|
||||||
// Reels History
|
// Reels History
|
||||||
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
||||||
@@ -46,6 +69,14 @@ class SettingsService extends ChangeNotifier {
|
|||||||
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
||||||
static const _keyNotifyPersistent = 'set_notify_persistent';
|
static const _keyNotifyPersistent = 'set_notify_persistent';
|
||||||
|
|
||||||
|
// Focus mode settings
|
||||||
|
static const _keyGhostMode = 'ghost_mode';
|
||||||
|
static const _keyNoAds = 'no_ads';
|
||||||
|
static const _keyNoStories = 'no_stories';
|
||||||
|
static const _keyNoReels = 'no_reels';
|
||||||
|
static const _keyNoAutoplay = 'no_autoplay';
|
||||||
|
static const _keyNoDMs = 'no_dms';
|
||||||
|
|
||||||
SharedPreferences? _prefs;
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
bool _blurExplore = true;
|
bool _blurExplore = true;
|
||||||
@@ -54,19 +85,33 @@ class SettingsService extends ChangeNotifier {
|
|||||||
bool _requireLongPress = true;
|
bool _requireLongPress = true;
|
||||||
bool _showBreathGate = true;
|
bool _showBreathGate = true;
|
||||||
bool _requireWordChallenge = true;
|
bool _requireWordChallenge = true;
|
||||||
|
int _breathGateSeconds = 10;
|
||||||
|
int _wordChallengeCount = 30;
|
||||||
bool _enableTextSelection = false;
|
bool _enableTextSelection = false;
|
||||||
bool _showInstaSettings = true;
|
bool _showInstaSettings = true;
|
||||||
bool _isDarkMode = true; // Default to dark as per existing app theme
|
bool _isDarkMode = true; // Default to dark as per existing app theme
|
||||||
|
|
||||||
bool _blockAutoplay = true;
|
bool _blockAutoplay = true;
|
||||||
|
|
||||||
bool _grayscaleEnabled = false;
|
bool _videoDownloadEnabled = false;
|
||||||
|
bool _hideSuggestedPosts = false;
|
||||||
|
|
||||||
// Grayscale schedules - list of {enabled, startTime, endTime}
|
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||||
// startTime and endTime are in format "HH:MM"
|
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;
|
||||||
List<Map<String, dynamic>> _grayscaleSchedules = [];
|
List<Map<String, dynamic>> _grayscaleSchedules = [];
|
||||||
|
|
||||||
bool _hideSponsoredPosts = false;
|
// Content filtering / UI hiding
|
||||||
bool _hideLikeCounts = false;
|
bool _hideLikeCounts = false;
|
||||||
bool _hideFollowerCounts = false;
|
bool _hideFollowerCounts = false;
|
||||||
bool _hideShopTab = false;
|
bool _hideShopTab = false;
|
||||||
@@ -74,12 +119,14 @@ class SettingsService extends ChangeNotifier {
|
|||||||
// These are now controlled internally by minimal mode
|
// These are now controlled internally by minimal mode
|
||||||
bool _disableReelsEntirely = false;
|
bool _disableReelsEntirely = false;
|
||||||
bool _disableExploreEntirely = false;
|
bool _disableExploreEntirely = false;
|
||||||
|
bool _blockHomeFeedScroll = false;
|
||||||
bool _minimalModeEnabled = false;
|
bool _minimalModeEnabled = false;
|
||||||
|
|
||||||
// Tracking for smart restore
|
// Tracking for smart restore
|
||||||
bool _prevDisableReels = false;
|
bool _prevDisableReels = false;
|
||||||
bool _prevDisableExplore = false;
|
bool _prevDisableExplore = false;
|
||||||
bool _prevBlurExplore = false;
|
bool _prevBlurExplore = false;
|
||||||
|
bool _prevBlockHomeFeedScroll = false;
|
||||||
|
|
||||||
bool _reelsHistoryEnabled = true;
|
bool _reelsHistoryEnabled = true;
|
||||||
|
|
||||||
@@ -90,6 +137,14 @@ class SettingsService extends ChangeNotifier {
|
|||||||
bool _notifySessionEnd = false;
|
bool _notifySessionEnd = false;
|
||||||
bool _notifyPersistent = false;
|
bool _notifyPersistent = false;
|
||||||
|
|
||||||
|
// Focus mode settings
|
||||||
|
bool _ghostMode = false;
|
||||||
|
bool _noAds = false;
|
||||||
|
bool _noStories = false;
|
||||||
|
bool _noReels = false;
|
||||||
|
bool _noAutoplay = false;
|
||||||
|
bool _noDMs = false;
|
||||||
|
|
||||||
List<String> _enabledTabs = [
|
List<String> _enabledTabs = [
|
||||||
'Home',
|
'Home',
|
||||||
'Search',
|
'Search',
|
||||||
@@ -105,12 +160,28 @@ class SettingsService extends ChangeNotifier {
|
|||||||
bool get requireLongPress => _requireLongPress;
|
bool get requireLongPress => _requireLongPress;
|
||||||
bool get showBreathGate => _showBreathGate;
|
bool get showBreathGate => _showBreathGate;
|
||||||
bool get requireWordChallenge => _requireWordChallenge;
|
bool get requireWordChallenge => _requireWordChallenge;
|
||||||
|
int get breathGateSeconds => _breathGateSeconds;
|
||||||
|
int get wordChallengeCount => _wordChallengeCount;
|
||||||
bool get enableTextSelection => _enableTextSelection;
|
bool get enableTextSelection => _enableTextSelection;
|
||||||
bool get showInstaSettings => _showInstaSettings;
|
bool get showInstaSettings => _showInstaSettings;
|
||||||
List<String> get enabledTabs => _enabledTabs;
|
List<String> get enabledTabs => _enabledTabs;
|
||||||
bool get isFirstRun => _isFirstRun;
|
bool get isFirstRun => _isFirstRun;
|
||||||
bool get isDarkMode => _isDarkMode;
|
bool get isDarkMode => _isDarkMode;
|
||||||
bool get blockAutoplay => _blockAutoplay;
|
bool get blockAutoplay => _blockAutoplay;
|
||||||
|
|
||||||
|
// Extras (Phase 2)
|
||||||
|
bool get videoDownloadEnabled => _videoDownloadEnabled;
|
||||||
|
bool get hideSuggestedPosts => _hideSuggestedPosts;
|
||||||
|
|
||||||
|
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||||
|
bool get v2GhostModeEnabled => _v2GhostModeEnabled;
|
||||||
|
bool get v2AdBlockerDomEnabled => _v2AdBlockerDomEnabled;
|
||||||
|
bool get v2ContentHiderEnabled => _v2ContentHiderEnabled;
|
||||||
|
|
||||||
|
bool get contentStories => _contentStories;
|
||||||
|
bool get contentPosts => _contentPosts;
|
||||||
|
bool get contentReels => _contentReels;
|
||||||
|
bool get contentSuggested => _contentSuggested;
|
||||||
bool get notifyDMs => _notifyDMs;
|
bool get notifyDMs => _notifyDMs;
|
||||||
bool get notifyActivity => _notifyActivity;
|
bool get notifyActivity => _notifyActivity;
|
||||||
bool get notifySessionEnd => _notifySessionEnd;
|
bool get notifySessionEnd => _notifySessionEnd;
|
||||||
@@ -119,14 +190,22 @@ class SettingsService extends ChangeNotifier {
|
|||||||
bool get grayscaleEnabled => _grayscaleEnabled;
|
bool get grayscaleEnabled => _grayscaleEnabled;
|
||||||
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
|
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
|
||||||
|
|
||||||
bool get hideSponsoredPosts => _hideSponsoredPosts;
|
|
||||||
bool get hideLikeCounts => _hideLikeCounts;
|
bool get hideLikeCounts => _hideLikeCounts;
|
||||||
bool get hideFollowerCounts => _hideFollowerCounts;
|
bool get hideFollowerCounts => _hideFollowerCounts;
|
||||||
bool get hideShopTab => _hideShopTab;
|
bool get hideShopTab => _hideShopTab;
|
||||||
|
|
||||||
|
// Focus mode settings
|
||||||
|
bool get ghostMode => _ghostMode;
|
||||||
|
bool get noAds => _noAds;
|
||||||
|
bool get noStories => _noStories;
|
||||||
|
bool get noReels => _noReels;
|
||||||
|
bool get noAutoplay => _noAutoplay;
|
||||||
|
bool get noDMs => _noDMs;
|
||||||
|
|
||||||
// These are now controlled by minimal mode only
|
// These are now controlled by minimal mode only
|
||||||
bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely;
|
bool get disableReelsEntirely => _disableReelsEntirely;
|
||||||
bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely;
|
bool get disableExploreEntirely => _disableExploreEntirely;
|
||||||
|
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
|
||||||
bool get minimalModeEnabled => _minimalModeEnabled;
|
bool get minimalModeEnabled => _minimalModeEnabled;
|
||||||
|
|
||||||
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
||||||
@@ -149,7 +228,8 @@ class SettingsService extends ChangeNotifier {
|
|||||||
|
|
||||||
if (startParts.length != 2 || endParts.length != 2) continue;
|
if (startParts.length != 2 || endParts.length != 2) continue;
|
||||||
|
|
||||||
final startMinutes = int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
|
final startMinutes =
|
||||||
|
int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
|
||||||
final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
|
final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
|
||||||
|
|
||||||
// Handle overnight schedules (e.g., 21:00 to 06:00)
|
// Handle overnight schedules (e.g., 21:00 to 06:00)
|
||||||
@@ -182,25 +262,46 @@ class SettingsService extends ChangeNotifier {
|
|||||||
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
||||||
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
|
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
|
||||||
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
||||||
|
_breathGateSeconds = (_prefs!.getInt(_keyBreathGateSeconds) ?? 10)
|
||||||
|
.clamp(3, 60)
|
||||||
|
.toInt();
|
||||||
|
_wordChallengeCount = _normaliseWordChallengeCount(
|
||||||
|
_prefs!.getInt(_keyWordChallengeCount) ?? 30,
|
||||||
|
);
|
||||||
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
||||||
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
|
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
|
||||||
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
|
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
|
||||||
|
|
||||||
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
|
// Extras (Phase 2) - defaults OFF for safety/non-invasive behavior
|
||||||
|
_videoDownloadEnabled = _prefs!.getBool(_keyVideoDownloadEnabled) ?? false;
|
||||||
|
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
||||||
|
|
||||||
|
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||||
|
_v2GhostModeEnabled = _prefs!.getBool(_keyV2GhostModeEnabled) ?? false;
|
||||||
|
_v2AdBlockerDomEnabled =
|
||||||
|
_prefs!.getBool(_keyV2AdBlockerDomEnabled) ?? false;
|
||||||
|
_v2ContentHiderEnabled =
|
||||||
|
_prefs!.getBool(_keyV2ContentHiderEnabled) ?? false;
|
||||||
|
|
||||||
|
_contentStories = _prefs!.getBool(_keyContentStories) ?? false;
|
||||||
|
_contentPosts = _prefs!.getBool(_keyContentPosts) ?? false;
|
||||||
|
_contentReels = _prefs!.getBool(_keyContentReels) ?? false;
|
||||||
|
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
|
||||||
|
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
||||||
|
|
||||||
// Load grayscale schedules
|
// Load grayscale schedules
|
||||||
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
|
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
|
||||||
if (schedulesJson != null) {
|
if (schedulesJson != null) {
|
||||||
try {
|
try {
|
||||||
_grayscaleSchedules = List<Map<String, dynamic>>.from(
|
_grayscaleSchedules = List<Map<String, dynamic>>.from(
|
||||||
(jsonDecode(schedulesJson) as List).map((e) => Map<String, dynamic>.from(e))
|
(jsonDecode(schedulesJson) as List).map(
|
||||||
|
(e) => Map<String, dynamic>.from(e),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_grayscaleSchedules = [];
|
_grayscaleSchedules = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
|
|
||||||
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
||||||
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
||||||
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
||||||
@@ -209,16 +310,32 @@ class SettingsService extends ChangeNotifier {
|
|||||||
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
||||||
|
|
||||||
// Load previous states for smart restore
|
// Load previous states for smart restore
|
||||||
_prevDisableReels = _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
_prevDisableReels =
|
||||||
_prevDisableExplore = _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
_prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
||||||
|
_prevDisableExplore =
|
||||||
|
_prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
||||||
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
|
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
|
||||||
|
_prevBlockHomeFeedScroll =
|
||||||
|
_prefs!.getBool(_keyMinimalModePrevBlockHomeFeedScroll) ?? false;
|
||||||
|
|
||||||
// These are now internal states, not user-facing settings
|
// These are now internal states, not user-facing settings
|
||||||
_disableReelsEntirely = _prefs!.getBool('internal_disable_reels_entirely') ?? false;
|
_disableReelsEntirely =
|
||||||
_disableExploreEntirely = _prefs!.getBool('internal_disable_explore_entirely') ?? false;
|
_prefs!.getBool('internal_disable_reels_entirely') ?? false;
|
||||||
|
_disableExploreEntirely =
|
||||||
|
_prefs!.getBool('internal_disable_explore_entirely') ?? false;
|
||||||
|
_blockHomeFeedScroll =
|
||||||
|
_prefs!.getBool('internal_block_home_feed_scroll') ?? false;
|
||||||
|
|
||||||
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
|
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
|
||||||
|
|
||||||
|
// Focus mode settings
|
||||||
|
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
|
||||||
|
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
|
||||||
|
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
|
||||||
|
_noReels = _prefs!.getBool(_keyNoReels) ?? false;
|
||||||
|
_noAutoplay = _prefs!.getBool(_keyNoAutoplay) ?? false;
|
||||||
|
_noDMs = _prefs!.getBool(_keyNoDMs) ?? false;
|
||||||
|
|
||||||
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
|
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
|
||||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
||||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
||||||
@@ -245,12 +362,12 @@ class SettingsService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> setBlurExplore(bool v) async {
|
Future<void> setBlurExplore(bool v) async {
|
||||||
_blurExplore = v;
|
_blurExplore = v;
|
||||||
// Sync blur explore with blur reels - enabling one enables the other
|
|
||||||
if (v && !_blurReels) {
|
|
||||||
_blurReels = true;
|
|
||||||
await _prefs?.setBool(_keyBlurReels, true);
|
|
||||||
}
|
|
||||||
await _prefs?.setBool(_keyBlurExplore, v);
|
await _prefs?.setBool(_keyBlurExplore, v);
|
||||||
|
|
||||||
|
if (_minimalModeEnabled) {
|
||||||
|
await _checkAndAutoDisableMinimalMode();
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +406,30 @@ class SettingsService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setBreathGateSeconds(int seconds) async {
|
||||||
|
_breathGateSeconds = seconds.clamp(3, 60).toInt();
|
||||||
|
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setWordChallengeCount(int count) async {
|
||||||
|
_wordChallengeCount = _normaliseWordChallengeCount(count);
|
||||||
|
await _prefs?.setInt(_keyWordChallengeCount, _wordChallengeCount);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
int resolvedWordChallengeCount() {
|
||||||
|
if (_wordChallengeCount != 0) return _wordChallengeCount;
|
||||||
|
final now = DateTime.now().microsecondsSinceEpoch;
|
||||||
|
return 10 + (now % 26);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _normaliseWordChallengeCount(int count) {
|
||||||
|
if (count == 0) return 0;
|
||||||
|
const allowed = [20, 25, 30, 35];
|
||||||
|
return allowed.contains(count) ? count : 30;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setEnableTextSelection(bool v) async {
|
Future<void> setEnableTextSelection(bool v) async {
|
||||||
_enableTextSelection = v;
|
_enableTextSelection = v;
|
||||||
await _prefs?.setBool(_keyEnableTextSelection, v);
|
await _prefs?.setBool(_keyEnableTextSelection, v);
|
||||||
@@ -307,13 +448,29 @@ class SettingsService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Extras (Phase 2) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> setVideoDownloadEnabled(bool v) async {
|
||||||
|
_videoDownloadEnabled = v;
|
||||||
|
await _prefs?.setBool(_keyVideoDownloadEnabled, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setHideSuggestedPosts(bool v) async {
|
||||||
|
_hideSuggestedPosts = v;
|
||||||
|
await _prefs?.setBool(_keyHideSuggestedPosts, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setGrayscaleEnabled(bool v) async {
|
Future<void> setGrayscaleEnabled(bool v) async {
|
||||||
_grayscaleEnabled = v;
|
_grayscaleEnabled = v;
|
||||||
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setGrayscaleSchedules(List<Map<String, dynamic>> schedules) async {
|
Future<void> setGrayscaleSchedules(
|
||||||
|
List<Map<String, dynamic>> schedules,
|
||||||
|
) async {
|
||||||
_grayscaleSchedules = schedules;
|
_grayscaleSchedules = schedules;
|
||||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
|
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -321,14 +478,23 @@ class SettingsService extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
|
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
|
||||||
_grayscaleSchedules.add(schedule);
|
_grayscaleSchedules.add(schedule);
|
||||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
await _prefs?.setString(
|
||||||
|
_keyGrayscaleSchedules,
|
||||||
|
jsonEncode(_grayscaleSchedules),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateGrayscaleSchedule(int index, Map<String, dynamic> schedule) async {
|
Future<void> updateGrayscaleSchedule(
|
||||||
|
int index,
|
||||||
|
Map<String, dynamic> schedule,
|
||||||
|
) async {
|
||||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
if (index >= 0 && index < _grayscaleSchedules.length) {
|
||||||
_grayscaleSchedules[index] = schedule;
|
_grayscaleSchedules[index] = schedule;
|
||||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
await _prefs?.setString(
|
||||||
|
_keyGrayscaleSchedules,
|
||||||
|
jsonEncode(_grayscaleSchedules),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,20 +502,76 @@ class SettingsService extends ChangeNotifier {
|
|||||||
Future<void> removeGrayscaleSchedule(int index) async {
|
Future<void> removeGrayscaleSchedule(int index) async {
|
||||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
if (index >= 0 && index < _grayscaleSchedules.length) {
|
||||||
_grayscaleSchedules.removeAt(index);
|
_grayscaleSchedules.removeAt(index);
|
||||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
await _prefs?.setString(
|
||||||
|
_keyGrayscaleSchedules,
|
||||||
|
jsonEncode(_grayscaleSchedules),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setHideSponsoredPosts(bool v) async {
|
Future<void> setHideShopTab(bool v) async {
|
||||||
_hideSponsoredPosts = v;
|
_hideShopTab = v;
|
||||||
await _prefs?.setBool(_keyHideSponsoredPosts, v);
|
await _prefs?.setBool(_keyHideShopTab, v);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setHideLikeCounts(bool v) async {
|
// ── FocusGram v2 overlay setters ──────────────────────────────────────────
|
||||||
_hideLikeCounts = v;
|
Future<void> setV2GhostModeEnabled(bool v) async {
|
||||||
await _prefs?.setBool(_keyHideLikeCounts, v);
|
_v2GhostModeEnabled = v;
|
||||||
|
await _prefs?.setBool(_keyV2GhostModeEnabled, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setV2AdBlockerDomEnabled(bool v) async {
|
||||||
|
_v2AdBlockerDomEnabled = v;
|
||||||
|
await _prefs?.setBool(_keyV2AdBlockerDomEnabled, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setV2ContentHiderEnabled(bool v) async {
|
||||||
|
_v2ContentHiderEnabled = v;
|
||||||
|
await _prefs?.setBool(_keyV2ContentHiderEnabled, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setContentStoriesEnabled(bool v) async {
|
||||||
|
if (v && !_v2ContentHiderEnabled) {
|
||||||
|
_v2ContentHiderEnabled = true;
|
||||||
|
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||||
|
}
|
||||||
|
_contentStories = v;
|
||||||
|
await _prefs?.setBool(_keyContentStories, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setContentPostsEnabled(bool v) async {
|
||||||
|
if (v && !_v2ContentHiderEnabled) {
|
||||||
|
_v2ContentHiderEnabled = true;
|
||||||
|
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||||
|
}
|
||||||
|
_contentPosts = v;
|
||||||
|
await _prefs?.setBool(_keyContentPosts, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setContentReelsEnabled(bool v) async {
|
||||||
|
if (v && !_v2ContentHiderEnabled) {
|
||||||
|
_v2ContentHiderEnabled = true;
|
||||||
|
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||||
|
}
|
||||||
|
_contentReels = v;
|
||||||
|
await _prefs?.setBool(_keyContentReels, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setContentSuggestedEnabled(bool v) async {
|
||||||
|
if (v && !_v2ContentHiderEnabled) {
|
||||||
|
_v2ContentHiderEnabled = true;
|
||||||
|
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||||
|
}
|
||||||
|
_contentSuggested = v;
|
||||||
|
await _prefs?.setBool(_keyContentSuggested, v);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,62 +581,138 @@ class SettingsService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setHideShopTab(bool v) async {
|
|
||||||
_hideShopTab = v;
|
|
||||||
await _prefs?.setBool(_keyHideShopTab, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Setter for internal disable reels state (used by minimal mode submenu)
|
/// Setter for internal disable reels state (used by minimal mode submenu)
|
||||||
|
/// Auto-disables minimal mode if all features are turned off
|
||||||
Future<void> setDisableReelsEntirelyInternal(bool v) async {
|
Future<void> setDisableReelsEntirelyInternal(bool v) async {
|
||||||
_disableReelsEntirely = v;
|
_disableReelsEntirely = v;
|
||||||
await _prefs?.setBool('internal_disable_reels_entirely', v);
|
await _prefs?.setBool('internal_disable_reels_entirely', v);
|
||||||
|
|
||||||
|
// Check if minimal mode should auto-disable
|
||||||
|
await _checkAndAutoDisableMinimalMode();
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setter for internal disable explore state (used by minimal mode submenu)
|
/// Setter for internal disable explore state (used by minimal mode submenu)
|
||||||
|
/// Auto-disables minimal mode if all features are turned off
|
||||||
Future<void> setDisableExploreEntirelyInternal(bool v) async {
|
Future<void> setDisableExploreEntirelyInternal(bool v) async {
|
||||||
_disableExploreEntirely = v;
|
_disableExploreEntirely = v;
|
||||||
await _prefs?.setBool('internal_disable_explore_entirely', v);
|
await _prefs?.setBool('internal_disable_explore_entirely', v);
|
||||||
|
|
||||||
|
// Check if minimal mode should auto-disable
|
||||||
|
await _checkAndAutoDisableMinimalMode();
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setter for home feed scroll blocking state (used by minimal mode submenu).
|
||||||
|
Future<void> setBlockHomeFeedScrollInternal(bool v) async {
|
||||||
|
_blockHomeFeedScroll = v;
|
||||||
|
await _prefs?.setBool('internal_block_home_feed_scroll', v);
|
||||||
|
|
||||||
|
await _checkAndAutoDisableMinimalMode();
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: Auto-disable minimal mode if all its features are disabled
|
||||||
|
/// This ensures minimal mode auto-turns-off when user disables all sub-features
|
||||||
|
///
|
||||||
|
/// NOTE: We must check the RAW state variables here, NOT the public getters
|
||||||
|
/// (disableReelsEntirely/disableExploreEntirely), because those getters
|
||||||
|
/// unconditionally return true when _minimalModeEnabled is true, which would
|
||||||
|
/// make the "all disabled" condition impossible to reach.
|
||||||
|
Future<void> _checkAndAutoDisableMinimalMode() async {
|
||||||
|
if (!_minimalModeEnabled) return;
|
||||||
|
|
||||||
|
// Check the RAW saved state, not the getters
|
||||||
|
final rawReels =
|
||||||
|
_prefs?.getBool('internal_disable_reels_entirely') ??
|
||||||
|
_disableReelsEntirely;
|
||||||
|
final rawExplore =
|
||||||
|
_prefs?.getBool('internal_disable_explore_entirely') ??
|
||||||
|
_disableExploreEntirely;
|
||||||
|
|
||||||
|
final rawHomeFeedScroll =
|
||||||
|
_prefs?.getBool('internal_block_home_feed_scroll') ??
|
||||||
|
_blockHomeFeedScroll;
|
||||||
|
|
||||||
|
final allDisabled =
|
||||||
|
!rawReels && !rawExplore && !rawHomeFeedScroll && !_blurExplore;
|
||||||
|
|
||||||
|
if (allDisabled) {
|
||||||
|
_minimalModeEnabled = false;
|
||||||
|
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Smart minimal mode toggle with state preservation
|
/// Smart minimal mode toggle with state preservation
|
||||||
Future<void> setMinimalModeEnabled(bool v) async {
|
Future<void> setMinimalModeEnabled(bool v) async {
|
||||||
if (v) {
|
if (v) {
|
||||||
// Turning ON - save current states BEFORE enabling minimal mode
|
// ── Turning ON ──────────────────────────────────────────────────────────
|
||||||
|
// Save current pre-minimal-mode states so we can restore them later
|
||||||
_prevDisableReels = _disableReelsEntirely;
|
_prevDisableReels = _disableReelsEntirely;
|
||||||
_prevDisableExplore = _disableExploreEntirely;
|
_prevDisableExplore = _disableExploreEntirely;
|
||||||
_prevBlurExplore = _blurExplore;
|
_prevBlurExplore = _blurExplore;
|
||||||
|
_prevBlockHomeFeedScroll = _blockHomeFeedScroll;
|
||||||
|
|
||||||
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
|
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
|
||||||
await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore);
|
await _prefs?.setBool(
|
||||||
|
_keyMinimalModePrevDisableExplore,
|
||||||
|
_prevDisableExplore,
|
||||||
|
);
|
||||||
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
|
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
|
||||||
|
await _prefs?.setBool(
|
||||||
|
_keyMinimalModePrevBlockHomeFeedScroll,
|
||||||
|
_prevBlockHomeFeedScroll,
|
||||||
|
);
|
||||||
|
|
||||||
// Enable all minimal mode settings
|
|
||||||
_minimalModeEnabled = true;
|
_minimalModeEnabled = true;
|
||||||
_disableReelsEntirely = true;
|
_disableReelsEntirely = true;
|
||||||
_disableExploreEntirely = true;
|
_disableExploreEntirely = true;
|
||||||
_blurExplore = true;
|
_blockHomeFeedScroll = true;
|
||||||
|
_blurExplore = true; // blurExplore is controlled by minimal mode while ON
|
||||||
|
|
||||||
await _prefs?.setBool(_keyMinimalModeEnabled, true);
|
await _prefs?.setBool(_keyMinimalModeEnabled, true);
|
||||||
await _prefs?.setBool('internal_disable_reels_entirely', true);
|
await _prefs?.setBool('internal_disable_reels_entirely', true);
|
||||||
await _prefs?.setBool('internal_disable_explore_entirely', true);
|
await _prefs?.setBool('internal_disable_explore_entirely', true);
|
||||||
|
await _prefs?.setBool('internal_block_home_feed_scroll', true);
|
||||||
await _prefs?.setBool(_keyBlurExplore, true);
|
await _prefs?.setBool(_keyBlurExplore, true);
|
||||||
} else {
|
} else {
|
||||||
// Turning OFF - restore to PREVIOUS states (before minimal mode was turned on)
|
// ── Turning OFF ─────────────────────────────────────────────────────────
|
||||||
|
// Restore states that were saved BEFORE minimal mode was enabled.
|
||||||
|
// _prevDisableReels/Explore were saved at the moment minimal mode turned ON.
|
||||||
_minimalModeEnabled = false;
|
_minimalModeEnabled = false;
|
||||||
|
|
||||||
// Simply restore to the states that were saved BEFORE minimal mode was enabled
|
|
||||||
_disableReelsEntirely = _prevDisableReels;
|
_disableReelsEntirely = _prevDisableReels;
|
||||||
_disableExploreEntirely = _prevDisableExplore;
|
_disableExploreEntirely = _prevDisableExplore;
|
||||||
|
_blockHomeFeedScroll = _prevBlockHomeFeedScroll;
|
||||||
|
// For blurExplore: use _prevBlurExplore if it was saved, otherwise fall back
|
||||||
|
// to the saved prefs value (covers the case where no prev was saved).
|
||||||
_blurExplore = _prevBlurExplore;
|
_blurExplore = _prevBlurExplore;
|
||||||
|
|
||||||
// Save the restored states
|
|
||||||
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
||||||
await _prefs?.setBool('internal_disable_reels_entirely', _disableReelsEntirely);
|
await _prefs?.setBool(
|
||||||
await _prefs?.setBool('internal_disable_explore_entirely', _disableExploreEntirely);
|
'internal_disable_reels_entirely',
|
||||||
|
_disableReelsEntirely,
|
||||||
|
);
|
||||||
|
await _prefs?.setBool(
|
||||||
|
'internal_disable_explore_entirely',
|
||||||
|
_disableExploreEntirely,
|
||||||
|
);
|
||||||
|
await _prefs?.setBool(
|
||||||
|
'internal_block_home_feed_scroll',
|
||||||
|
_blockHomeFeedScroll,
|
||||||
|
);
|
||||||
await _prefs?.setBool(_keyBlurExplore, _blurExplore);
|
await _prefs?.setBool(_keyBlurExplore, _blurExplore);
|
||||||
|
|
||||||
|
// After restoring, check whether the user had ALL minimal features OFF
|
||||||
|
// already — if so, minimal mode should stay off (no-op).
|
||||||
|
if (!_disableReelsEntirely &&
|
||||||
|
!_disableExploreEntirely &&
|
||||||
|
!_blockHomeFeedScroll &&
|
||||||
|
!_blurExplore) {
|
||||||
|
// All features are off — minimal mode correctly stays off. No action needed.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -441,24 +739,69 @@ class SettingsService extends ChangeNotifier {
|
|||||||
Future<void> setNotifyDMs(bool v) async {
|
Future<void> setNotifyDMs(bool v) async {
|
||||||
_notifyDMs = v;
|
_notifyDMs = v;
|
||||||
await _prefs?.setBool(_keyNotifyDMs, v);
|
await _prefs?.setBool(_keyNotifyDMs, v);
|
||||||
|
if (v) await NotificationService().requestPermissionsNow();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setNotifyActivity(bool v) async {
|
Future<void> setNotifyActivity(bool v) async {
|
||||||
_notifyActivity = v;
|
_notifyActivity = v;
|
||||||
await _prefs?.setBool(_keyNotifyActivity, v);
|
await _prefs?.setBool(_keyNotifyActivity, v);
|
||||||
|
if (v) await NotificationService().requestPermissionsNow();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setNotifySessionEnd(bool v) async {
|
Future<void> setNotifySessionEnd(bool v) async {
|
||||||
_notifySessionEnd = v;
|
_notifySessionEnd = v;
|
||||||
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
||||||
|
if (v) await NotificationService().requestPermissionsNow();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setNotifyPersistent(bool v) async {
|
Future<void> setNotifyPersistent(bool v) async {
|
||||||
_notifyPersistent = v;
|
_notifyPersistent = v;
|
||||||
await _prefs?.setBool(_keyNotifyPersistent, v);
|
await _prefs?.setBool(_keyNotifyPersistent, v);
|
||||||
|
if (v) {
|
||||||
|
await NotificationService().requestPermissionsNow();
|
||||||
|
} else {
|
||||||
|
await NotificationService().cancelPersistentNotification(id: 5001);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Focus mode settings ──────────────────────────────────────────────────────
|
||||||
|
Future<void> setGhostMode(bool v) async {
|
||||||
|
_ghostMode = v;
|
||||||
|
await _prefs?.setBool(_keyGhostMode, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNoAds(bool v) async {
|
||||||
|
_noAds = v;
|
||||||
|
await _prefs?.setBool(_keyNoAds, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNoStories(bool v) async {
|
||||||
|
_noStories = v;
|
||||||
|
await _prefs?.setBool(_keyNoStories, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNoReels(bool v) async {
|
||||||
|
_noReels = v;
|
||||||
|
await _prefs?.setBool(_keyNoReels, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNoAutoplay(bool v) async {
|
||||||
|
_noAutoplay = v;
|
||||||
|
await _prefs?.setBool(_keyNoAutoplay, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setNoDMs(bool v) async {
|
||||||
|
_noDMs = v;
|
||||||
|
await _prefs?.setBool(_keyNoDMs, v);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ class DisciplineChallenge {
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Shows the word challenge dialog. Returns true if successful.
|
/// Shows the word challenge dialog. Returns true if successful.
|
||||||
static Future<bool> show(BuildContext context, {int count = 15}) async {
|
static Future<bool> show(BuildContext context, {int count = 30}) async {
|
||||||
final list = List<String>.from(_words)..shuffle();
|
final list = List<String>.from(_words)..shuffle();
|
||||||
final challenge = list.take(count).join(' ');
|
final challenge = list.take(count).join(' ');
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'script_registry_v2_overlay.dart';
|
||||||
|
|
||||||
|
class ScriptEngineV2Overlay {
|
||||||
|
final InAppWebViewController controller;
|
||||||
|
final SharedPreferences prefs;
|
||||||
|
|
||||||
|
final Map<String, String> _cache = {};
|
||||||
|
|
||||||
|
ScriptEngineV2Overlay({required this.controller, required this.prefs});
|
||||||
|
|
||||||
|
Future<void> initDocumentStartScripts() async {
|
||||||
|
for (final s in V2OverlayScriptRegistry.all) {
|
||||||
|
final enabled = _getEnabled(s.id);
|
||||||
|
s.enabled = enabled;
|
||||||
|
|
||||||
|
if (!enabled) continue;
|
||||||
|
|
||||||
|
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
|
||||||
|
final code = await _load(s.assetPath);
|
||||||
|
if (code == null) continue;
|
||||||
|
|
||||||
|
await controller.addUserScript(
|
||||||
|
userScript: UserScript(
|
||||||
|
source: code,
|
||||||
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||||
|
allowedOriginRules: {'https://www.instagram.com'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> injectDocumentEndScripts() async {
|
||||||
|
for (final s in V2OverlayScriptRegistry.all) {
|
||||||
|
final enabled = _getEnabled(s.id);
|
||||||
|
s.enabled = enabled;
|
||||||
|
if (!enabled) continue;
|
||||||
|
|
||||||
|
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END) {
|
||||||
|
final code = await _load(s.assetPath);
|
||||||
|
if (code == null) continue;
|
||||||
|
try {
|
||||||
|
await controller.evaluateJavascript(source: code);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort injection; never crash UI.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _pushContentFlagsIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggle(V2OverlayScriptId id, bool enabled) async {
|
||||||
|
await prefs.setBool(_enabledKey(id), enabled);
|
||||||
|
|
||||||
|
// For DOCUMENT_START scripts, require reload for clean removal.
|
||||||
|
if (V2OverlayScriptRegistry.byId(id).injectionTime ==
|
||||||
|
UserScriptInjectionTime.AT_DOCUMENT_START) {
|
||||||
|
await controller.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For DOCUMENT_END scripts: just reload too to ensure DOM effects stop.
|
||||||
|
await controller.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _getEnabled(V2OverlayScriptId id) {
|
||||||
|
return prefs.getBool(_enabledKey(id)) ??
|
||||||
|
(id == V2OverlayScriptId.themeDetector);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _enabledKey(V2OverlayScriptId id) => 'fg_v2_${id.name}_enabled';
|
||||||
|
|
||||||
|
Future<void> _pushContentFlagsIfNeeded() async {
|
||||||
|
final contentScriptEnabled = _getEnabled(V2OverlayScriptId.contentHider);
|
||||||
|
|
||||||
|
final contentFlags = <String, bool>{
|
||||||
|
'stories': prefs.getBool('content_stories') ?? false,
|
||||||
|
'posts': prefs.getBool('content_posts') ?? false,
|
||||||
|
'reels': prefs.getBool('content_reels') ?? false,
|
||||||
|
'suggested': prefs.getBool('content_suggested') ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply DOM content hider flags
|
||||||
|
if (contentScriptEnabled) {
|
||||||
|
await controller.evaluateJavascript(
|
||||||
|
source: 'window.__fgContent?.applyAll(${jsonEncode(contentFlags)});',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also push network filter flags used by fetch_interceptor.js
|
||||||
|
// so toggles actually affect request/response behavior.
|
||||||
|
final noAds =
|
||||||
|
(prefs.getBool('no_ads') ?? false) ||
|
||||||
|
(prefs.getBool(_enabledKey(V2OverlayScriptId.adBlockerDom)) ?? false);
|
||||||
|
final blockFeedPosts = contentFlags['posts'] ?? false;
|
||||||
|
final blockSuggested = contentFlags['suggested'] ?? false;
|
||||||
|
final blockReels = contentFlags['reels'] ?? false;
|
||||||
|
final blockAutoplay =
|
||||||
|
prefs.getBool(_enabledKey(V2OverlayScriptId.autoplayBlocker)) ?? false;
|
||||||
|
|
||||||
|
await controller.evaluateJavascript(
|
||||||
|
source:
|
||||||
|
'window.__fgSetFilterConfig?.(${jsonEncode({
|
||||||
|
// Strictly requested: when Hide Feed Posts is ON, block ALL graphql/query.
|
||||||
|
'blockGraphQLQueryWhenFeedPosts': blockFeedPosts,
|
||||||
|
|
||||||
|
// Ads blocker: use existing FocusGram "noAds" toggle (wired elsewhere in prefs).
|
||||||
|
'blockAds': noAds,
|
||||||
|
'blockSponsored': noAds,
|
||||||
|
|
||||||
|
'blockSuggested': blockSuggested,
|
||||||
|
|
||||||
|
// Keep video blocking controlled by existing toggles if desired.
|
||||||
|
'blockVideos': blockReels,
|
||||||
|
'blockAutoplay': blockAutoplay,
|
||||||
|
})});',
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.evaluateJavascript(
|
||||||
|
source: 'window.__fgSetBlockAutoplay?.($blockAutoplay);',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _load(String assetPath) async {
|
||||||
|
if (_cache.containsKey(assetPath)) return _cache[assetPath];
|
||||||
|
try {
|
||||||
|
final code = await rootBundle.loadString(assetPath);
|
||||||
|
_cache[assetPath] = code;
|
||||||
|
return code;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
|
||||||
|
enum V2OverlayScriptId {
|
||||||
|
ghostMode,
|
||||||
|
themeDetector,
|
||||||
|
adBlockerDom,
|
||||||
|
contentHider,
|
||||||
|
fetchInterceptor,
|
||||||
|
autoplayBlocker,
|
||||||
|
}
|
||||||
|
|
||||||
|
class V2OverlayInstaScript {
|
||||||
|
final V2OverlayScriptId id;
|
||||||
|
final String name;
|
||||||
|
final String assetPath;
|
||||||
|
final UserScriptInjectionTime injectionTime;
|
||||||
|
bool enabled;
|
||||||
|
|
||||||
|
V2OverlayInstaScript({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.assetPath,
|
||||||
|
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||||
|
this.enabled = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class V2OverlayScriptRegistry {
|
||||||
|
static final List<V2OverlayInstaScript> all = [
|
||||||
|
V2OverlayInstaScript(
|
||||||
|
id: V2OverlayScriptId.ghostMode,
|
||||||
|
name: 'ghost_mode',
|
||||||
|
assetPath: 'assets/scripts/ghost_mode.js',
|
||||||
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
V2OverlayInstaScript(
|
||||||
|
id: V2OverlayScriptId.themeDetector,
|
||||||
|
name: 'theme_detector',
|
||||||
|
assetPath: 'assets/scripts/theme_detector.js',
|
||||||
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||||
|
enabled: true,
|
||||||
|
),
|
||||||
|
V2OverlayInstaScript(
|
||||||
|
id: V2OverlayScriptId.adBlockerDom,
|
||||||
|
name: 'ad_blocker_dom',
|
||||||
|
assetPath: 'assets/scripts/ad_blocker_dom.js',
|
||||||
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
V2OverlayInstaScript(
|
||||||
|
id: V2OverlayScriptId.contentHider,
|
||||||
|
name: 'content_hider',
|
||||||
|
assetPath: 'assets/scripts/content_hider.js',
|
||||||
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
V2OverlayInstaScript(
|
||||||
|
id: V2OverlayScriptId.fetchInterceptor,
|
||||||
|
name: 'fetch_interceptor',
|
||||||
|
assetPath: 'assets/scripts/fetch_interceptor.js',
|
||||||
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
V2OverlayInstaScript(
|
||||||
|
id: V2OverlayScriptId.autoplayBlocker,
|
||||||
|
name: 'autoplay_blocker',
|
||||||
|
assetPath: 'assets/scripts/autoplay_blocker.js',
|
||||||
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||||
|
enabled: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static V2OverlayInstaScript byId(V2OverlayScriptId id) {
|
||||||
|
return all.firstWhere((s) => s.id == id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: app_settings
|
name: app_settings
|
||||||
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
|
sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.1"
|
version: "7.0.0"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -213,10 +213,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: fl_chart
|
name: fl_chart
|
||||||
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
|
sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.69.2"
|
version: "0.71.0"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -290,10 +290,10 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: flutter_launcher_icons
|
name: flutter_launcher_icons
|
||||||
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.1"
|
version: "0.14.4"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -372,10 +372,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: google_fonts
|
name: google_fonts
|
||||||
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
|
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.2"
|
version: "8.1.0"
|
||||||
gtk:
|
gtk:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -596,10 +596,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.3.1"
|
version: "9.0.1"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -668,18 +668,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: permission_handler
|
name: permission_handler
|
||||||
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.0.1"
|
version: "11.4.0"
|
||||||
permission_handler_android:
|
permission_handler_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_android
|
name: permission_handler_android
|
||||||
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "13.0.1"
|
version: "12.1.0"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -764,10 +764,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.4"
|
version: "2.5.5"
|
||||||
shared_preferences_android:
|
shared_preferences_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+17
-11
@@ -11,11 +11,11 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely
|
# WebView engine
|
||||||
flutter_inappwebview: ^6.1.5
|
flutter_inappwebview: ^6.1.5
|
||||||
|
|
||||||
# Local key-value persistence — latest stable
|
# Local key-value persistence — latest stable
|
||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.5
|
||||||
|
|
||||||
# Date/time formatting for daily resets — latest stable
|
# Date/time formatting for daily resets — latest stable
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
@@ -28,26 +28,26 @@ dependencies:
|
|||||||
|
|
||||||
# URL launcher for About page links — latest stable
|
# URL launcher for About page links — latest stable
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
package_info_plus: ^8.1.2
|
package_info_plus: ^9.0.0
|
||||||
# Handling Instagram deep links — latest stable
|
# Handling Instagram deep links — latest stable
|
||||||
app_links: ^6.3.2
|
app_links: ^6.4.1
|
||||||
# Open system settings — latest stable
|
# Open system settings — latest stable
|
||||||
app_settings: ^6.1.1
|
app_settings: ^7.0.0
|
||||||
google_fonts: ^8.0.2
|
google_fonts: ^8.1.0
|
||||||
http: ^1.3.0
|
http: ^1.6.0
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^11.4.0
|
||||||
# Image/file picker for story uploads on Android
|
# Image/file picker for story uploads on Android
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.2.0
|
||||||
flutter_windowmanager_plus: ^1.0.1
|
flutter_windowmanager_plus: ^1.0.1
|
||||||
|
|
||||||
# Charts for on-device screen time dashboard (MIT)
|
# Charts for on-device screen time dashboard (MIT)
|
||||||
fl_chart: ^0.69.0
|
fl_chart: ^0.71.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: ^0.13.1
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
@@ -55,6 +55,12 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/images/focusgram.png
|
- assets/images/focusgram.png
|
||||||
- assets/images/focusgram.ico
|
- assets/images/focusgram.ico
|
||||||
|
- assets/scripts/ghost_mode.js
|
||||||
|
- assets/scripts/ad_blocker_dom.js
|
||||||
|
- assets/scripts/content_hider.js
|
||||||
|
- assets/scripts/theme_detector.js
|
||||||
|
- assets/scripts/fetch_interceptor.js
|
||||||
|
- assets/scripts/autoplay_blocker.js
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:focusgram/screens/main_webview_page.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('handleFocusGramMediaDownload', () {
|
||||||
|
test('rejects non-http(s) schemes', () async {
|
||||||
|
final launched = <Uri>[];
|
||||||
|
final ok = await handleFocusGramMediaDownload(
|
||||||
|
raw: '{"type":"video","url":"file:///etc/passwd","filename":"x"}',
|
||||||
|
launch: (uri) async => launched.add(uri),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok, isFalse);
|
||||||
|
expect(launched, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts http(s) instagram-like hosts and calls launcher', () async {
|
||||||
|
final launched = <Uri>[];
|
||||||
|
final ok = await handleFocusGramMediaDownload(
|
||||||
|
raw: '{"type":"video","url":"https://cdninstagram.com/v/1.mp4","filename":"x"}',
|
||||||
|
launch: (uri) async => launched.add(uri),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok, isTrue);
|
||||||
|
expect(launched, hasLength(1));
|
||||||
|
expect(launched.first.scheme, 'https');
|
||||||
|
expect(launched.first.host.toLowerCase(), contains('cdninstagram.com'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-instagram hosts even if http(s)', () async {
|
||||||
|
final launched = <Uri>[];
|
||||||
|
final ok = await handleFocusGramMediaDownload(
|
||||||
|
raw: '{"type":"video","url":"https://example.com/video.mp4","filename":"x"}',
|
||||||
|
launch: (uri) async => launched.add(uri),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok, isFalse);
|
||||||
|
expect(launched, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed JSON safely', () async {
|
||||||
|
final launched = <Uri>[];
|
||||||
|
final ok = await handleFocusGramMediaDownload(
|
||||||
|
raw: '{not json',
|
||||||
|
launch: (uri) async => launched.add(uri),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok, isFalse);
|
||||||
|
expect(launched, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing url field', () async {
|
||||||
|
final launched = <Uri>[];
|
||||||
|
final ok = await handleFocusGramMediaDownload(
|
||||||
|
raw: '{"type":"video","filename":"x"}',
|
||||||
|
launch: (uri) async => launched.add(uri),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ok, isFalse);
|
||||||
|
expect(launched, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
// android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt
|
||||||
//
|
//
|
||||||
// Adds:
|
// Ghost mode WebView integration notes
|
||||||
// 1. Platform channel for FLAG_SECURE (anti-screenshot at OS level)
|
|
||||||
// 2. Ghost mode WebView integration notes
|
|
||||||
|
|
||||||
package com.focusgram.focusgram
|
package com.focusgram.focusgram
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
private val CHANNEL = "com.focusgram/window_flags"
|
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
MethodChannel(
|
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
|
||||||
CHANNEL
|
|
||||||
).setMethodCallHandler { call, result ->
|
|
||||||
when (call.method) {
|
|
||||||
"setSecure" -> {
|
|
||||||
val secure = call.argument<Boolean>("secure") ?: false
|
|
||||||
if (secure) {
|
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
} else {
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
}
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +31,6 @@ class MainActivity : FlutterActivity() {
|
|||||||
// super.initState();
|
// super.initState();
|
||||||
// _ghost = GhostModeService();
|
// _ghost = GhostModeService();
|
||||||
// _ghost.load().then((_) {
|
// _ghost.load().then((_) {
|
||||||
// WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
// _ghost.applyWindowFlags(context);
|
|
||||||
// });
|
|
||||||
// setState(() {});
|
// setState(() {});
|
||||||
// });
|
// });
|
||||||
// }
|
// }
|
||||||
|
|||||||
@@ -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(
|
JavaScriptChannel _ghostChannel() => JavaScriptChannel(
|
||||||
name: 'GhostChannel',
|
name: 'GhostChannel',
|
||||||
onMessageReceived: (msg) {
|
onMessageReceived: (msg) {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[Ghost] ${data['type']} — ${data['url'] ?? ''}');
|
debugPrint('[Ghost] ${data['type']} — ${data['url'] ?? ''}');
|
||||||
}
|
}
|
||||||
// In release: silent. Could surface to a debug overlay in dev builds.
|
// In release: silent. Could surface to a debug overlay in dev builds.
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
JavaScriptChannel _themeChannel() => JavaScriptChannel(
|
JavaScriptChannel _themeChannel() => JavaScriptChannel(
|
||||||
name: 'ThemeChannel',
|
name: 'ThemeChannel',
|
||||||
onMessageReceived: (msg) {
|
onMessageReceived: (msg) {
|
||||||
SystemUiManager.applyFromThemePayload(msg.message);
|
SystemUiManager.applyFromThemePayload(msg.message);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
JavaScriptChannel _contentChannel() => JavaScriptChannel(
|
JavaScriptChannel _contentChannel() => JavaScriptChannel(
|
||||||
name: 'ContentChannel',
|
name: 'ContentChannel',
|
||||||
onMessageReceived: (msg) {
|
onMessageReceived: (msg) {
|
||||||
// 'ready' signal — engine pushes flags back via evaluateJavascript
|
// 'ready' signal — engine pushes flags back via evaluateJavascript
|
||||||
// handled in ScriptEngine.injectDocumentEndScripts()
|
// handled in ScriptEngine.injectDocumentEndScripts()
|
||||||
if (kDebugMode) debugPrint('[Content] ${msg.message}');
|
if (kDebugMode) debugPrint('[Content] ${msg.message}');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
JavaScriptChannel _activityChannel() => JavaScriptChannel(
|
JavaScriptChannel _activityChannel() => JavaScriptChannel(
|
||||||
name: 'ActivityChannel',
|
name: 'ActivityChannel',
|
||||||
onMessageReceived: (msg) {
|
onMessageReceived: (msg) {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
||||||
onActivityEvent?.call(data);
|
onActivityEvent?.call(data);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-54
@@ -3,7 +3,7 @@
|
|||||||
// Three-layer ghost mode:
|
// Three-layer ghost mode:
|
||||||
// 1. AT_DOCUMENT_START JS injection — overrides fetch/XHR/WS before IG code runs
|
// 1. AT_DOCUMENT_START JS injection — overrides fetch/XHR/WS before IG code runs
|
||||||
// 2. shouldInterceptRequest — native Android intercept (catches SW requests too)
|
// 2. shouldInterceptRequest — native Android intercept (catches SW requests too)
|
||||||
// 3. FLAG_SECURE — anti-screenshot at OS level
|
// 3. FLAG_SECURE — anti-screenshot at OS level (disabled per user request)
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
// final service = GhostModeService();
|
// final service = GhostModeService();
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
// shouldInterceptRequest: service.shouldInterceptRequest,
|
// shouldInterceptRequest: service.shouldInterceptRequest,
|
||||||
// )
|
// )
|
||||||
//
|
//
|
||||||
// // Anti-screenshot: call from initState after WidgetsBinding.instance.addPostFrameCallback
|
// // Anti-screenshot: disabled per user request
|
||||||
// service.applyWindowFlags(context);
|
// // service.applyWindowFlags(context);
|
||||||
|
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
@@ -36,55 +36,50 @@ class GhostFeatures {
|
|||||||
bool hideVoiceListened;
|
bool hideVoiceListened;
|
||||||
bool hideReplyImageViewed;
|
bool hideReplyImageViewed;
|
||||||
bool disableAnalytics;
|
bool disableAnalytics;
|
||||||
bool antiScreenshot;
|
|
||||||
|
|
||||||
GhostFeatures({
|
GhostFeatures({
|
||||||
this.hideStoryViews = true,
|
this.hideStoryViews = true,
|
||||||
this.hideReadReceipts = true,
|
this.hideReadReceipts = true,
|
||||||
this.hideLiveJoin = true,
|
this.hideLiveJoin = true,
|
||||||
this.hideTypingIndicator = true,
|
this.hideTypingIndicator = true,
|
||||||
this.hideVoiceListened = true,
|
this.hideVoiceListened = true,
|
||||||
this.hideReplyImageViewed = true,
|
this.hideReplyImageViewed = true,
|
||||||
this.disableAnalytics = true,
|
this.disableAnalytics = true,
|
||||||
this.antiScreenshot = false, // Off by default — user must opt in
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static const _keys = {
|
static const _keys = {
|
||||||
'hideStoryViews': 'gm_story',
|
'hideStoryViews': 'gm_story',
|
||||||
'hideReadReceipts': 'gm_read',
|
'hideReadReceipts': 'gm_read',
|
||||||
'hideLiveJoin': 'gm_live',
|
'hideLiveJoin': 'gm_live',
|
||||||
'hideTypingIndicator': 'gm_typing',
|
'hideTypingIndicator': 'gm_typing',
|
||||||
'hideVoiceListened': 'gm_voice',
|
'hideVoiceListened': 'gm_voice',
|
||||||
'hideReplyImageViewed': 'gm_reply',
|
'hideReplyImageViewed': 'gm_reply',
|
||||||
'disableAnalytics': 'gm_analytics',
|
'disableAnalytics': 'gm_analytics',
|
||||||
'antiScreenshot': 'gm_screenshot',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Future<void> save() async {
|
Future<void> save() async {
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
p.setBool(_keys['hideStoryViews']!, hideStoryViews),
|
p.setBool(_keys['hideStoryViews']!, hideStoryViews),
|
||||||
p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
|
p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
|
||||||
p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
|
p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
|
||||||
p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
|
p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
|
||||||
p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
|
p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
|
||||||
p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed),
|
p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed),
|
||||||
p.setBool(_keys['disableAnalytics']!, disableAnalytics),
|
p.setBool(_keys['disableAnalytics']!, disableAnalytics),
|
||||||
p.setBool(_keys['antiScreenshot']!, antiScreenshot),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<GhostFeatures> load() async {
|
static Future<GhostFeatures> load() async {
|
||||||
final p = await SharedPreferences.getInstance();
|
final p = await SharedPreferences.getInstance();
|
||||||
return GhostFeatures(
|
return GhostFeatures(
|
||||||
hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
|
hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
|
||||||
hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
|
hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
|
||||||
hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
|
hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
|
||||||
hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
|
hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
|
||||||
hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
|
hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
|
||||||
hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true,
|
hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true,
|
||||||
disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
|
disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
|
||||||
antiScreenshot: p.getBool(_keys['antiScreenshot']!) ?? false,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,23 +105,18 @@ final _nativeBlocklist = [
|
|||||||
RegExp(r'/ajax/logging/'),
|
RegExp(r'/ajax/logging/'),
|
||||||
];
|
];
|
||||||
|
|
||||||
final Uint8List _fakeOkBody = Uint8List.fromList(
|
final Uint8List _fakeOkBody = Uint8List.fromList('{"status":"ok"}'.codeUnits);
|
||||||
'{"status":"ok"}'.codeUnits,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── Main service ─────────────────────────────────────────────────────────────
|
// ─── Main service ─────────────────────────────────────────────────────────────
|
||||||
class GhostModeService {
|
class GhostModeService {
|
||||||
GhostFeatures features = GhostFeatures();
|
GhostFeatures features = GhostFeatures();
|
||||||
InAppWebViewController? _controller;
|
InAppWebViewController? _controller;
|
||||||
|
|
||||||
// Platform channel for FLAG_SECURE (anti-screenshot)
|
|
||||||
static const _channel = MethodChannel('com.focusgram/window_flags');
|
|
||||||
|
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
features = await GhostFeatures.load();
|
features = await GhostFeatures.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── WebView setup ──────────────────────────────────────────────────────────
|
// ─── WebView setup ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Call from InAppWebView.onWebViewCreated
|
/// Call from InAppWebView.onWebViewCreated
|
||||||
void onWebViewCreated(InAppWebViewController controller) {
|
void onWebViewCreated(InAppWebViewController controller) {
|
||||||
@@ -170,34 +160,28 @@ class GhostModeService {
|
|||||||
/// InAppWebViewSettings required for shouldInterceptRequest to fire
|
/// InAppWebViewSettings required for shouldInterceptRequest to fire
|
||||||
InAppWebViewSettings buildWebViewSettings() {
|
InAppWebViewSettings buildWebViewSettings() {
|
||||||
return InAppWebViewSettings(
|
return InAppWebViewSettings(
|
||||||
useShouldInterceptRequest: true, // Enable native intercept callback
|
useShouldInterceptRequest: true, // Enable native intercept callback
|
||||||
useShouldOverrideUrlLoading: true,
|
useShouldOverrideUrlLoading: true,
|
||||||
javaScriptEnabled: true,
|
javaScriptEnabled: true,
|
||||||
disableDefaultErrorPage: true,
|
disableDefaultErrorPage: true,
|
||||||
useHybridComposition: true, // Needed for FLAG_SECURE to work
|
useHybridComposition:
|
||||||
|
true, // Needed for FLAG_SECURE to work (though disabled)
|
||||||
// Disable service worker cache that can replay seen-events offline
|
// Disable service worker cache that can replay seen-events offline
|
||||||
cacheEnabled: false, // Start clean — optional, tradeoff vs perf
|
cacheEnabled: false, // Start clean — optional, tradeoff vs perf
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Anti-screenshot ────────────────────────────────────────────────────────
|
// ─── Anti-screenshot ────────────────────────────────────────────────────────
|
||||||
|
// Anti-screenshot disabled per user request
|
||||||
|
|
||||||
/// Call from initState → addPostFrameCallback
|
|
||||||
Future<void> applyWindowFlags(BuildContext context) async {
|
Future<void> applyWindowFlags(BuildContext context) async {
|
||||||
if (!features.antiScreenshot) return;
|
// Anti-screenshot disabled per user request
|
||||||
try {
|
return;
|
||||||
await _channel.invokeMethod('setSecure', {'secure': true});
|
|
||||||
} on MissingPluginException {
|
|
||||||
// Platform channel not registered — use plugin fallback below
|
|
||||||
debugPrint('[GhostMode] FLAG_SECURE: platform channel missing. '
|
|
||||||
'Add flutter_windowmanager or implement MainActivity channel.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clearWindowFlags() async {
|
Future<void> clearWindowFlags() async {
|
||||||
try {
|
// Anti-screenshot disabled per user request
|
||||||
await _channel.invokeMethod('setSecure', {'secure': false});
|
return;
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Re-inject after page nav (SPA navigation doesn't re-run userScripts) ──
|
// ─── Re-inject after page nav (SPA navigation doesn't re-run userScripts) ──
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../injection/script_engine.dart';
|
|||||||
import '../injection/script_registry.dart';
|
import '../injection/script_registry.dart';
|
||||||
import '../channels/channel_registry.dart';
|
import '../channels/channel_registry.dart';
|
||||||
import '../webview/webview_config.dart';
|
import '../webview/webview_config.dart';
|
||||||
|
import '../services/ghost_mode_service.dart';
|
||||||
|
|
||||||
class InstagramWebView extends StatefulWidget {
|
class InstagramWebView extends StatefulWidget {
|
||||||
const InstagramWebView({super.key});
|
const InstagramWebView({super.key});
|
||||||
@@ -17,9 +18,10 @@ class InstagramWebView extends StatefulWidget {
|
|||||||
class InstagramWebViewState extends State<InstagramWebView> {
|
class InstagramWebViewState extends State<InstagramWebView> {
|
||||||
InAppWebViewController? _controller;
|
InAppWebViewController? _controller;
|
||||||
ScriptEngine? _engine;
|
ScriptEngine? _engine;
|
||||||
|
GhostModeService? _ghostMode;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
// ── Public API — call from Settings screen ────────────────────────────────
|
// ── Public API — call from Settings screen ─────────────────────────────
|
||||||
Future<void> toggleScript(ScriptId id, bool enabled) async {
|
Future<void> toggleScript(ScriptId id, bool enabled) async {
|
||||||
await _engine?.toggle(id, enabled);
|
await _engine?.toggle(id, enabled);
|
||||||
}
|
}
|
||||||
@@ -32,6 +34,37 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
|||||||
await _engine?.setOnlineHide(enabled);
|
await _engine?.setOnlineHide(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ghost mode controls
|
||||||
|
Future<void> setGhostModeEnabled(bool enabled) async {
|
||||||
|
if (_ghostMode != null) {
|
||||||
|
_ghostMode!.features.disableAnalytics = enabled;
|
||||||
|
_ghostMode!.features.hideStoryViews = enabled;
|
||||||
|
_ghostMode!.features.hideReadReceipts = enabled;
|
||||||
|
_ghostMode!.features.hideLiveJoin = enabled;
|
||||||
|
_ghostMode!.features.hideTypingIndicator = enabled;
|
||||||
|
_ghostMode!.features.hideVoiceListened = enabled;
|
||||||
|
_ghostMode!.features.hideReplyImageViewed = enabled;
|
||||||
|
await _ghostMode!.features.save();
|
||||||
|
// Reapply settings if webview exists
|
||||||
|
if (_controller != null) {
|
||||||
|
// Force reload to apply new settings
|
||||||
|
await _controller!.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAntiScreenshot(bool enabled) async {
|
||||||
|
if (_ghostMode != null) {
|
||||||
|
_ghostMode!.features.antiScreenshot = enabled;
|
||||||
|
await _ghostMode!.features.save();
|
||||||
|
if (_ghostMode!.features.antiScreenshot) {
|
||||||
|
await _ghostMode!.applyWindowFlags(context);
|
||||||
|
} else {
|
||||||
|
await _ghostMode!.clearWindowFlags();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -40,12 +73,18 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
|||||||
children: [
|
children: [
|
||||||
InAppWebView(
|
InAppWebView(
|
||||||
initialUrlRequest: WebViewConfig.initialRequest,
|
initialUrlRequest: WebViewConfig.initialRequest,
|
||||||
initialSettings: WebViewConfig.settings,
|
initialSettings:
|
||||||
|
_ghostMode?.buildWebViewSettings() ?? WebViewConfig.settings,
|
||||||
|
|
||||||
// ── ContentBlockers — merged base + EasyList rules ──────────────
|
// ── ContentBlockers — merged base + EasyList rules ──────────────
|
||||||
contentBlockers: WebViewConfig.baseContentBlockers,
|
contentBlockers: WebViewConfig.baseContentBlockers,
|
||||||
// TODO Phase 1.5: merge EasyListParser.load() here at startup
|
// TODO Phase 1.5: merge EasyListParser.load() here at startup
|
||||||
|
|
||||||
|
// ── User Scripts — AT_DOCUMENT_START critical for ghost mode ─────
|
||||||
|
initialUserScripts: UnmodifiableListView(
|
||||||
|
_ghostMode?.buildUserScripts() ?? [],
|
||||||
|
),
|
||||||
|
|
||||||
// ── JavaScript channels ─────────────────────────────────────────
|
// ── JavaScript channels ─────────────────────────────────────────
|
||||||
javascriptChannels: ChannelRegistry(
|
javascriptChannels: ChannelRegistry(
|
||||||
onActivityEvent: (event) {
|
onActivityEvent: (event) {
|
||||||
@@ -56,6 +95,12 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
|||||||
|
|
||||||
onWebViewCreated: (controller) async {
|
onWebViewCreated: (controller) async {
|
||||||
_controller = controller;
|
_controller = controller;
|
||||||
|
|
||||||
|
// Initialize GhostModeService
|
||||||
|
_ghostMode = GhostModeService();
|
||||||
|
await _ghostMode!.load();
|
||||||
|
|
||||||
|
// Initialize existing script engine for other scripts
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_engine = ScriptEngine(controller: controller, prefs: prefs);
|
_engine = ScriptEngine(controller: controller, prefs: prefs);
|
||||||
|
|
||||||
@@ -66,6 +111,10 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
|||||||
onLoadStop: (controller, url) async {
|
onLoadStop: (controller, url) async {
|
||||||
// Inject DOCUMENT_END scripts
|
// Inject DOCUMENT_END scripts
|
||||||
await _engine?.injectDocumentEndScripts();
|
await _engine?.injectDocumentEndScripts();
|
||||||
|
|
||||||
|
// Re-inject ghost mode scripts on SPA navigation
|
||||||
|
await _ghostMode?.onPageLoaded(url?.uriValue);
|
||||||
|
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -103,6 +152,15 @@ class InstagramWebViewState extends State<InstagramWebView> {
|
|||||||
await _engine?.injectDocumentEndScripts();
|
await _engine?.injectDocumentEndScripts();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Native intercept for service worker requests ────────────────
|
||||||
|
shouldInterceptRequest: (controller, request) async {
|
||||||
|
return await _ghostMode?.shouldInterceptRequest(
|
||||||
|
controller,
|
||||||
|
request,
|
||||||
|
) ??
|
||||||
|
null;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Subtle loading indicator ──────────────────────────────────────
|
// ── Subtle loading indicator ──────────────────────────────────────
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
import 'core/theme/system_ui_manager.dart';
|
import 'core/theme/system_ui_manager.dart';
|
||||||
import 'core/webview/instagram_webview.dart';
|
import 'core/webview/instagram_webview.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Enable web contents debugging for ghost mode verification
|
||||||
|
if (kDebugMode) {
|
||||||
|
InAppWebViewController.setWebContentsDebuggingEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
await SystemUiManager.enableEdgeToEdge();
|
await SystemUiManager.enableEdgeToEdge();
|
||||||
runApp(const FocusGramApp());
|
runApp(const FocusGramApp());
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-4
@@ -35,12 +35,36 @@ class ScriptEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize script configurations after scripts are loaded
|
||||||
|
await _initializeScriptConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initialize script configurations from saved preferences ────────────────
|
||||||
|
Future<void> _initializeScriptConfigs() async {
|
||||||
|
// Fetch interceptor config
|
||||||
|
final fetchInterceptor = ScriptRegistry.byId(ScriptId.fetchInterceptor);
|
||||||
|
if (fetchInterceptor.enabled) {
|
||||||
|
await _updateFetchInterceptorConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autoplay blocker config
|
||||||
|
final autoplayBlocker = ScriptRegistry.byId(ScriptId.autoplayBlocker);
|
||||||
|
if (autoplayBlocker.enabled) {
|
||||||
|
await _updateAutoplayBlockerConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content hider flags
|
||||||
|
await _pushContentFlags();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Called from onLoadStop: inject all DOCUMENT_END enabled scripts ────────
|
// ── Called from onLoadStop: inject all DOCUMENT_END enabled scripts ────────
|
||||||
Future<void> injectDocumentEndScripts() async {
|
Future<void> injectDocumentEndScripts() async {
|
||||||
for (final script in ScriptRegistry.all
|
for (final script in ScriptRegistry.all.where(
|
||||||
.where((s) => s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END && s.enabled)) {
|
(s) =>
|
||||||
|
s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END &&
|
||||||
|
s.enabled,
|
||||||
|
)) {
|
||||||
await _inject(script);
|
await _inject(script);
|
||||||
}
|
}
|
||||||
// After content_hider is injected, push saved content flags
|
// After content_hider is injected, push saved content flags
|
||||||
@@ -77,6 +101,9 @@ class ScriptEngine {
|
|||||||
} else {
|
} else {
|
||||||
await _inject(script);
|
await _inject(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-initialize configurations after toggle
|
||||||
|
await _initializeScriptConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Content hider flags ────────────────────────────────────────────────────
|
// ── Content hider flags ────────────────────────────────────────────────────
|
||||||
@@ -100,15 +127,80 @@ class ScriptEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fetch interceptor configuration ────────────────────────────────────────
|
||||||
|
Future<void> setFetchInterceptorConfig({
|
||||||
|
bool? blockAds,
|
||||||
|
bool? blockSponsored,
|
||||||
|
bool? blockSuggested,
|
||||||
|
bool? blockVideos,
|
||||||
|
bool? blockAutoplay,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final config = {
|
||||||
|
'blockAds': blockAds ?? prefs.getBool('fetch_block_ads') ?? false,
|
||||||
|
'blockSponsored':
|
||||||
|
blockSponsored ?? prefs.getBool('fetch_block_sponsored') ?? false,
|
||||||
|
'blockSuggested':
|
||||||
|
blockSuggested ?? prefs.getBool('fetch_block_suggested') ?? false,
|
||||||
|
'blockVideos':
|
||||||
|
blockVideos ?? prefs.getBool('fetch_block_videos') ?? false,
|
||||||
|
'blockAutoplay':
|
||||||
|
blockAutoplay ?? prefs.getBool('fetch_block_autoplay') ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save individual prefs
|
||||||
|
await prefs.setBool('fetch_block_ads', config['blockAds']!);
|
||||||
|
await prefs.setBool('fetch_block_sponsored', config['blockSponsored']!);
|
||||||
|
await prefs.setBool('fetch_block_suggested', config['blockSuggested']!);
|
||||||
|
await prefs.setBool('fetch_block_videos', config['blockVideos']!);
|
||||||
|
await prefs.setBool('fetch_block_autoplay', config['blockAutoplay']!);
|
||||||
|
|
||||||
|
// Apply to webview
|
||||||
|
await controller.evaluateJavascript(
|
||||||
|
source: 'window.__fgSetFilterConfig?.(${jsonEncode(config)})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateFetchInterceptorConfig() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await setFetchInterceptorConfig(
|
||||||
|
blockAds: prefs.getBool('fetch_block_ads'),
|
||||||
|
blockSponsored: prefs.getBool('fetch_block_sponsored'),
|
||||||
|
blockSuggested: prefs.getBool('fetch_block_suggested'),
|
||||||
|
blockVideos: prefs.getBool('fetch_block_videos'),
|
||||||
|
blockAutoplay: prefs.getBool('fetch_block_autoplay'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Autoplay blocker configuration ─────────────────────────────────────────
|
||||||
|
Future<void> setAutoplayBlockerEnabled(bool enabled) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('autoplay_blocker_enabled', enabled);
|
||||||
|
|
||||||
|
// Apply to webview
|
||||||
|
await controller.evaluateJavascript(
|
||||||
|
source: 'window.__fgSetBlockAutoplay?.(${jsonEncode(enabled)})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateAutoplayBlockerConfig() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await setAutoplayBlockerEnabled(
|
||||||
|
prefs.getBool('autoplay_blocker_enabled') ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Online status hide ─────────────────────────────────────────────────────
|
// ── Online status hide ─────────────────────────────────────────────────────
|
||||||
Future<void> setOnlineHide(bool enabled) async {
|
Future<void> setOnlineHide(bool enabled) async {
|
||||||
await prefs.setBool('ghost_online_hide', enabled);
|
await prefs.setBool('ghost_online_hide', enabled);
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
await controller.evaluateJavascript(
|
await controller.evaluateJavascript(
|
||||||
source: 'window.__fgEnableOnlineHide?.()');
|
source: 'window.__fgEnableOnlineHide?.()',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await controller.evaluateJavascript(
|
await controller.evaluateJavascript(
|
||||||
source: 'window.__fgDisableOnlineHide?.()');
|
source: 'window.__fgDisableOnlineHide?.()',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-21
@@ -3,7 +3,6 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|||||||
enum ScriptId {
|
enum ScriptId {
|
||||||
ghostMode,
|
ghostMode,
|
||||||
themeDetector,
|
themeDetector,
|
||||||
adBlockerDom,
|
|
||||||
contentHider,
|
contentHider,
|
||||||
fetchInterceptor,
|
fetchInterceptor,
|
||||||
autoplayBlocker,
|
autoplayBlocker,
|
||||||
@@ -32,18 +31,11 @@ class InstaScript {
|
|||||||
class ScriptRegistry {
|
class ScriptRegistry {
|
||||||
static final List<InstaScript> all = [
|
static final List<InstaScript> all = [
|
||||||
// ── DOCUMENT_START — must be before IG's JS loads ──
|
// ── DOCUMENT_START — must be before IG's JS loads ──
|
||||||
InstaScript(
|
|
||||||
id: ScriptId.ghostMode,
|
|
||||||
name: 'Ghost Mode',
|
|
||||||
description: 'Blocks story seen, message seen, and online status signals.',
|
|
||||||
assetPath: 'assets/scripts/ghost_mode.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
InstaScript(
|
InstaScript(
|
||||||
id: ScriptId.fetchInterceptor,
|
id: ScriptId.fetchInterceptor,
|
||||||
name: 'Fetch Interceptor',
|
name: 'Ad & Content Blocker',
|
||||||
description: 'Unified feed filter: blocks ads, sponsored, suggested, videos via GraphQL interception.',
|
description:
|
||||||
|
'Blocks ads, sponsored, suggested content, videos, and prevents autoplay via GraphQL interception.',
|
||||||
assetPath: 'assets/scripts/fetch_interceptor.js',
|
assetPath: 'assets/scripts/fetch_interceptor.js',
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -66,14 +58,6 @@ class ScriptRegistry {
|
|||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||||
enabled: true, // always on — needed for native feel
|
enabled: true, // always on — needed for native feel
|
||||||
),
|
),
|
||||||
InstaScript(
|
|
||||||
id: ScriptId.adBlockerDom,
|
|
||||||
name: 'DOM Ad Blocker',
|
|
||||||
description: 'Removes sponsored posts and tracking elements from feed (legacy - use Fetch Interceptor instead).',
|
|
||||||
assetPath: 'assets/scripts/ad_blocker_dom.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
InstaScript(
|
InstaScript(
|
||||||
id: ScriptId.contentHider,
|
id: ScriptId.contentHider,
|
||||||
name: 'Content Hider',
|
name: 'Content Hider',
|
||||||
@@ -100,6 +84,4 @@ class ScriptRegistry {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
static InstaScript byId(ScriptId id) => all.firstWhere((s) => s.id == id);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-19
@@ -8,7 +8,8 @@ class SystemUiManager {
|
|||||||
try {
|
try {
|
||||||
final data = jsonDecode(jsonPayload) as Map<String, dynamic>;
|
final data = jsonDecode(jsonPayload) as Map<String, dynamic>;
|
||||||
final isDark = data['isDark'] as bool? ?? false;
|
final isDark = data['isDark'] as bool? ?? false;
|
||||||
final bodyHex = data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
|
final bodyHex =
|
||||||
|
data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
|
||||||
final navHex = data['navHex'] as String? ?? bodyHex;
|
final navHex = data['navHex'] as String? ?? bodyHex;
|
||||||
|
|
||||||
final bodyColor = _parseHex(bodyHex);
|
final bodyColor = _parseHex(bodyHex);
|
||||||
@@ -20,8 +21,9 @@ class SystemUiManager {
|
|||||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||||
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
|
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
|
||||||
systemNavigationBarColor: navColor,
|
systemNavigationBarColor: navColor,
|
||||||
systemNavigationBarIconBrightness:
|
systemNavigationBarIconBrightness: isDark
|
||||||
isDark ? Brightness.light : Brightness.dark,
|
? Brightness.light
|
||||||
|
: Brightness.dark,
|
||||||
systemNavigationBarDividerColor: Colors.transparent,
|
systemNavigationBarDividerColor: Colors.transparent,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -33,25 +35,29 @@ class SystemUiManager {
|
|||||||
|
|
||||||
// ── Fallback presets ─────────────────────────────────────────────────────
|
// ── Fallback presets ─────────────────────────────────────────────────────
|
||||||
static void applyLight() {
|
static void applyLight() {
|
||||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
statusBarColor: Color(0xFFFFFFFF),
|
const SystemUiOverlayStyle(
|
||||||
statusBarIconBrightness: Brightness.dark,
|
statusBarColor: Color(0xFFFFFFFF),
|
||||||
statusBarBrightness: Brightness.light,
|
statusBarIconBrightness: Brightness.dark,
|
||||||
systemNavigationBarColor: Color(0xFFFFFFFF),
|
statusBarBrightness: Brightness.light,
|
||||||
systemNavigationBarIconBrightness: Brightness.dark,
|
systemNavigationBarColor: Color(0xFFFFFFFF),
|
||||||
systemNavigationBarDividerColor: Colors.transparent,
|
systemNavigationBarIconBrightness: Brightness.dark,
|
||||||
));
|
systemNavigationBarDividerColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void applyDark() {
|
static void applyDark() {
|
||||||
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
statusBarColor: Color(0xFF000000),
|
const SystemUiOverlayStyle(
|
||||||
statusBarIconBrightness: Brightness.light,
|
statusBarColor: Color(0xFF000000),
|
||||||
statusBarBrightness: Brightness.dark,
|
statusBarIconBrightness: Brightness.light,
|
||||||
systemNavigationBarColor: Color(0xFF000000),
|
statusBarBrightness: Brightness.dark,
|
||||||
systemNavigationBarIconBrightness: Brightness.light,
|
systemNavigationBarColor: Color(0xFF000000),
|
||||||
systemNavigationBarDividerColor: Colors.transparent,
|
systemNavigationBarIconBrightness: Brightness.light,
|
||||||
));
|
systemNavigationBarDividerColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edge-to-edge setup — call once in main() ─────────────────────────────
|
// ── Edge-to-edge setup — call once in main() ─────────────────────────────
|
||||||
|
|||||||
+80
-83
@@ -11,110 +11,107 @@ class WebViewConfig {
|
|||||||
|
|
||||||
// ── Base InAppWebView settings ────────────────────────────────────────────
|
// ── Base InAppWebView settings ────────────────────────────────────────────
|
||||||
static InAppWebViewSettings get settings => InAppWebViewSettings(
|
static InAppWebViewSettings get settings => InAppWebViewSettings(
|
||||||
// Identity
|
// Identity
|
||||||
userAgent: userAgent,
|
userAgent: userAgent,
|
||||||
|
|
||||||
// Performance
|
// Performance
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
// useHybridComposition: false breaks some Android 12+ devices — keep true
|
// useHybridComposition: false breaks some Android 12+ devices — keep true
|
||||||
useHybridComposition: true,
|
useHybridComposition: true,
|
||||||
cacheEnabled: true,
|
cacheEnabled: true,
|
||||||
cacheMode: CacheMode.LOAD_DEFAULT,
|
cacheMode: CacheMode.LOAD_DEFAULT,
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
mediaPlaybackRequiresUserGesture: false,
|
mediaPlaybackRequiresUserGesture: false,
|
||||||
allowsInlineMediaPlayback: true,
|
allowsInlineMediaPlayback: true,
|
||||||
allowsPictureInPictureMediaPlayback: true,
|
allowsPictureInPictureMediaPlayback: true,
|
||||||
|
|
||||||
// UX — feel like native, not browser
|
// UX — feel like native, not browser
|
||||||
overScrollMode: OverScrollMode.NEVER,
|
overScrollMode: OverScrollMode.NEVER,
|
||||||
verticalScrollBarEnabled: false,
|
verticalScrollBarEnabled: false,
|
||||||
horizontalScrollBarEnabled: false,
|
horizontalScrollBarEnabled: false,
|
||||||
supportZoom: false,
|
supportZoom: false,
|
||||||
builtInZoomControls: false,
|
builtInZoomControls: false,
|
||||||
displayZoomControls: false,
|
displayZoomControls: false,
|
||||||
scrollsToTop: true,
|
scrollsToTop: true,
|
||||||
|
|
||||||
// JS & storage — IG needs all of these
|
// JS & storage — IG needs all of these
|
||||||
javaScriptEnabled: true,
|
javaScriptEnabled: true,
|
||||||
javaScriptCanOpenWindowsAutomatically: false,
|
javaScriptCanOpenWindowsAutomatically: false,
|
||||||
domStorageEnabled: true,
|
domStorageEnabled: true,
|
||||||
databaseEnabled: true,
|
databaseEnabled: true,
|
||||||
allowFileAccessFromFileURLs: false,
|
allowFileAccessFromFileURLs: false,
|
||||||
allowUniversalAccessFromFileURLs: false,
|
allowUniversalAccessFromFileURLs: false,
|
||||||
|
|
||||||
// Compat
|
// Compat
|
||||||
mixedContentMode: MixedContentMode.COMPATIBILITY_MODE,
|
mixedContentMode: MixedContentMode.COMPATIBILITY_MODE,
|
||||||
safeBrowsingEnabled: false, // IG known-safe domain, no need for extra latency
|
safeBrowsingEnabled:
|
||||||
|
false, // IG known-safe domain, no need for extra latency
|
||||||
|
// Disable Chrome custom tabs popup (links open in WebView)
|
||||||
|
suppressesIncrementalRendering: false,
|
||||||
|
|
||||||
// Disable Chrome custom tabs popup (links open in WebView)
|
// iOS specific
|
||||||
suppressesIncrementalRendering: false,
|
allowsBackForwardNavigationGestures: true,
|
||||||
|
allowsLinkPreview: false,
|
||||||
|
isFraudulentWebsiteWarningEnabled: false,
|
||||||
|
|
||||||
// iOS specific
|
// Android specific
|
||||||
allowsBackForwardNavigationGestures: true,
|
forceDark: ForceDark.AUTO, // respect system dark mode
|
||||||
allowsLinkPreview: false,
|
algorithmicDarkeningAllowed: true,
|
||||||
isFraudulentWebsiteWarningEnabled: false,
|
);
|
||||||
|
|
||||||
// Android specific
|
|
||||||
forceDark: ForceDark.AUTO, // respect system dark mode
|
|
||||||
algorithmicDarkeningAllowed: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── ContentBlocker rules — ad network blocking ─────────────────────────
|
// ── ContentBlocker rules — ad network blocking ─────────────────────────
|
||||||
// These are baked-in rules targeting known ad/tracking domains.
|
// These are baked-in rules targeting known ad/tracking domains.
|
||||||
// Full EasyList parsing is handled separately and merged at runtime.
|
// Full EasyList parsing is handled separately and merged at runtime.
|
||||||
// This set is always-on regardless of user toggle.
|
// This set is always-on regardless of user toggle.
|
||||||
static List<ContentBlocker> get baseContentBlockers => [
|
static List<ContentBlocker> get baseContentBlockers => [
|
||||||
// Meta ad infrastructure
|
// Meta ad infrastructure
|
||||||
_block('.*connect\\.facebook\\.net.*'),
|
_block('.*connect\\.facebook\\.net.*'),
|
||||||
_block('.*graph\\.facebook\\.com.*ads.*'),
|
_block('.*graph\\.facebook\\.com.*ads.*'),
|
||||||
_block('.*an\\.facebook\\.com.*'),
|
_block('.*an\\.facebook\\.com.*'),
|
||||||
|
|
||||||
// Google ad networks
|
// Google ad networks
|
||||||
_block('.*doubleclick\\.net.*'),
|
_block('.*doubleclick\\.net.*'),
|
||||||
_block('.*googleadservices\\.com.*'),
|
_block('.*googleadservices\\.com.*'),
|
||||||
_block('.*googlesyndication\\.com.*'),
|
_block('.*googlesyndication\\.com.*'),
|
||||||
_block('.*adservice\\.google\\..*'),
|
_block('.*adservice\\.google\\..*'),
|
||||||
|
|
||||||
// Common trackers
|
// Common trackers
|
||||||
_block('.*scorecardresearch\\.com.*'),
|
_block('.*scorecardresearch\\.com.*'),
|
||||||
_block('.*quantserve\\.com.*'),
|
_block('.*quantserve\\.com.*'),
|
||||||
_block('.*chartbeat\\.com.*'),
|
_block('.*chartbeat\\.com.*'),
|
||||||
_block('.*newrelic\\.com.*'),
|
_block('.*newrelic\\.com.*'),
|
||||||
|
|
||||||
// Ad servers
|
// Ad servers
|
||||||
_block('.*ads\\.yahoo\\.com.*'),
|
_block('.*ads\\.yahoo\\.com.*'),
|
||||||
_block('.*advertising\\.com.*'),
|
_block('.*advertising\\.com.*'),
|
||||||
_block('.*adnxs\\.com.*'),
|
_block('.*adnxs\\.com.*'),
|
||||||
_block('.*adsrvr\\.org.*'),
|
_block('.*adsrvr\\.org.*'),
|
||||||
_block('.*taboola\\.com.*'),
|
_block('.*taboola\\.com.*'),
|
||||||
_block('.*outbrain\\.com.*'),
|
_block('.*outbrain\\.com.*'),
|
||||||
_block('.*pubmatic\\.com.*'),
|
_block('.*pubmatic\\.com.*'),
|
||||||
_block('.*rubiconproject\\.com.*'),
|
_block('.*rubiconproject\\.com.*'),
|
||||||
_block('.*openx\\.net.*'),
|
_block('.*openx\\.net.*'),
|
||||||
_block('.*casalemedia\\.com.*'),
|
_block('.*casalemedia\\.com.*'),
|
||||||
_block('.*criteo\\.com.*'),
|
_block('.*criteo\\.com.*'),
|
||||||
_block('.*criteo\\.net.*'),
|
_block('.*criteo\\.net.*'),
|
||||||
|
|
||||||
// Pixel trackers
|
// Pixel trackers
|
||||||
_block('.*pixel\\.quantserve\\.com.*'),
|
_block('.*pixel\\.quantserve\\.com.*'),
|
||||||
_block('.*pixel\\.facebook\\.com.*'),
|
_block('.*pixel\\.facebook\\.com.*'),
|
||||||
|
|
||||||
// IG-specific ad endpoints (safe to block — don't affect core IG)
|
// IG-specific ad endpoints (safe to block — don't affect core IG)
|
||||||
_block('.*\\.instagram\\.com.*\\/ads\\/.*'),
|
_block('.*\\.instagram\\.com.*\\/ads\\/.*'),
|
||||||
];
|
];
|
||||||
|
|
||||||
static ContentBlocker _block(String pattern) => ContentBlocker(
|
static ContentBlocker _block(String pattern) => ContentBlocker(
|
||||||
trigger: ContentBlockerTrigger(urlFilter: pattern),
|
trigger: ContentBlockerTrigger(urlFilter: pattern),
|
||||||
action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
|
action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── URLRequest for initial load ───────────────────────────────────────────
|
// ── URLRequest for initial load ───────────────────────────────────────────
|
||||||
static URLRequest get initialRequest => URLRequest(
|
static URLRequest get initialRequest => URLRequest(
|
||||||
url: WebUri(instagramUrl),
|
url: WebUri(instagramUrl),
|
||||||
headers: {
|
headers: {'Accept-Language': 'en-US,en;q=0.9', 'DNT': '1'},
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
);
|
||||||
'DNT': '1',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user