mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-07-04 10:17:52 +02:00
Compare commits
7 Commits
v1.1.0
...
backup-rewrite
| Author | SHA1 | Date | |
|---|---|---|---|
| 842dc70829 | |||
| 2d33dcb889 | |||
| 4f63e784ac | |||
| a504c51ac5 | |||
| 5fafb9f142 | |||
| d2a0294ab3 | |||
| 2e3823cdf7 |
-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
|
||||
@@ -1,54 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '15 14 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: java-kotlin
|
||||
build-mode: manual
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.38.7'
|
||||
channel: 'stable'
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Build Android (for CodeQL)
|
||||
run: flutter build apk --debug
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,143 @@
|
||||
name: Build APK and Create GitHub Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version without leading v. Example: 1.0.0. Leave empty to read the top CHANGELOG heading."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v5.2.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v4.0.1
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2.23.0
|
||||
with:
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
- name: Install required Android SDK packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-35" \
|
||||
"build-tools;34.0.0" \
|
||||
"build-tools;35.0.0" \
|
||||
"ndk;28.2.12676356"
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Resolve version and tag
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
INPUT_VERSION="${{ github.event.inputs.version }}"
|
||||
|
||||
if [[ -n "${INPUT_VERSION}" ]]; then
|
||||
VERSION="${INPUT_VERSION#v}"
|
||||
else
|
||||
VERSION="$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
m = re.search(r'^##\s+FocusGram\s+([0-9]+\.[0-9]+\.[0-9]+)\s*$', text, re.M)
|
||||
if not m:
|
||||
raise SystemExit("Could not find a top changelog heading like: ## FocusGram 2.0.0")
|
||||
print(m.group(1))
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
|
||||
TAG="v${VERSION}"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract release notes from CHANGELOG.md
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
|
||||
version = os.environ["VERSION"]
|
||||
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
|
||||
pattern = rf'(?ms)^##\s+FocusGram\s+{re.escape(version)}\s*$.*?(?=^##\s+|\Z)'
|
||||
m = re.search(pattern, text)
|
||||
if not m:
|
||||
raise SystemExit(f"Could not find changelog section for version {version}")
|
||||
|
||||
Path("release_notes.md").write_text(m.group(0).strip() + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
- name: Build release APK
|
||||
run: flutter build apk --release
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: focusgram-apk-${{ steps.meta.outputs.tag }}
|
||||
path: build/app/outputs/flutter-apk/app-release.apk
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Git tag
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ steps.meta.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
echo "Tag already exists on remote: ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git tag -a "${TAG}" -m "Release ${TAG}"
|
||||
git push origin "${TAG}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3.0.0
|
||||
with:
|
||||
tag_name: ${{ steps.meta.outputs.tag }}
|
||||
name: FocusGram ${{ steps.meta.outputs.tag }}
|
||||
body_path: release_notes.md
|
||||
files: build/app/outputs/flutter-apk/app-release.apk
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,12 +0,0 @@
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.38.7'
|
||||
+3
-2
@@ -14,6 +14,9 @@ migrate_working_dir/
|
||||
PRD.md
|
||||
.agents/
|
||||
TODO.md
|
||||
v2/FOCUSGRAM_V2_PLAN.md
|
||||
v2/FocusGram_Feed_Filtering_Reference.docx
|
||||
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
@@ -25,9 +28,7 @@ TODO.md
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
RELEASE_GUIDE.md
|
||||
android/key.properties
|
||||
android/fdroid-config.properties
|
||||
android/app/*.jks
|
||||
upload-keystore.jks
|
||||
|
||||
|
||||
+18
-8
@@ -1,13 +1,23 @@
|
||||
## FocusGram 1.0.0
|
||||
## FocusGram 2.0.0
|
||||
|
||||
### What's new
|
||||
- Reordered Settings Page.
|
||||
- Added "Click to Unblur" for posts.
|
||||
- Added Persistent Notification
|
||||
- Improved Grayscale Scheduling.
|
||||
|
||||
- NEW: Added Media Downloader for downloading images and videos
|
||||
- NEW: Added Ghost Mode
|
||||
- NEW: Added a toggle for scroll lock in minimal mode
|
||||
- NEW: Added Option to Choose Duration of Mindfulness Gate
|
||||
- NEW: Added ability to customize number of words in typing challenge
|
||||
- UPDATED: Redesigned Focus Control Flyout
|
||||
- UPDATED: Settings and Reordered items
|
||||
- UPDATED: Added more time Choices for reels session
|
||||
- UPDATED: Improved Permission Request invocation in onboarding page.
|
||||
- UPDATED: Improved Notification Alerts
|
||||
|
||||
|
||||
|
||||
### Bug fixes
|
||||
- Fixed a Bug Where Reels Werent playing despite Reels Sessions being ON.
|
||||
- Fixed a Bug Where Session End Popup could be just dismissed and app ran Normally despite session already ended.
|
||||
- Fixed: back button on homepage didnt exit the app.
|
||||
- Fixed: Only First image of multiple imaged posts was blurred.
|
||||
- FIxed: Couldn't scroll the home feed after enabling minimal mode
|
||||
- Perfomance Optimizations
|
||||
- Other Minor Changes.
|
||||
- A lof of other Minor fixes .
|
||||
@@ -7,12 +7,12 @@
|
||||
**Use social media on your terms.**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://flutter.dev)
|
||||
[](https://github.com/ujwal223/focusgram/releases)
|
||||
[](https://flutter.dev)
|
||||
[](https://github.com/ujwal223/focusgram/releases)
|
||||
|
||||
<a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram' >
|
||||
<img src='https://stc.utdstc.com/img/mediakit/download-gio-small.png' alt='Download FocusGram'>
|
||||
</a>
|
||||
<a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram'>
|
||||
<img src='https://stc.utdstc.com/img/mediakit/download-gio-small.png' alt='Download FocusGram on Uptodown'>
|
||||
</a>
|
||||
|
||||
[Download APK](https://github.com/ujwal223/focusgram/releases) · [View Changelog](CHANGELOG.md) · [Report a Bug](https://github.com/ujwal223/focusgram/issues/new)
|
||||
|
||||
@@ -24,8 +24,11 @@ Most people don't want to quit Instagram. They want to check their messages, pos
|
||||
|
||||
FocusGram is an Android app that loads the Instagram website with the distracting parts removed. No private APIs. No data collection. No accounts. Just a cleaner way to use a platform you already use.
|
||||
|
||||
<img width="1920" height="1080" alt="FocusGram App Screenshots" src="https://github.com/user-attachments/assets/cffd4012-4cf3-4ba8-aa1a-883e1f85478e" />
|
||||
> FocusGram is free and always will be. If it's saved you some time, show your support by buying me a momo 👉👈.
|
||||
>
|
||||
> [](https://buymemomo.com/ujwal)
|
||||
|
||||
<img width="1920" height="1080" alt="FocusGram App Screenshots" src="https://github.com/user-attachments/assets/cffd4012-4cf3-4ba8-aa1a-883e1f85478e" />
|
||||
|
||||
---
|
||||
|
||||
@@ -40,8 +43,8 @@ FocusGram is an Android app that loads the Instagram website with the distractin
|
||||
**Content filtering**
|
||||
|
||||
- Hide the Explore tab, Reels tab, or Shop tab individually
|
||||
- Disable Explore and suggested content entirely
|
||||
- Disable Reels Entirely
|
||||
- Disable Explore and blur posts entirely
|
||||
- Disable Reels entirely
|
||||
|
||||
**Habit tools**
|
||||
|
||||
@@ -51,7 +54,7 @@ FocusGram is an Android app that loads the Instagram website with the distractin
|
||||
|
||||
**The app itself**
|
||||
|
||||
- Feels (almost) like a native app, not a browser.
|
||||
- Feels (almost) like a native app, not a browser
|
||||
- No blank loading screen — content loads in the background before you get there
|
||||
- Instant updates via pull-to-refresh
|
||||
- Dark mode follows your system
|
||||
@@ -66,10 +69,10 @@ FocusGram is an Android app that loads the Instagram website with the distractin
|
||||
3. Open the file and allow "Install from unknown sources" if prompted
|
||||
|
||||
### Uptodown
|
||||
Go to the [Focusgram - Uptodown](https://focusgram.en.uptodown.com/android) page<br>
|
||||
2. Click "Get the Latest Version"<br>
|
||||
3. Click "Download"<br>
|
||||
3. Open the file and allow "Install from unknown sources" if prompted
|
||||
1. Go to the [FocusGram on Uptodown](https://focusgram.en.uptodown.com/android) page
|
||||
2. Click "Get the Latest Version"
|
||||
3. Click "Download"
|
||||
4. Open the file and allow "Install from unknown sources" if prompted
|
||||
|
||||
---
|
||||
|
||||
@@ -81,14 +84,13 @@ FocusGram has no access to your Instagram account credentials. It loads `instagr
|
||||
- No crash reporting
|
||||
- No third-party SDKs
|
||||
- No data leaves your device
|
||||
- All settings and history are stored locally using Android's standard storage APIs
|
||||
|
||||
---
|
||||
|
||||
## Frequently asked questions
|
||||
|
||||
**Will this get my account banned?**
|
||||
Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials. See the technical details below for specifics.
|
||||
Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials.
|
||||
|
||||
**Is this a mod of Instagram's app?**
|
||||
No. FocusGram is a separate app that loads `instagram.com` in a WebView. It does not modify Instagram's APK or use any of Meta's proprietary code.
|
||||
@@ -112,8 +114,7 @@ Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](htt
|
||||
- JDK 17 (Eclipse Adoptium 17.0.17+)
|
||||
|
||||
### Build
|
||||
```
|
||||
bash
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter build apk --release
|
||||
```
|
||||
@@ -124,9 +125,8 @@ FocusGram uses a standard Android System WebView to load `instagram.com`. All fe
|
||||
- CSS injection (element hiding, grayscale, scroll behaviour)
|
||||
- URL interception via NavigationDelegate (Reels blocking, Explore blocking)
|
||||
|
||||
Nothing is modified server-side. The app never reads, intercepts, or stores Instagram content beyond what is explicitly listed (Reel URL, title, and thumbnail URL for the local history feature).
|
||||
|
||||
### Permissions
|
||||
|
||||
| Permission | Reason |
|
||||
|---|---|
|
||||
| `INTERNET` | Load instagram.com |
|
||||
@@ -135,6 +135,7 @@ Nothing is modified server-side. The app never reads, intercepts, or stores Inst
|
||||
| `FOREGROUND_SERVICE` | Run background service for session tracking |
|
||||
|
||||
### Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Framework | Flutter (Dart) |
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- v2/**
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
|
||||
Copyright (c) 2005-2014, The Android Open Source Project
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
@@ -45,7 +45,7 @@ android {
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 4
|
||||
versionName = "1.1.0"
|
||||
versionName = "2.0.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
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') && !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
|
||||
@@ -92,16 +92,16 @@ class _SkeletonScreenState extends State<SkeletonScreen>
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
SizedBox(
|
||||
height: 80,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: 6,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
@@ -111,13 +111,13 @@ class _SkeletonScreenState extends State<SkeletonScreen>
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 8,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -35,12 +35,11 @@ class NativeBottomNav extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
final bgColor =
|
||||
theme.colorScheme.surface.withValues(alpha: isDark ? 0.95 : 0.98);
|
||||
final iconColorInactive =
|
||||
isDark ? Colors.white70 : Colors.black54;
|
||||
final iconColorActive =
|
||||
theme.colorScheme.primary;
|
||||
final bgColor = theme.colorScheme.surface.withValues(
|
||||
alpha: isDark ? 0.95 : 0.98,
|
||||
);
|
||||
final iconColorInactive = isDark ? Colors.white70 : Colors.black54;
|
||||
final iconColorActive = theme.colorScheme.primary;
|
||||
|
||||
final tabs = <_NavItem>[
|
||||
_NavItem(
|
||||
@@ -103,8 +102,7 @@ class NativeBottomNav extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: tabs.map((item) {
|
||||
final color =
|
||||
item.active ? iconColorActive : iconColorInactive;
|
||||
final color = item.active ? iconColorActive : iconColorInactive;
|
||||
final opacity = item.enabled ? 1.0 : 0.35;
|
||||
|
||||
return Expanded(
|
||||
@@ -129,10 +127,7 @@ class NativeBottomNav extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: color,
|
||||
),
|
||||
style: TextStyle(fontSize: 10, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -164,4 +159,3 @@ class _NavItem {
|
||||
required this.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,10 @@ class InstagramPreloader {
|
||||
|
||||
static Future<void> start(String userAgent) async {
|
||||
if (_headlessWebView != null) return; // don't start twice
|
||||
|
||||
|
||||
_headlessWebView = HeadlessInAppWebView(
|
||||
keepAlive: keepAlive,
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri('https://www.instagram.com/'),
|
||||
),
|
||||
initialUrlRequest: URLRequest(url: WebUri('https://www.instagram.com/')),
|
||||
initialSettings: InAppWebViewSettings(
|
||||
userAgent: userAgent,
|
||||
mediaPlaybackRequiresUserGesture: true,
|
||||
@@ -69,4 +67,3 @@ class InstagramPreloader {
|
||||
isReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@ class ReelsHistoryEntry {
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'title': title,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
'visitedAt': visitedAt.toUtc().toIso8601String(),
|
||||
};
|
||||
'id': id,
|
||||
'url': url,
|
||||
'title': title,
|
||||
'thumbnailUrl': thumbnailUrl,
|
||||
'visitedAt': visitedAt.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
static ReelsHistoryEntry fromJson(Map<String, dynamic> json) {
|
||||
return ReelsHistoryEntry(
|
||||
@@ -31,7 +31,8 @@ class ReelsHistoryEntry {
|
||||
url: (json['url'] as String?) ?? '',
|
||||
title: (json['title'] as String?) ?? 'Instagram Reel',
|
||||
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
|
||||
visitedAt: DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
|
||||
visitedAt:
|
||||
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
|
||||
DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
@@ -114,4 +115,3 @@ class ReelsHistoryService {
|
||||
await prefs.setString(_prefsKey, jsonEncode(jsonList));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,10 +32,7 @@ class _UpdateBannerState extends State<UpdateBanner> {
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
@@ -121,10 +118,11 @@ class _UpdateBannerState extends State<UpdateBanner> {
|
||||
text = text.replaceAll(RegExp(r'#{1,6}\s'), '');
|
||||
text = text.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'\1');
|
||||
text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1');
|
||||
text =
|
||||
text.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'\1'); // links -> text
|
||||
text = text.replaceAll(
|
||||
RegExp(r'\[([^\]]+)\]\([^)]+\)'),
|
||||
r'\1',
|
||||
); // links -> text
|
||||
text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1');
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,9 @@ class UpdateCheckerService extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final cleanVersion =
|
||||
gitVersionTag.startsWith('v') ? gitVersionTag.substring(1) : gitVersionTag;
|
||||
final cleanVersion = gitVersionTag.startsWith('v')
|
||||
? gitVersionTag.substring(1)
|
||||
: gitVersionTag;
|
||||
|
||||
var trimmed = body.trim();
|
||||
if (trimmed.length > 1500) {
|
||||
|
||||
@@ -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 = true,
|
||||
this.noStories = false,
|
||||
this.noReels = false,
|
||||
this.noAutoplay = false,
|
||||
this.noDMs = false,
|
||||
});
|
||||
}
|
||||
+7
-1
@@ -17,6 +17,7 @@ import 'screens/cooldown_gate_screen.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'features/update_checker/update_checker_service.dart';
|
||||
import 'features/preloader/instagram_preloader.dart';
|
||||
import 'widgets/remote_popup_handler.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -88,7 +89,7 @@ class FocusGramApp extends StatelessWidget {
|
||||
/// 2. Cooldown Gate (if app-open cooldown active)
|
||||
/// 3. Breath Gate (if enabled in settings)
|
||||
/// 4. If an app session is already active, resume it
|
||||
/// otherwise show App Session Picker
|
||||
/// otherwise show App Session Picker
|
||||
/// 5. Main WebView
|
||||
class InitialRouteHandler extends StatefulWidget {
|
||||
const InitialRouteHandler({super.key});
|
||||
@@ -108,6 +109,10 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||
super.initState();
|
||||
_appLinks = AppLinks();
|
||||
_initDeepLinks();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
RemotePopupHandler.checkAndShow(context);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initDeepLinks() async {
|
||||
@@ -145,6 +150,7 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
||||
// Step 3: Breath gate
|
||||
if (settings.showBreathGate && !_breathCompleted) {
|
||||
return BreathGateScreen(
|
||||
durationSeconds: settings.breathGateSeconds,
|
||||
onFinish: () => setState(() => _breathCompleted = true),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,25 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
||||
55,
|
||||
60,
|
||||
];
|
||||
int _selectedIndex = 2; // default: 15 min
|
||||
int _selectedIndex = 0; // default: 5 min unless a previous choice exists
|
||||
late final FixedExtentScrollController _scrollController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final lastMinutes = context.read<SessionManager>().lastAppSessionMinutes;
|
||||
final lastIndex = _minuteOptions.indexOf(lastMinutes);
|
||||
_selectedIndex = lastIndex >= 0 ? lastIndex : 0;
|
||||
_scrollController = FixedExtentScrollController(
|
||||
initialItem: _selectedIndex,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -118,12 +136,10 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
||||
perspective: 0.003,
|
||||
squeeze: 1.1,
|
||||
diameterRatio: 2.5,
|
||||
controller: _scrollController,
|
||||
onSelectedItemChanged: (i) {
|
||||
setState(() => _selectedIndex = i);
|
||||
},
|
||||
controller: FixedExtentScrollController(
|
||||
initialItem: _selectedIndex,
|
||||
),
|
||||
childDelegate: ListWheelChildListDelegate(
|
||||
children: _minuteOptions.asMap().entries.map((entry) {
|
||||
final isSelected = entry.key == _selectedIndex;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
/// A mindfulness screen shown before the app opens.
|
||||
/// Forces the user to take a deep 10-second breath.
|
||||
/// A mindfulness screen shown before Instagram opens.
|
||||
class BreathGateScreen extends StatefulWidget {
|
||||
final VoidCallback onFinish;
|
||||
final int durationSeconds;
|
||||
|
||||
const BreathGateScreen({super.key, required this.onFinish});
|
||||
const BreathGateScreen({
|
||||
super.key,
|
||||
required this.onFinish,
|
||||
this.durationSeconds = 10,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
||||
@@ -16,15 +20,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
int _secondsRemaining = 10;
|
||||
late int _secondsRemaining;
|
||||
Timer? _timer;
|
||||
bool _canContinue = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
|
||||
|
||||
// 10-second breathing animation: 5s in, 5s out
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 5),
|
||||
@@ -71,7 +75,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Are you sure you want to open FocusGram?',
|
||||
'Are you sure you want to open Instagram?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
@@ -131,7 +135,7 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
),
|
||||
child: const Text('Continue to FocusGram'),
|
||||
child: const Text('Continue to Instagram'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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();
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.amber.withValues(alpha: 0.12)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8, top: 2),
|
||||
child: Icon(
|
||||
Icons.info_outline,
|
||||
size: 14,
|
||||
color: Colors.amber,
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'NOTE: The seen indicator is not sent to the sender while you are active in the chat, but as soon as you close and reopen the chat, the seen indicator is sent.',
|
||||
style: TextStyle(fontSize: 11, color: Colors.amber),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
/* TRIED BUT IT DIDNT WORK 98% oF THE TIME)
|
||||
|
||||
const _SectionHeader(title: 'FOCUSGRAM V2'),
|
||||
_SwitchTile(
|
||||
title: 'Ad Blocker',
|
||||
subtitle: 'Removes ads and sponsored posts',
|
||||
value: settings.v2AdBlockerDomEnabled,
|
||||
onChanged: (v) async {
|
||||
await settings.setV2AdBlockerDomEnabled(v);
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
),
|
||||
_SwitchTile(
|
||||
title: 'Block Suggested Posts',
|
||||
subtitle: 'Removes Suggested for you and recommendation units',
|
||||
value: settings.contentSuggested,
|
||||
onChanged: (v) async {
|
||||
await settings.setContentSuggestedEnabled(v);
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
),
|
||||
*/
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SwitchTile extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const _SwitchTile({
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile(
|
||||
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||
subtitle: subtitle != null
|
||||
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
|
||||
: null,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,11 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
|
||||
Future<void> Function() action,
|
||||
) async {
|
||||
if (sm.isScheduledBlockActive) {
|
||||
final ok = await DisciplineChallenge.show(context, count: 35);
|
||||
final settings = context.read<SettingsService>();
|
||||
final ok = await DisciplineChallenge.show(
|
||||
context,
|
||||
count: settings.resolvedWordChallengeCount(),
|
||||
);
|
||||
if (!context.mounted || !ok) return;
|
||||
}
|
||||
await action();
|
||||
@@ -321,7 +325,8 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final sm = context.read<SessionManager>();
|
||||
int wordCount = 15;
|
||||
final settings = context.read<SettingsService>();
|
||||
int wordCount = settings.resolvedWordChallengeCount();
|
||||
// If we are at 0 quota, increase difficulty to 35 words
|
||||
if (widget.title.contains('Daily Reel Limit') &&
|
||||
sm.dailyRemainingSeconds <= 0) {
|
||||
|
||||
+960
-252
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
|
||||
// Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications
|
||||
// Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications
|
||||
static const int _kTotalPages = 5;
|
||||
|
||||
static const int _kBlurPage = 3;
|
||||
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final List<Widget> slides = [
|
||||
// ── Page 0: Welcome ─────────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.auto_awesome,
|
||||
color: Colors.blue,
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
color: const Color(0xFF4F8DFF),
|
||||
title: 'Welcome to FocusGram',
|
||||
description:
|
||||
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
|
||||
'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.',
|
||||
),
|
||||
|
||||
// ── Page 1: Session Management ───────────────────────────────────────
|
||||
// ── Page 1: Focus controls ───────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.timer,
|
||||
color: Colors.orange,
|
||||
title: 'Session Management',
|
||||
icon: Icons.timer_outlined,
|
||||
color: const Color(0xFFFFB74D),
|
||||
title: 'Time With Intent',
|
||||
description:
|
||||
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
|
||||
'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.',
|
||||
),
|
||||
|
||||
// ── Page 2: Open links ───────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.link,
|
||||
color: Colors.cyan,
|
||||
icon: Icons.link_rounded,
|
||||
color: const Color(0xFF35C2D6),
|
||||
title: 'Open Links in FocusGram',
|
||||
description:
|
||||
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
|
||||
@@ -63,11 +63,11 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
|
||||
// ── Page 4: Notifications ────────────────────────────────────────────
|
||||
_StaticSlide(
|
||||
icon: Icons.notifications_active,
|
||||
color: Colors.green,
|
||||
title: 'Stay Notified',
|
||||
icon: Icons.notifications_active_outlined,
|
||||
color: const Color(0xFF5DD18A),
|
||||
title: 'Useful Alerts Only',
|
||||
description:
|
||||
'We need notification permissions to alert you when your session is over or a new message arrives.',
|
||||
'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.',
|
||||
isPermissionPage: true,
|
||||
permission: Permission.notification,
|
||||
),
|
||||
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const SizedBox(height: 28),
|
||||
// CTA button
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
final isBlur = _currentPage == _kBlurPage;
|
||||
|
||||
String label;
|
||||
if (isLast) {
|
||||
label = 'Get Started';
|
||||
if (isNotif) {
|
||||
label = 'Allow & Start';
|
||||
} else if (isLink) {
|
||||
label = 'Configure';
|
||||
} else if (isNotif) {
|
||||
label = 'Allow Notifications';
|
||||
} else if (isBlur) {
|
||||
label = 'Save & Continue';
|
||||
} else if (isLast) {
|
||||
label = 'Get Started';
|
||||
} else {
|
||||
label = 'Next';
|
||||
}
|
||||
@@ -143,7 +143,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
);
|
||||
} else if (isNotif) {
|
||||
await Permission.notification.request();
|
||||
await NotificationService().init();
|
||||
await NotificationService()
|
||||
.requestPermissionsNow();
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
@@ -178,9 +179,19 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
// Skip button (available on all pages except last)
|
||||
if (_currentPage < _kTotalPages - 1)
|
||||
TextButton(
|
||||
onPressed: () => _finish(context),
|
||||
onPressed: () {
|
||||
if (_currentPage == _kNotifPage) {
|
||||
_finish(context);
|
||||
} else {
|
||||
_pageController.animateToPage(
|
||||
_kTotalPages - 1,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
'Skip',
|
||||
'Skip setup',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 14),
|
||||
),
|
||||
),
|
||||
@@ -222,18 +233,27 @@ class _StaticSlide extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
|
||||
padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 120, color: color),
|
||||
const SizedBox(height: 48),
|
||||
Container(
|
||||
width: 112,
|
||||
height: 112,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: color.withValues(alpha: 0.28)),
|
||||
),
|
||||
child: Icon(icon, size: 54, color: color),
|
||||
),
|
||||
const SizedBox(height: 36),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -243,10 +263,28 @@ class _StaticSlide extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 18,
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (isPermissionPage || isAppSettingsPage) ...[
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Text(
|
||||
isPermissionPage
|
||||
? 'Permission is optional and can be changed later.'
|
||||
: 'This opens Android settings; return here when done.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -115,6 +115,7 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
||||
hideReelsTab: false,
|
||||
hideShopTab: false,
|
||||
disableReelsEntirely: false,
|
||||
blockHomeFeedScroll: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../services/session_manager.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/discipline_challenge.dart';
|
||||
|
||||
class SessionModal extends StatefulWidget {
|
||||
@@ -63,23 +64,22 @@ class _SessionModalState extends State<SessionModal> {
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [1, 5, 10, 15].map((m) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||
? null
|
||||
: () => _start(m),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white12,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text('${m}m'),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [1, 3, 5, 10, 15, 20, 30].map((m) {
|
||||
return SizedBox(
|
||||
width: 72,
|
||||
child: ElevatedButton(
|
||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||
? null
|
||||
: () => _start(m),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white12,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: Text('${m}m'),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
|
||||
Slider(
|
||||
value: _customMinutes,
|
||||
min: 1,
|
||||
max: 30,
|
||||
divisions: 29,
|
||||
max: 60,
|
||||
divisions: 59,
|
||||
label: '${_customMinutes.toInt()}m',
|
||||
onChanged: (v) => setState(() => _customMinutes = v),
|
||||
),
|
||||
@@ -126,10 +126,15 @@ class _SessionModalState extends State<SessionModal> {
|
||||
|
||||
void _start(int minutes) async {
|
||||
final sm = context.read<SessionManager>();
|
||||
final settings = context.read<SettingsService>();
|
||||
|
||||
// Always require word challenge for reel sessions (User request)
|
||||
final success = await DisciplineChallenge.show(context);
|
||||
if (!success) return;
|
||||
if (settings.requireWordChallenge) {
|
||||
final success = await DisciplineChallenge.show(
|
||||
context,
|
||||
count: settings.resolvedWordChallengeCount(),
|
||||
);
|
||||
if (!success) return;
|
||||
}
|
||||
|
||||
if (sm.startSession(minutes)) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
+422
-83
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@@ -8,6 +9,7 @@ import '../services/settings_service.dart';
|
||||
import '../services/focusgram_router.dart';
|
||||
import '../features/screen_time/screen_time_screen.dart';
|
||||
import 'guardrails_page.dart';
|
||||
import 'extras_settings_page.dart';
|
||||
|
||||
// ─── Main Settings Page ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,6 +36,7 @@ class SettingsPage extends StatelessWidget {
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
const _DonateTile(),
|
||||
_buildStatsRow(sm),
|
||||
|
||||
const _SectionHeader(title: 'FOCUS & BLOCKING'),
|
||||
@@ -43,7 +46,7 @@ class SettingsPage extends StatelessWidget {
|
||||
title: 'Focus Mode',
|
||||
subtitle: settings.minimalModeEnabled
|
||||
? 'Minimal mode on'
|
||||
: 'Blocking, friction, media',
|
||||
: 'Blocking, Content Hider, Feed Blur and more',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const FocusSettingsPage()),
|
||||
@@ -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',
|
||||
enabled: true,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ExtrasSettingsPage()),
|
||||
),
|
||||
),
|
||||
|
||||
const _SectionHeader(title: 'APPEARANCE'),
|
||||
_SubmoduleTile(
|
||||
icon: Icons.palette_outlined,
|
||||
@@ -84,7 +100,7 @@ class SettingsPage extends StatelessWidget {
|
||||
icon: Icons.lock_outline,
|
||||
iconColor: Colors.tealAccent,
|
||||
title: 'Privacy & Notifications',
|
||||
subtitle: 'Session end alerts',
|
||||
subtitle: 'Manage Your Notifications',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -264,6 +280,7 @@ class FocusSettingsPage extends StatelessWidget {
|
||||
body: ListView(
|
||||
children: [
|
||||
const _SectionHeader(title: 'BLOCKING'),
|
||||
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -293,12 +310,23 @@ class FocusSettingsPage extends StatelessWidget {
|
||||
color: Colors.redAccent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.shield_rounded, color: Colors.redAccent, size: 20),
|
||||
child: const Icon(
|
||||
Icons.shield_rounded,
|
||||
color: Colors.redAccent,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: const Text('Minimal Mode', style: TextStyle(fontSize: 15)),
|
||||
subtitle: Text(
|
||||
settings.minimalModeEnabled ? 'Enabled - tap to customize' : 'Disabled - tap to configure',
|
||||
style: TextStyle(fontSize: 12, color: settings.minimalModeEnabled ? Colors.greenAccent : Colors.grey),
|
||||
settings.minimalModeEnabled
|
||||
? 'Enabled - tap to customize'
|
||||
: 'Disabled - tap to configure',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: settings.minimalModeEnabled
|
||||
? Colors.greenAccent
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
trailing: Switch(
|
||||
value: settings.minimalModeEnabled,
|
||||
@@ -307,29 +335,60 @@ class FocusSettingsPage extends StatelessWidget {
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
),
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage())),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const MinimalModeSubmenuPage()),
|
||||
),
|
||||
),
|
||||
|
||||
const _SectionHeader(title: 'FRICTION'),
|
||||
_SwitchTile(
|
||||
title: 'Mindfulness Gate',
|
||||
subtitle: 'Breath screen before opening Instagram',
|
||||
subtitle: '${settings.breathGateSeconds}s before opening Instagram',
|
||||
value: settings.showBreathGate,
|
||||
onChanged: (v) => settings.setShowBreathGate(v),
|
||||
),
|
||||
if (settings.showBreathGate)
|
||||
_NumberEditTile(
|
||||
title: 'Gate Duration',
|
||||
label: '${settings.breathGateSeconds} seconds',
|
||||
initialValue: settings.breathGateSeconds,
|
||||
min: 3,
|
||||
max: 60,
|
||||
suffix: 'seconds',
|
||||
onSubmitted: (v) => settings.setBreathGateSeconds(v),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: 'Strict Mode (Word Challenge)',
|
||||
subtitle: 'Must type a phrase before starting a Reel session',
|
||||
title: 'Typing Challenge',
|
||||
subtitle: settings.wordChallengeCount == 0
|
||||
? 'Random: 10-35 words'
|
||||
: '${settings.wordChallengeCount} words',
|
||||
value: settings.requireWordChallenge,
|
||||
onChanged: (v) => settings.setRequireWordChallenge(v),
|
||||
),
|
||||
if (settings.requireWordChallenge)
|
||||
_ChoiceTile<int>(
|
||||
title: 'Typing Words',
|
||||
value: settings.wordChallengeCount,
|
||||
label: settings.wordChallengeCount == 0
|
||||
? 'Random (10-35)'
|
||||
: '${settings.wordChallengeCount} words',
|
||||
options: const [20, 25, 30, 35, 0],
|
||||
optionLabel: (v) => v == 0 ? 'Random (10-35)' : '$v words',
|
||||
onSelected: (v) => settings.setWordChallengeCount(v),
|
||||
),
|
||||
|
||||
const _SectionHeader(title: 'MEDIA'),
|
||||
/*
|
||||
( I TRIED SO HARD, AND GOT SO FAR, BUT IN THE END...
|
||||
IT DOESNT EVEN MATTER ..... (didnt work))
|
||||
|
||||
_SwitchTile(
|
||||
title: 'Block Autoplay Videos',
|
||||
subtitle: 'Videos won\'t play until you tap them',
|
||||
value: settings.blockAutoplay,
|
||||
onChanged: (v) => settings.setBlockAutoplay(v),
|
||||
),
|
||||
),*/
|
||||
_SwitchTile(
|
||||
title: 'Blur Feed & Explore',
|
||||
subtitle: 'Blurs post thumbnails until tapped',
|
||||
@@ -348,6 +407,16 @@ class FocusSettingsPage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
const _SectionHeader(title: 'CONTENT HIDER'),
|
||||
|
||||
_SwitchTile(
|
||||
title: 'Hide Feed Posts',
|
||||
subtitle:
|
||||
'Hides home feed posts (stories tray, posts, suggested content)',
|
||||
value: settings.contentPosts,
|
||||
onChanged: (v) => settings.setContentPostsEnabled(v),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
@@ -355,6 +424,50 @@ class FocusSettingsPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DonateTile extends StatelessWidget {
|
||||
const _DonateTile();
|
||||
|
||||
static final Uri _donateUri = Uri.parse('https://buymemomo.com/ujwal');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.pinkAccent.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.pinkAccent.withValues(alpha: 0.22)),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.pinkAccent.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Colors.pinkAccent,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: const Text(
|
||||
'Please donate to support the development of this project.',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Your support keeps FocusGram free and maintained.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: const Icon(Icons.open_in_new, size: 14),
|
||||
onTap: () =>
|
||||
launchUrl(_donateUri, mode: LaunchMode.externalApplication),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Minimal Mode Submenu ─────────────────────────────────────────────────────
|
||||
|
||||
class MinimalModeSubmenuPage extends StatefulWidget {
|
||||
@@ -368,6 +481,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
late bool _blurExplore;
|
||||
late bool _disableReelsEntirely;
|
||||
late bool _disableExploreEntirely;
|
||||
late bool _blockHomeFeedScroll;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -376,26 +490,51 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
_blurExplore = settings.blurExplore;
|
||||
_disableReelsEntirely = settings.disableReelsEntirely;
|
||||
_disableExploreEntirely = settings.disableExploreEntirely;
|
||||
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
||||
}
|
||||
|
||||
void _updateSetting(String key, bool value) {
|
||||
Future<void> _updateSetting(String key, bool value) async {
|
||||
final settings = context.read<SettingsService>();
|
||||
setState(() {
|
||||
switch (key) {
|
||||
case 'blurExplore':
|
||||
_blurExplore = value;
|
||||
settings.setBlurExplore(value);
|
||||
break;
|
||||
case 'disableReelsEntirely':
|
||||
_disableReelsEntirely = value;
|
||||
settings.setDisableReelsEntirelyInternal(value);
|
||||
break;
|
||||
case 'disableExploreEntirely':
|
||||
_disableExploreEntirely = value;
|
||||
settings.setDisableExploreEntirelyInternal(value);
|
||||
break;
|
||||
case 'blockHomeFeedScroll':
|
||||
_blockHomeFeedScroll = value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
switch (key) {
|
||||
case 'blurExplore':
|
||||
await settings.setBlurExplore(value);
|
||||
break;
|
||||
case 'disableReelsEntirely':
|
||||
await settings.setDisableReelsEntirelyInternal(value);
|
||||
break;
|
||||
case 'disableExploreEntirely':
|
||||
await settings.setDisableExploreEntirelyInternal(value);
|
||||
break;
|
||||
case 'blockHomeFeedScroll':
|
||||
await settings.setBlockHomeFeedScrollInternal(value);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final latest = context.read<SettingsService>();
|
||||
setState(() {
|
||||
_blurExplore = latest.blurExplore;
|
||||
_disableReelsEntirely = latest.disableReelsEntirely;
|
||||
_disableExploreEntirely = latest.disableExploreEntirely;
|
||||
_blockHomeFeedScroll = latest.blockHomeFeedScroll;
|
||||
});
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
|
||||
@@ -406,6 +545,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
_blurExplore = true;
|
||||
_disableReelsEntirely = true;
|
||||
_disableExploreEntirely = true;
|
||||
_blockHomeFeedScroll = true;
|
||||
});
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
@@ -418,6 +558,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
_blurExplore = settings.blurExplore;
|
||||
_disableReelsEntirely = settings.disableReelsEntirely;
|
||||
_disableExploreEntirely = settings.disableExploreEntirely;
|
||||
_blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
||||
});
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
@@ -437,61 +578,88 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: isMinimalModeEnabled
|
||||
? [Colors.redAccent.withValues(alpha: 0.2), Colors.red.withValues(alpha: 0.1)]
|
||||
: [Colors.grey.withValues(alpha: 0.1), Colors.grey.withValues(alpha: 0.05)],
|
||||
colors: isMinimalModeEnabled
|
||||
? [
|
||||
Colors.redAccent.withValues(alpha: 0.2),
|
||||
Colors.red.withValues(alpha: 0.1),
|
||||
]
|
||||
: [
|
||||
Colors.grey.withValues(alpha: 0.1),
|
||||
Colors.grey.withValues(alpha: 0.05),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isMinimalModeEnabled ? Colors.redAccent.withValues(alpha: 0.3) : Colors.grey.withValues(alpha: 0.2),
|
||||
color: isMinimalModeEnabled
|
||||
? Colors.redAccent.withValues(alpha: 0.3)
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
isMinimalModeEnabled ? Icons.shield_rounded : Icons.shield_outlined,
|
||||
isMinimalModeEnabled
|
||||
? Icons.shield_rounded
|
||||
: Icons.shield_outlined,
|
||||
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
isMinimalModeEnabled ? 'Minimal Mode Active' : 'Minimal Mode Disabled',
|
||||
isMinimalModeEnabled
|
||||
? 'Minimal Mode Active'
|
||||
: 'Minimal Mode Disabled',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isMinimalModeEnabled ? Colors.redAccent : Colors.grey,
|
||||
color: isMinimalModeEnabled
|
||||
? Colors.redAccent
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
isMinimalModeEnabled
|
||||
isMinimalModeEnabled
|
||||
? 'Distractions are blocked. Customize which features stay enabled below.'
|
||||
: 'Turn on to block all distractions at once, or customize individual settings below.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: isDark ? Colors.white54 : Colors.black54),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: isDark ? Colors.white54 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: isMinimalModeEnabled ? _turnOffMinimalMode : _turnOnMinimalMode,
|
||||
onPressed: isMinimalModeEnabled
|
||||
? _turnOffMinimalMode
|
||||
: _turnOnMinimalMode,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isMinimalModeEnabled ? Colors.grey : Colors.redAccent,
|
||||
backgroundColor: isMinimalModeEnabled
|
||||
? Colors.grey
|
||||
: Colors.redAccent,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
isMinimalModeEnabled
|
||||
? 'Turn Off Minimal Mode'
|
||||
: 'Turn On Minimal Mode',
|
||||
),
|
||||
child: Text(isMinimalModeEnabled ? 'Turn Off Minimal Mode' : 'Turn On Minimal Mode'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const _SectionHeader(title: 'CUSTOMIZE SETTINGS'),
|
||||
|
||||
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -502,7 +670,11 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.touch_app_rounded, size: 14, color: Colors.blueAccent),
|
||||
Icon(
|
||||
Icons.touch_app_rounded,
|
||||
size: 14,
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -513,13 +685,19 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
_SwitchTile(
|
||||
title: 'Blur Feed & Explore',
|
||||
subtitle: 'Blurs post thumbnails until tapped',
|
||||
value: _blurExplore,
|
||||
onChanged: (v) => _updateSetting('blurExplore', v),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: 'Block Home Feed Scroll',
|
||||
subtitle: 'Freeze vertical scrolling on the home feed only',
|
||||
value: _blockHomeFeedScroll,
|
||||
onChanged: (v) => _updateSetting('blockHomeFeedScroll', v),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: 'Disable Reels Entirely',
|
||||
subtitle: 'Block all Reels with no session option',
|
||||
@@ -532,7 +710,7 @@ class _MinimalModeSubmenuPageState extends State<MinimalModeSubmenuPage> {
|
||||
value: _disableExploreEntirely,
|
||||
onChanged: (v) => _updateSetting('disableExploreEntirely', v),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
@@ -550,43 +728,52 @@ class AppearancePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AppearancePageState extends State<AppearancePage> {
|
||||
Future<void> _addSchedule(BuildContext context, SettingsService settings) async {
|
||||
Future<void> _addSchedule(
|
||||
BuildContext context,
|
||||
SettingsService settings,
|
||||
) async {
|
||||
TimeOfDay? startTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: const TimeOfDay(hour: 21, minute: 0),
|
||||
helpText: 'Select start time',
|
||||
);
|
||||
|
||||
|
||||
if (startTime == null || !context.mounted) return;
|
||||
|
||||
|
||||
TimeOfDay? endTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: const TimeOfDay(hour: 6, minute: 0),
|
||||
helpText: 'Select end time',
|
||||
);
|
||||
|
||||
|
||||
if (endTime == null || !context.mounted) return;
|
||||
|
||||
|
||||
final newSchedule = {
|
||||
'enabled': true,
|
||||
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
|
||||
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
|
||||
'startTime':
|
||||
'${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
|
||||
'endTime':
|
||||
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
|
||||
};
|
||||
|
||||
|
||||
await settings.addGrayscaleSchedule(newSchedule);
|
||||
}
|
||||
|
||||
Future<void> _editSchedule(BuildContext context, SettingsService settings, int index) async {
|
||||
Future<void> _editSchedule(
|
||||
BuildContext context,
|
||||
SettingsService settings,
|
||||
int index,
|
||||
) async {
|
||||
final schedules = settings.grayscaleSchedules;
|
||||
if (index >= schedules.length) return;
|
||||
|
||||
|
||||
final current = schedules[index];
|
||||
final startParts = (current['startTime'] as String).split(':');
|
||||
final endParts = (current['endTime'] as String).split(':');
|
||||
|
||||
|
||||
// Capture context before async gap
|
||||
final capturedContext = context;
|
||||
|
||||
|
||||
TimeOfDay? startTime = await showTimePicker(
|
||||
context: capturedContext,
|
||||
initialTime: TimeOfDay(
|
||||
@@ -595,9 +782,9 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
),
|
||||
helpText: 'Select start time',
|
||||
);
|
||||
|
||||
|
||||
if (startTime == null || !capturedContext.mounted) return;
|
||||
|
||||
|
||||
TimeOfDay? endTime = await showTimePicker(
|
||||
context: capturedContext,
|
||||
initialTime: TimeOfDay(
|
||||
@@ -606,27 +793,31 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
),
|
||||
helpText: 'Select end time',
|
||||
);
|
||||
|
||||
|
||||
if (endTime == null || !capturedContext.mounted) return;
|
||||
|
||||
|
||||
final updatedSchedule = {
|
||||
...current,
|
||||
'startTime': '${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
|
||||
'endTime': '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
|
||||
'startTime':
|
||||
'${startTime.hour.toString().padLeft(2, '0')}:${startTime.minute.toString().padLeft(2, '0')}',
|
||||
'endTime':
|
||||
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}',
|
||||
};
|
||||
|
||||
|
||||
await settings.updateGrayscaleSchedule(index, updatedSchedule);
|
||||
}
|
||||
|
||||
Future<void> _toggleSchedule(SettingsService settings, int index) async {
|
||||
final schedules = List<Map<String, dynamic>>.from(settings.grayscaleSchedules);
|
||||
final schedules = List<Map<String, dynamic>>.from(
|
||||
settings.grayscaleSchedules,
|
||||
);
|
||||
if (index >= schedules.length) return;
|
||||
|
||||
|
||||
schedules[index] = {
|
||||
...schedules[index],
|
||||
'enabled': !(schedules[index]['enabled'] as bool),
|
||||
};
|
||||
|
||||
|
||||
await settings.setGrayscaleSchedules(schedules);
|
||||
}
|
||||
|
||||
@@ -648,7 +839,7 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
if (confirmed == true) {
|
||||
await settings.removeGrayscaleSchedule(index);
|
||||
}
|
||||
@@ -669,7 +860,8 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
const _SectionHeader(title: 'DISPLAY'),
|
||||
_SwitchTile(
|
||||
title: 'Grayscale Mode',
|
||||
subtitle: 'Makes Instagram black & white — reduces dopamine response',
|
||||
subtitle:
|
||||
'Makes Instagram black & white — reduces dopamine response',
|
||||
value: settings.grayscaleEnabled,
|
||||
onChanged: (v) => settings.setGrayscaleEnabled(v),
|
||||
),
|
||||
@@ -687,7 +879,7 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
style: TextStyle(fontSize: 12, height: 1.5),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Status indicator
|
||||
if (settings.grayscaleSchedules.isNotEmpty)
|
||||
Padding(
|
||||
@@ -695,26 +887,38 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.1) : Colors.orange.withValues(alpha: 0.1),
|
||||
color: settings.isGrayscaleActiveNow
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: settings.isGrayscaleActiveNow ? Colors.green.withValues(alpha: 0.3) : Colors.orange.withValues(alpha: 0.3)),
|
||||
border: Border.all(
|
||||
color: settings.isGrayscaleActiveNow
|
||||
? Colors.green.withValues(alpha: 0.3)
|
||||
: Colors.orange.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
settings.isGrayscaleActiveNow ? Icons.check_circle : Icons.schedule,
|
||||
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent,
|
||||
size: 20
|
||||
settings.isGrayscaleActiveNow
|
||||
? Icons.check_circle
|
||||
: Icons.schedule,
|
||||
color: settings.isGrayscaleActiveNow
|
||||
? Colors.greenAccent
|
||||
: Colors.orangeAccent,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
settings.isGrayscaleActiveNow
|
||||
? 'Grayscale is active now'
|
||||
settings.isGrayscaleActiveNow
|
||||
? 'Grayscale is active now'
|
||||
: 'Grayscale is currently inactive',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: settings.isGrayscaleActiveNow ? Colors.greenAccent : Colors.orangeAccent
|
||||
fontSize: 13,
|
||||
color: settings.isGrayscaleActiveNow
|
||||
? Colors.greenAccent
|
||||
: Colors.orangeAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -722,7 +926,7 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Schedule list
|
||||
...List.generate(settings.grayscaleSchedules.length, (index) {
|
||||
final schedule = settings.grayscaleSchedules[index];
|
||||
@@ -732,11 +936,14 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: (isEnabled ? Colors.purpleAccent : Colors.grey).withValues(alpha: 0.12),
|
||||
color: (isEnabled ? Colors.purpleAccent : Colors.grey)
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
isEnabled ? Icons.play_circle_outline : Icons.pause_circle_outline,
|
||||
isEnabled
|
||||
? Icons.play_circle_outline
|
||||
: Icons.pause_circle_outline,
|
||||
color: isEnabled ? Colors.purpleAccent : Colors.grey,
|
||||
size: 20,
|
||||
),
|
||||
@@ -750,7 +957,10 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
),
|
||||
subtitle: Text(
|
||||
isEnabled ? 'Active' : 'Disabled',
|
||||
style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.white54 : Colors.black45,
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -795,7 +1005,7 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
onTap: () => _editSchedule(context, settings, index),
|
||||
);
|
||||
}),
|
||||
|
||||
|
||||
// Add schedule button
|
||||
ListTile(
|
||||
leading: Container(
|
||||
@@ -805,16 +1015,26 @@ class _AppearancePageState extends State<AppearancePage> {
|
||||
color: Colors.green.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.add_circle_outline, color: Colors.green, size: 20),
|
||||
child: const Icon(
|
||||
Icons.add_circle_outline,
|
||||
color: Colors.green,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: const Text(
|
||||
'Add Schedule',
|
||||
style: TextStyle(color: Colors.green),
|
||||
),
|
||||
title: const Text('Add Schedule', style: TextStyle(color: Colors.green)),
|
||||
subtitle: Text(
|
||||
'Add a new grayscale schedule',
|
||||
style: TextStyle(fontSize: 12, color: isDark ? Colors.white54 : Colors.black45),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.white54 : Colors.black45,
|
||||
),
|
||||
),
|
||||
onTap: () => _addSchedule(context, settings),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
@@ -966,15 +1186,9 @@ class _SwitchTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile(
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||
subtitle: subtitle != null
|
||||
? Text(
|
||||
subtitle ?? '',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
)
|
||||
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
|
||||
: null,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
@@ -982,6 +1196,131 @@ class _SwitchTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChoiceTile<T> extends StatelessWidget {
|
||||
final String title;
|
||||
final T value;
|
||||
final String label;
|
||||
final List<T> options;
|
||||
final String Function(T value) optionLabel;
|
||||
final ValueChanged<T> onSelected;
|
||||
|
||||
const _ChoiceTile({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.options,
|
||||
required this.optionLabel,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||
subtitle: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
trailing: PopupMenuButton<T>(
|
||||
initialValue: value,
|
||||
onSelected: onSelected,
|
||||
itemBuilder: (context) => options
|
||||
.map(
|
||||
(option) => PopupMenuItem<T>(
|
||||
value: option,
|
||||
child: Text(optionLabel(option)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: const Icon(Icons.expand_more_rounded, size: 22),
|
||||
),
|
||||
onTap: () async {
|
||||
final selected = await showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: options
|
||||
.map(
|
||||
(option) => ListTile(
|
||||
title: Text(optionLabel(option)),
|
||||
trailing: option == value
|
||||
? const Icon(Icons.check_rounded)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(context, option),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selected != null) onSelected(selected);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NumberEditTile extends StatelessWidget {
|
||||
final String title;
|
||||
final String label;
|
||||
final int initialValue;
|
||||
final int min;
|
||||
final int max;
|
||||
final String suffix;
|
||||
final ValueChanged<int> onSubmitted;
|
||||
|
||||
const _NumberEditTile({
|
||||
required this.title,
|
||||
required this.label,
|
||||
required this.initialValue,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.suffix,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(title, style: const TextStyle(fontSize: 15)),
|
||||
subtitle: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
trailing: const Icon(Icons.edit_outlined, size: 20),
|
||||
onTap: () async {
|
||||
final controller = TextEditingController(text: '$initialValue');
|
||||
final result = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
suffixText: suffix,
|
||||
helperText: '$min-$max $suffix',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
final parsed = int.tryParse(controller.text.trim());
|
||||
if (parsed == null) return;
|
||||
Navigator.pop(dialogContext, parsed.clamp(min, max).toInt());
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
if (result != null) onSubmitted(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@@ -451,18 +451,40 @@ const String kHideSuggestedPostsJS = r'''
|
||||
(function() {
|
||||
function hideSuggestedPosts() {
|
||||
try {
|
||||
document.querySelectorAll('span, h3, h4').forEach(function(el) {
|
||||
// Target text patterns that indicate suggested content
|
||||
const suggestedPatterns = [
|
||||
'Suggested for you',
|
||||
'Suggested posts',
|
||||
"You're all caught up",
|
||||
'Suggested',
|
||||
'Recommendations',
|
||||
'Discover more',
|
||||
'Suggested Accounts',
|
||||
];
|
||||
|
||||
// Find and hide all elements with suggested content text
|
||||
document.querySelectorAll('span, h3, h4, h2, a').forEach(function(el) {
|
||||
try {
|
||||
const text = el.textContent.trim();
|
||||
if (
|
||||
text === 'Suggested for you' ||
|
||||
text === 'Suggested posts' ||
|
||||
text === "You're all caught up"
|
||||
) {
|
||||
const matched = suggestedPatterns.some(pattern =>
|
||||
text === pattern || text.includes(pattern)
|
||||
);
|
||||
|
||||
if (matched) {
|
||||
let parent = el.parentElement;
|
||||
for (let i = 0; i < 8 && parent; i++) {
|
||||
// Traverse up to find the container section/article
|
||||
for (let i = 0; i < 12 && parent; i++) {
|
||||
const tag = parent.tagName.toLowerCase();
|
||||
if (tag === 'article' || tag === 'section' || tag === 'li') {
|
||||
const classList = parent.className || '';
|
||||
|
||||
// Hide articles, sections, lists, and common suggestion containers
|
||||
if (
|
||||
tag === 'article' ||
|
||||
tag === 'section' ||
|
||||
tag === 'li' ||
|
||||
classList.includes('xjx87jv0') || // Instagram suggestion container
|
||||
classList.includes('x1a8lsjc') // Reel suggestion container
|
||||
) {
|
||||
parent.style.setProperty('display', 'none', 'important');
|
||||
break;
|
||||
}
|
||||
@@ -471,6 +493,21 @@ const String kHideSuggestedPostsJS = r'''
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
|
||||
// Also hide by attribute patterns
|
||||
document.querySelectorAll('[aria-label*="Suggested"], [data-testid*="suggested"]').forEach(function(el) {
|
||||
try {
|
||||
let parent = el;
|
||||
for (let i = 0; i < 12 && parent; i++) {
|
||||
const tag = parent.tagName.toLowerCase();
|
||||
if (tag === 'article' || tag === 'section' || tag === 'li') {
|
||||
parent.style.setProperty('display', 'none', 'important');
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
} catch(_) {}
|
||||
});
|
||||
} catch(_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -277,13 +277,15 @@ const String kReelsMutationObserverJS = r'''
|
||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||
|
||||
function lockMode() {
|
||||
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled with reel present
|
||||
// Lock DM reels to prevent swipe-to-next, and optionally lock the home
|
||||
// feed as a separate Minimal Mode control.
|
||||
const isDmReel = window.location.pathname.includes('/direct/') &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||
if (isDmReel) return 'dm_reel';
|
||||
// Only lock scroll when reel element is actually present on the page
|
||||
if (window.__fgDisableReelsEntirely === true &&
|
||||
!!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled';
|
||||
if (window.__fgBlockHomeFeedScroll === true &&
|
||||
(window.location.pathname === '/' || window.location.pathname === '')) {
|
||||
return 'home_feed';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -338,8 +340,7 @@ const String kReelsMutationObserverJS = r'''
|
||||
try {
|
||||
const mode = lockMode();
|
||||
const hasReel = !!document.querySelector(REEL_SEL);
|
||||
// Apply lock for dm_reel or disabled modes when reel is present
|
||||
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
|
||||
if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
|
||||
if (__fgOrigHtmlOverflow === null) {
|
||||
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
||||
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
||||
|
||||
@@ -13,4 +13,3 @@ const String kDmKeyboardFixJS = r'''
|
||||
} catch (_) {}
|
||||
});
|
||||
''';
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
}
|
||||
@@ -9,4 +9,3 @@ const String kHapticBridgeScript = '''
|
||||
}, true);
|
||||
})();
|
||||
''';
|
||||
|
||||
|
||||
@@ -10,4 +10,3 @@ const String kScrollSmoothingJS = r'''
|
||||
} catch (_) {}
|
||||
})();
|
||||
''';
|
||||
|
||||
|
||||
@@ -29,4 +29,3 @@ const String kSpaNavigationMonitorScript = '''
|
||||
window.addEventListener('popstate', () => notifyUrlChange());
|
||||
})();
|
||||
''';
|
||||
|
||||
|
||||
@@ -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:999',
|
||||
'width:34px',
|
||||
'height:34px',
|
||||
'border-radius:10px',
|
||||
'border:1px solid rgba(255,255,255,.18)',
|
||||
'background:' + (mode === 'inline' ? 'transparent' : 'rgba(0,0,0,.58)'),
|
||||
'color:rgba(255,255,255,.94)',
|
||||
'display:flex',
|
||||
'align-items:center',
|
||||
'justify-content:center',
|
||||
'padding:0',
|
||||
'cursor:pointer',
|
||||
'pointer-events:auto',
|
||||
'backdrop-filter:blur(8px)',
|
||||
'-webkit-backdrop-filter:blur(8px)',
|
||||
].join(';');
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
sendDownload(btn.getAttribute(URL_ATTR), btn.getAttribute(TYPE_ATTR) || type);
|
||||
}, true);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function ensureRelative(container) {
|
||||
try {
|
||||
const pos = window.getComputedStyle(container).position;
|
||||
if (!pos || pos === 'static') container.style.position = 'relative';
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function placeNearSave(article, url, type) {
|
||||
const ref = article.querySelector([
|
||||
'button[aria-label*="Save" i]',
|
||||
'button[aria-label*="Bookmark" i]',
|
||||
'svg[aria-label*="Save" i]',
|
||||
'svg[aria-label*="Bookmark" i]',
|
||||
'a[href*="/save"]',
|
||||
].join(','));
|
||||
if (!ref) return false;
|
||||
|
||||
const target = ref.closest('button,a,div') || ref;
|
||||
const bar = target.parentElement || article;
|
||||
if (bar.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
|
||||
|
||||
const btn = makeButton(url, type, 'inline');
|
||||
btn.style.position = 'relative';
|
||||
btn.style.inset = 'auto';
|
||||
btn.style.marginLeft = '8px';
|
||||
btn.style.color = 'currentColor';
|
||||
btn.style.border = '0';
|
||||
btn.style.backdropFilter = 'none';
|
||||
btn.style.webkitBackdropFilter = 'none';
|
||||
try {
|
||||
target.insertAdjacentElement('afterend', btn);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function placeOverlay(container, url, type, where) {
|
||||
if (!container || container.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
|
||||
ensureRelative(container);
|
||||
const btn = makeButton(url, type, 'overlay');
|
||||
if (where === 'reel') {
|
||||
btn.style.top = '12px';
|
||||
btn.style.right = '12px';
|
||||
} else if (where === 'profile') {
|
||||
btn.style.top = '8px';
|
||||
btn.style.right = '8px';
|
||||
} else {
|
||||
btn.style.right = '10px';
|
||||
btn.style.bottom = '10px';
|
||||
}
|
||||
container.appendChild(btn);
|
||||
return true;
|
||||
}
|
||||
|
||||
function visibleMedia(root) {
|
||||
return Array.prototype.slice.call(root.querySelectorAll('video,img,[style*="background-image"]'))
|
||||
.filter(inView)
|
||||
.map(function(el) {
|
||||
const url = mediaUrl(el);
|
||||
return url ? { el: el, url: url, type: typeFrom(el, url) } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function handleFeed() {
|
||||
let added = 0;
|
||||
document.querySelectorAll('article').forEach(function(article) {
|
||||
if (added >= MAX_PER_PASS || article.querySelector('[' + BTN_ATTR + '="1"]')) return;
|
||||
const media = visibleMedia(article)
|
||||
.filter(function(item) { return !looksLikeAvatar(item.el); })
|
||||
.sort(function(a, b) { return mediaScore(b) - mediaScore(a); })[0];
|
||||
if (!media) return;
|
||||
if (placeNearSave(article, media.url, media.type) ||
|
||||
placeOverlay(article, media.url, media.type, 'feed')) {
|
||||
added++;
|
||||
}
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
function handleReels() {
|
||||
let added = 0;
|
||||
visibleMedia(document).forEach(function(media) {
|
||||
if (added >= MAX_PER_PASS) return;
|
||||
const container =
|
||||
media.el.closest('[class*="ReelsVideoPlayer"]') ||
|
||||
media.el.closest('article') ||
|
||||
media.el.closest('[role="presentation"]') ||
|
||||
media.el.parentElement;
|
||||
if (placeOverlay(container, media.url, media.type, 'reel')) added++;
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
function handleDirect() {
|
||||
let added = 0;
|
||||
visibleMedia(document).forEach(function(media) {
|
||||
if (added >= MAX_PER_PASS) return;
|
||||
const bubble =
|
||||
media.el.closest('[role="button"]') ||
|
||||
media.el.closest('div[style*="max-width"]') ||
|
||||
media.el.closest('article') ||
|
||||
media.el.parentElement;
|
||||
if (placeOverlay(bubble, media.url, media.type, 'dm')) added++;
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
function handleProfile() {
|
||||
let added = 0;
|
||||
const path = window.location.pathname || '/';
|
||||
if (path === '/' || path.indexOf('/explore') === 0 || path.indexOf('/direct') === 0) return 0;
|
||||
document.querySelectorAll('header img,img[alt*="profile" i],img[alt*="avatar" i]').forEach(function(img) {
|
||||
if (added >= 4 || !inView(img)) return;
|
||||
const url = mediaUrl(img);
|
||||
if (!url) return;
|
||||
const r = img.getBoundingClientRect();
|
||||
if (r.width < 56 && r.height < 56) return;
|
||||
const container = img.closest('div') || img.parentElement;
|
||||
if (placeOverlay(container, url, 'photo', 'profile')) added++;
|
||||
});
|
||||
return added;
|
||||
}
|
||||
|
||||
function pass() {
|
||||
try {
|
||||
const path = window.location.pathname || '/';
|
||||
if (path.indexOf('/direct') === 0) {
|
||||
handleDirect();
|
||||
} else if (path.indexOf('/reels') === 0 || path.indexOf('/reel/') >= 0) {
|
||||
handleReels();
|
||||
} else {
|
||||
handleFeed();
|
||||
handleProfile();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(pass, 220);
|
||||
}
|
||||
|
||||
new MutationObserver(schedule).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['src', 'srcset', 'style'],
|
||||
});
|
||||
window.addEventListener('scroll', schedule, { passive: true });
|
||||
window.addEventListener('resize', schedule, { passive: true });
|
||||
window.addEventListener('focus', schedule, { passive: true });
|
||||
pass();
|
||||
})();
|
||||
''';
|
||||
@@ -0,0 +1,430 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class AdblockContentBlockerData {
|
||||
final List<ContentBlocker> contentBlockers;
|
||||
final Set<String> blockedHosts;
|
||||
final String sourceTag;
|
||||
|
||||
const AdblockContentBlockerData({
|
||||
required this.contentBlockers,
|
||||
required this.blockedHosts,
|
||||
required this.sourceTag,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'sourceTag': sourceTag,
|
||||
'hosts': blockedHosts.toList(),
|
||||
// We can’t safely serialize ContentBlocker objects; rebuild from hosts.
|
||||
// contentBlockers will always be regenerated from hosts when restoring.
|
||||
};
|
||||
|
||||
static AdblockContentBlockerData fromJson(Map<String, dynamic> json) {
|
||||
final hosts =
|
||||
(json['hosts'] as List?)?.whereType<String>().toSet() ?? <String>{};
|
||||
return AdblockContentBlockerData(
|
||||
contentBlockers: hosts
|
||||
.map(
|
||||
(h) => ContentBlocker(
|
||||
trigger: ContentBlockerTrigger(
|
||||
urlFilter: AdblockContentBlockerLoader._urlFilterForHost(h),
|
||||
),
|
||||
action: ContentBlockerAction(
|
||||
type: ContentBlockerActionType.BLOCK,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
blockedHosts: hosts,
|
||||
sourceTag: (json['sourceTag'] as String?) ?? 'cached',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AdblockContentBlockerLoader {
|
||||
// Cache keys
|
||||
static const _keyCache = 'adblock_cb_cache_v2';
|
||||
static const _keyCacheUpdatedAt = 'adblock_cb_cache_updated_at_v1';
|
||||
static const _keySourceCache = 'adblock_source_cache_v1';
|
||||
|
||||
static const _maxContentBlockerRules = 5000;
|
||||
|
||||
// Raw GitHub sources, intentionally split by repository sections so the app
|
||||
// follows upstream changes without depending on third-party packaged mirrors.
|
||||
static const _sources = <_SourceSpec>[
|
||||
// uBlock Origin built-in Annoyances family:
|
||||
// https://github.com/uBlockOrigin/uAssets/tree/master/filters
|
||||
_SourceSpec(
|
||||
tag: 'ublock_annoyances',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'ublock_annoyances_cookies',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-cookies.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'ublock_annoyances_others',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-others.txt',
|
||||
),
|
||||
|
||||
// EasyList network-blocking sections:
|
||||
// https://github.com/easylist/easylist/tree/master/easylist
|
||||
_SourceSpec(
|
||||
tag: 'easylist_adservers',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_adservers.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'easylist_general_block',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_block.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'easylist_specific_block',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_specific_block.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'easylist_thirdparty',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_thirdparty.txt',
|
||||
),
|
||||
|
||||
// AdGuard BaseFilter network-blocking sections:
|
||||
// https://github.com/AdguardTeam/AdguardFilters/tree/master/BaseFilter/sections
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_adservers',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_adservers_firstparty',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers_firstparty.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_antiadblock',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/antiadblock.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_cryptominers',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/cryptominers.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_general_url',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/general_url.txt',
|
||||
),
|
||||
_SourceSpec(
|
||||
tag: 'adguard_base_specific',
|
||||
url:
|
||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/specific.txt',
|
||||
),
|
||||
];
|
||||
|
||||
Future<AdblockContentBlockerData> loadOrUpdateIfNeeded({
|
||||
required bool enabled,
|
||||
required SharedPreferences prefs,
|
||||
int timeoutMs = 8000,
|
||||
}) async {
|
||||
if (!enabled) {
|
||||
return const AdblockContentBlockerData(
|
||||
contentBlockers: [],
|
||||
blockedHosts: {},
|
||||
sourceTag: 'disabled',
|
||||
);
|
||||
}
|
||||
|
||||
final cachedData = _readCachedData(prefs);
|
||||
final sourceCache = _readSourceCache(prefs);
|
||||
|
||||
final fetchResults = await _fetchAllSources(
|
||||
cache: sourceCache,
|
||||
timeoutMs: timeoutMs,
|
||||
);
|
||||
|
||||
if (fetchResults.isEmpty && cachedData != null) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
final sourceEntries = <String, _CachedSource>{...sourceCache};
|
||||
for (final result in fetchResults) {
|
||||
sourceEntries[result.tag] = result.source;
|
||||
}
|
||||
|
||||
final hosts = sourceEntries.values
|
||||
.expand((source) => source.hosts)
|
||||
.where(_isValidHostname)
|
||||
.toSet();
|
||||
|
||||
if (hosts.isEmpty && cachedData != null) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
final data = _buildData(
|
||||
hosts: hosts,
|
||||
sourceTag: fetchResults.any((r) => r.changed)
|
||||
? 'updated-github'
|
||||
: 'validated-github-cache',
|
||||
);
|
||||
|
||||
await prefs.setString(_keyCache, jsonEncode(data.toJson()));
|
||||
await prefs.setString(
|
||||
_keySourceCache,
|
||||
jsonEncode({
|
||||
for (final entry in sourceEntries.entries) entry.key: entry.value,
|
||||
}),
|
||||
);
|
||||
await prefs.setInt(
|
||||
_keyCacheUpdatedAt,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
AdblockContentBlockerData? _readCachedData(SharedPreferences prefs) {
|
||||
final cached = prefs.getString(_keyCache);
|
||||
if (cached == null) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(cached) as Map<String, dynamic>;
|
||||
return AdblockContentBlockerData.fromJson(decoded);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, _CachedSource> _readSourceCache(SharedPreferences prefs) {
|
||||
final cached = prefs.getString(_keySourceCache);
|
||||
if (cached == null) return {};
|
||||
try {
|
||||
final decoded = jsonDecode(cached) as Map<String, dynamic>;
|
||||
return decoded.map((tag, value) {
|
||||
return MapEntry(
|
||||
tag,
|
||||
_CachedSource.fromJson(value as Map<String, dynamic>),
|
||||
);
|
||||
});
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
AdblockContentBlockerData _buildData({
|
||||
required Set<String> hosts,
|
||||
required String sourceTag,
|
||||
}) {
|
||||
final sortedHosts = hosts.toList(growable: false)..sort();
|
||||
final cappedHosts = sortedHosts.take(_maxContentBlockerRules).toSet();
|
||||
|
||||
return AdblockContentBlockerData(
|
||||
contentBlockers: cappedHosts
|
||||
.map(
|
||||
(h) => ContentBlocker(
|
||||
trigger: ContentBlockerTrigger(urlFilter: _urlFilterForHost(h)),
|
||||
action: ContentBlockerAction(
|
||||
type: ContentBlockerActionType.BLOCK,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
blockedHosts: cappedHosts,
|
||||
sourceTag: sourceTag,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<_FetchedSource>> _fetchAllSources({
|
||||
required Map<String, _CachedSource> cache,
|
||||
required int timeoutMs,
|
||||
}) async {
|
||||
final client = http.Client();
|
||||
try {
|
||||
final timeout = Duration(milliseconds: timeoutMs);
|
||||
return Future.wait(
|
||||
_sources.map(
|
||||
(source) => _fetchSource(
|
||||
client: client,
|
||||
source: source,
|
||||
cached: cache[source.tag],
|
||||
timeout: timeout,
|
||||
),
|
||||
),
|
||||
).then((results) => results.whereType<_FetchedSource>().toList());
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_FetchedSource?> _fetchSource({
|
||||
required http.Client client,
|
||||
required _SourceSpec source,
|
||||
required _CachedSource? cached,
|
||||
required Duration timeout,
|
||||
}) async {
|
||||
try {
|
||||
final headers = <String, String>{
|
||||
if (cached?.etag != null) 'If-None-Match': cached!.etag!,
|
||||
if (cached?.lastModified != null)
|
||||
'If-Modified-Since': cached!.lastModified!,
|
||||
'User-Agent': 'FocusGram-AdblockListUpdater',
|
||||
};
|
||||
|
||||
final res = await client
|
||||
.get(Uri.parse(source.url), headers: headers)
|
||||
.timeout(timeout);
|
||||
|
||||
if (res.statusCode == 304 && cached != null) {
|
||||
return _FetchedSource(tag: source.tag, source: cached, changed: false);
|
||||
}
|
||||
|
||||
if (res.statusCode != 200 || res.body.isEmpty) return null;
|
||||
|
||||
return _FetchedSource(
|
||||
tag: source.tag,
|
||||
source: _CachedSource(
|
||||
url: source.url,
|
||||
etag: res.headers['etag'],
|
||||
lastModified: res.headers['last-modified'],
|
||||
hosts: parseHostsFromFilterText(res.body),
|
||||
),
|
||||
changed: true,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict/strong: we only extract domain-ish entries from common uBlock/EasyList
|
||||
/// syntax forms:
|
||||
/// - ||example.com^
|
||||
/// - ||example.com/
|
||||
/// - ||example.com
|
||||
///
|
||||
/// We ignore all element-hiding/cosmetic rules and $ options.
|
||||
@visibleForTesting
|
||||
static Set<String> parseHostsFromFilterText(String raw) {
|
||||
final hosts = <String>{};
|
||||
|
||||
for (final line in raw.split('\n')) {
|
||||
final l = line.trim();
|
||||
if (l.isEmpty) continue;
|
||||
if (l.startsWith('!')) continue;
|
||||
if (l.startsWith('@@')) continue;
|
||||
|
||||
// Skip comments / metadata
|
||||
if (l.startsWith('[')) continue;
|
||||
|
||||
// Skip cosmetic element-hiding rules
|
||||
if (l.contains('##') || l.contains('#@#') || l.contains(r'#$#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// uBlock-style host anchors
|
||||
if (l.startsWith('||')) {
|
||||
final body = l.substring(2);
|
||||
|
||||
// Drop anything after a separator like '^', '/', '?', ' ' (conservative)
|
||||
// e.g. "example.com^" -> "example.com"
|
||||
// e.g. "example.com/" -> "example.com"
|
||||
// e.g. "example.com^$third-party" -> "example.com"
|
||||
final stopChars = ['^', '/', '?', '\\', '|', '\t', ' ', r'$'];
|
||||
|
||||
String host = body;
|
||||
for (final sc in stopChars) {
|
||||
final idx = host.indexOf(sc);
|
||||
if (idx >= 0) host = host.substring(0, idx);
|
||||
}
|
||||
|
||||
host = host.trim();
|
||||
|
||||
// Remove leading/trailing dots
|
||||
host = host
|
||||
.replaceAll(RegExp(r'^\.+'), '')
|
||||
.replaceAll(RegExp(r'\.+$'), '');
|
||||
|
||||
if (host.isEmpty) continue;
|
||||
if (host.contains('*') || host.contains(',')) continue;
|
||||
|
||||
final normalized = host.toLowerCase();
|
||||
if (!_isValidHostname(normalized)) continue;
|
||||
|
||||
hosts.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
static String _urlFilterForHost(String host) {
|
||||
final escaped = RegExp.escape(host);
|
||||
return r'^https?://([^/?#]+\.)?'
|
||||
'$escaped'
|
||||
r'([/?#:].*)?$';
|
||||
}
|
||||
|
||||
static bool _isValidHostname(String host) {
|
||||
if (!host.contains('.')) return false;
|
||||
if (host.length > 255) return false;
|
||||
if (host.startsWith('.') || host.endsWith('.')) return false;
|
||||
if (host.contains('..')) return false;
|
||||
return RegExp(r'^[a-z0-9][a-z0-9.-]*[a-z0-9]$').hasMatch(host);
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceSpec {
|
||||
final String tag;
|
||||
final String url;
|
||||
|
||||
const _SourceSpec({required this.tag, required this.url});
|
||||
}
|
||||
|
||||
class _FetchedSource {
|
||||
final String tag;
|
||||
final _CachedSource source;
|
||||
final bool changed;
|
||||
|
||||
_FetchedSource({
|
||||
required this.tag,
|
||||
required this.source,
|
||||
required this.changed,
|
||||
});
|
||||
}
|
||||
|
||||
class _CachedSource {
|
||||
final String url;
|
||||
final String? etag;
|
||||
final String? lastModified;
|
||||
final Set<String> hosts;
|
||||
|
||||
const _CachedSource({
|
||||
required this.url,
|
||||
required this.etag,
|
||||
required this.lastModified,
|
||||
required this.hosts,
|
||||
});
|
||||
|
||||
factory _CachedSource.fromJson(Map<String, dynamic> json) {
|
||||
return _CachedSource(
|
||||
url: (json['url'] as String?) ?? '',
|
||||
etag: json['etag'] as String?,
|
||||
lastModified: json['lastModified'] as String?,
|
||||
hosts: (json['hosts'] as List?)?.whereType<String>().toSet() ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'url': url,
|
||||
'etag': etag,
|
||||
'lastModified': lastModified,
|
||||
'hosts': hosts.toList(growable: false)..sort(),
|
||||
};
|
||||
}
|
||||
@@ -57,15 +57,15 @@ class InjectionController {
|
||||
required bool blurReels,
|
||||
required bool tapToUnblur,
|
||||
required bool enableTextSelection,
|
||||
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
|
||||
required bool hideSuggestedPosts,
|
||||
required bool hideSponsoredPosts,
|
||||
required bool hideLikeCounts,
|
||||
required bool hideFollowerCounts,
|
||||
// hideStoriesBar parameter removed per user request
|
||||
required bool hideExploreTab,
|
||||
required bool hideReelsTab,
|
||||
required bool hideShopTab,
|
||||
required bool disableReelsEntirely,
|
||||
required bool blockHomeFeedScroll,
|
||||
}) {
|
||||
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
||||
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
||||
@@ -75,18 +75,12 @@ class InjectionController {
|
||||
css.writeln(scripts.kHideReelsFeedContentCSS);
|
||||
}
|
||||
|
||||
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
|
||||
// Previously it was inside that block alongside display:none on the parent —
|
||||
// you cannot blur children of a display:none element, making it dead code.
|
||||
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
|
||||
// when sessionActive=false, reels are hidden anyway (blur harmless).
|
||||
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
|
||||
|
||||
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
||||
|
||||
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
||||
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
||||
// Stories hiding removed per user request
|
||||
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
||||
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
||||
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
||||
@@ -94,6 +88,7 @@ class InjectionController {
|
||||
return '''
|
||||
${buildSessionStateJS(sessionActive)}
|
||||
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
||||
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
|
||||
window.__fgTapToUnblur = $tapToUnblur;
|
||||
${scripts.kTrackPathJS}
|
||||
${_buildMutationObserver(css.toString())}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'injection_controller.dart';
|
||||
import '../scripts/grayscale.dart' as grayscale;
|
||||
import '../scripts/ui_hider.dart' as ui_hider;
|
||||
import '../scripts/content_disabling.dart' as content_disabling;
|
||||
import '../scripts/video_downloader.dart' as video_downloader;
|
||||
|
||||
// Core JS and CSS payloads injected into the Instagram WebView.
|
||||
//
|
||||
@@ -386,18 +387,39 @@ const String kLinkSanitizationJS = r'''
|
||||
|
||||
// ── InjectionManager class ─────────────────────────────────────────────────
|
||||
|
||||
class InjectionManager {
|
||||
abstract class JsEvaluator {
|
||||
Future<void> evaluateJavascript({required String source});
|
||||
}
|
||||
|
||||
class _WebViewJsEvaluator implements JsEvaluator {
|
||||
final InAppWebViewController controller;
|
||||
_WebViewJsEvaluator(this.controller);
|
||||
|
||||
@override
|
||||
Future<void> evaluateJavascript({required String source}) {
|
||||
return controller.evaluateJavascript(source: source);
|
||||
}
|
||||
}
|
||||
|
||||
class InjectionManager {
|
||||
final JsEvaluator _jsEvaluator;
|
||||
final SharedPreferences prefs;
|
||||
final SessionManager sessionManager;
|
||||
|
||||
SettingsService? _settingsService;
|
||||
|
||||
InjectionManager({
|
||||
required this.controller,
|
||||
required InAppWebViewController controller,
|
||||
required this.prefs,
|
||||
required this.sessionManager,
|
||||
});
|
||||
JsEvaluator? jsEvaluator,
|
||||
}) : _jsEvaluator = jsEvaluator ?? _WebViewJsEvaluator(controller);
|
||||
|
||||
InjectionManager.forTest({
|
||||
required JsEvaluator jsEvaluator,
|
||||
required this.prefs,
|
||||
required this.sessionManager,
|
||||
}) : _jsEvaluator = jsEvaluator;
|
||||
|
||||
void setSettingsService(SettingsService settingsService) {
|
||||
_settingsService = settingsService;
|
||||
@@ -415,18 +437,19 @@ class InjectionManager {
|
||||
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
|
||||
final tapToUnblur = settings.tapToUnblur;
|
||||
final enableTextSelection = settings.enableTextSelection;
|
||||
final hideSponsoredPosts = settings.hideSponsoredPosts;
|
||||
|
||||
// Per request: remove ALL “Hide Suggested Posts” behavior/UI/JS injection.
|
||||
final hideSuggestedPosts = false;
|
||||
final hideLikeCounts = settings.hideLikeCounts;
|
||||
final hideFollowerCounts = settings.hideFollowerCounts;
|
||||
// Stories hiding functionality removed per user request
|
||||
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
|
||||
// These are now only controllable via minimal mode submenu
|
||||
final disableExploreEntirely = settings.disableExploreEntirely;
|
||||
final disableReelsEntirely = settings.disableReelsEntirely;
|
||||
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
||||
final hideExploreTab = disableExploreEntirely;
|
||||
final hideReelsTab = disableReelsEntirely;
|
||||
final hideShopTab = settings.hideShopTab;
|
||||
final isGrayscaleActive = settings.isGrayscaleActiveNow;
|
||||
|
||||
final injectionJS = InjectionController.buildInjectionJS(
|
||||
sessionActive: sessionActive,
|
||||
@@ -434,33 +457,35 @@ class InjectionManager {
|
||||
blurReels: false, // Blur reels feature removed
|
||||
tapToUnblur: blurExplore && tapToUnblur,
|
||||
enableTextSelection: enableTextSelection,
|
||||
hideSuggestedPosts: false, // Feature removed
|
||||
hideSponsoredPosts: hideSponsoredPosts,
|
||||
hideSuggestedPosts: hideSuggestedPosts,
|
||||
hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
|
||||
hideLikeCounts: hideLikeCounts,
|
||||
hideFollowerCounts: hideFollowerCounts,
|
||||
// hideStoriesBar removed per user request
|
||||
hideExploreTab: hideExploreTab,
|
||||
hideReelsTab: hideReelsTab,
|
||||
hideShopTab: hideShopTab,
|
||||
disableReelsEntirely: disableReelsEntirely,
|
||||
blockHomeFeedScroll: blockHomeFeedScroll,
|
||||
);
|
||||
|
||||
try {
|
||||
await controller.evaluateJavascript(source: injectionJS);
|
||||
await _jsEvaluator.evaluateJavascript(source: injectionJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
|
||||
// Inject grayscale when active, remove when not active
|
||||
if (isGrayscaleActive) {
|
||||
if (settings.isGrayscaleActiveNow) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: grayscale.kGrayscaleOffJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
@@ -469,7 +494,9 @@ class InjectionManager {
|
||||
// Inject hide like counts JS when enabled
|
||||
if (hideLikeCounts) {
|
||||
try {
|
||||
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: ui_hider.kHideLikeCountsJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
}
|
||||
@@ -478,11 +505,11 @@ class InjectionManager {
|
||||
// Stories hiding functionality removed per user request
|
||||
// No stories overlay injection needed
|
||||
|
||||
// Inject hide sponsored posts JS when enabled
|
||||
if (hideSponsoredPosts) {
|
||||
// Inject video downloader UI when enabled
|
||||
if (settings.videoDownloadEnabled) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
source: ui_hider.kHideSponsoredPostsJS,
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: video_downloader.kVideoDownloadJS,
|
||||
);
|
||||
} catch (e) {
|
||||
// Silently handle injection errors
|
||||
@@ -492,7 +519,7 @@ class InjectionManager {
|
||||
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
||||
if (disableReelsEntirely) {
|
||||
try {
|
||||
await controller.evaluateJavascript(
|
||||
await _jsEvaluator.evaluateJavascript(
|
||||
source: content_disabling.kDmReelBlockerJS,
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@@ -9,16 +9,16 @@ class NotificationService {
|
||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> init() async {
|
||||
Future<void> init({bool requestPermissions = false}) async {
|
||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// Request permissions for iOS
|
||||
final DarwinInitializationSettings initializationSettingsIOS =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
requestAlertPermission: requestPermissions,
|
||||
requestBadgePermission: requestPermissions,
|
||||
requestSoundPermission: requestPermissions,
|
||||
defaultPresentAlert: true,
|
||||
defaultPresentBadge: true,
|
||||
defaultPresentSound: true,
|
||||
@@ -37,7 +37,12 @@ class NotificationService {
|
||||
},
|
||||
);
|
||||
|
||||
// Request permissions after initialization
|
||||
if (requestPermissions) {
|
||||
await requestPermissionsNow();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> requestPermissionsNow() async {
|
||||
await _requestIOSPermissions();
|
||||
await _requestAndroidPermissions();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class RemotePopupData {
|
||||
final bool show;
|
||||
final String id;
|
||||
final String title;
|
||||
final String body;
|
||||
final int maxShows;
|
||||
final String buttonText;
|
||||
|
||||
RemotePopupData({
|
||||
required this.show,
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.maxShows,
|
||||
required this.buttonText,
|
||||
});
|
||||
|
||||
factory RemotePopupData.fromJson(Map<String, dynamic> json) {
|
||||
return RemotePopupData(
|
||||
show: json['show'] ?? false,
|
||||
id: json['id']?.toString() ?? '',
|
||||
title: json['header']?.toString() ?? 'Notice',
|
||||
body: json['body']?.toString() ?? '',
|
||||
maxShows: json['max_shows'] ?? 1,
|
||||
buttonText: json['button_text']?.toString() ?? 'OK',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RemotePopupService {
|
||||
// Keep placeholder value until you replace it.
|
||||
static const String popupUrl =
|
||||
'https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/android/popup.json';
|
||||
|
||||
static Future<RemotePopupData?> fetchPopup() async {
|
||||
try {
|
||||
// Cache-busting to avoid stale popup configs from GitHub raw URLs.
|
||||
final uri = Uri.parse(
|
||||
'$popupUrl?t=${DateTime.now().millisecondsSinceEpoch}',
|
||||
);
|
||||
|
||||
final response = await http.get(
|
||||
uri,
|
||||
headers: const {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
return RemotePopupData.fromJson(decoded);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> shouldShow(RemotePopupData data) async {
|
||||
if (!data.show) return false;
|
||||
if (data.id.isEmpty) return false;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = 'popup_count_${data.id}';
|
||||
final shownCount = prefs.getInt(key) ?? 0;
|
||||
|
||||
return shownCount < data.maxShows;
|
||||
}
|
||||
|
||||
static Future<void> markShown(RemotePopupData data) async {
|
||||
if (data.id.isEmpty) return;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final key = 'popup_count_${data.id}';
|
||||
final current = prefs.getInt(key) ?? 0;
|
||||
await prefs.setInt(key, current + 1);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
///
|
||||
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
||||
/// {
|
||||
/// "2026-02-26": 3420, // seconds
|
||||
/// "2026-02-25": 1800
|
||||
/// "2026-05-26": 3420, // seconds
|
||||
/// "2026-05-25": 1800
|
||||
/// }
|
||||
///
|
||||
/// All data stays on-device only.
|
||||
@@ -22,6 +22,8 @@ class ScreenTimeService extends ChangeNotifier {
|
||||
bool _tracking = false;
|
||||
|
||||
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
||||
int get totalSeconds =>
|
||||
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
@@ -37,9 +39,7 @@ class ScreenTimeService extends ChangeNotifier {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
_secondsByDate = decoded.map(
|
||||
(k, v) => MapEntry(k, (v as num).toInt()),
|
||||
);
|
||||
_secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
|
||||
}
|
||||
} catch (_) {
|
||||
_secondsByDate = {};
|
||||
@@ -104,4 +104,3 @@ class ScreenTimeService extends ChangeNotifier {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class SessionManager extends ChangeNotifier {
|
||||
static const _keyAppSessionEnd = 'app_sess_end_ts';
|
||||
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
|
||||
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
|
||||
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
|
||||
static const _keyDailyOpenCount = 'app_open_count';
|
||||
static const _keyScheduleEnabled = 'sched_enabled';
|
||||
static const _keyScheduleStartHour = 'sched_start_h';
|
||||
@@ -81,6 +82,7 @@ class SessionManager extends ChangeNotifier {
|
||||
bool _appSessionExpiredFlag =
|
||||
false; // set when time runs out, waiting for user action
|
||||
int _dailyOpenCount = 0;
|
||||
int _lastAppSessionMinutes = 5;
|
||||
|
||||
// ── Scheduled Blocking runtime ─────────────────────────────
|
||||
bool _scheduleEnabled = false;
|
||||
@@ -90,8 +92,10 @@ class SessionManager extends ChangeNotifier {
|
||||
int _schedEndMin = 0;
|
||||
List<FocusSchedule> _schedules = [];
|
||||
bool _lastScheduleState = false;
|
||||
bool _scheduleNotificationShown = false; // Track if schedule notification was shown
|
||||
bool _sessionEndNotificationShown = true; // Default to true to prevent notification on app startup (will be reset when new session starts)
|
||||
bool _scheduleNotificationShown =
|
||||
false; // Track if schedule notification was shown
|
||||
bool _sessionEndNotificationShown =
|
||||
true; // Default to true to prevent notification on app startup (will be reset when new session starts)
|
||||
|
||||
bool _isInForeground = true; // Tracking app lifecycle state
|
||||
int _cachedRemainingSessionSeconds = 0;
|
||||
@@ -175,6 +179,7 @@ class SessionManager extends ChangeNotifier {
|
||||
|
||||
/// How many times the user has opened the app today.
|
||||
int get dailyOpenCount => _dailyOpenCount;
|
||||
int get lastAppSessionMinutes => _lastAppSessionMinutes;
|
||||
|
||||
// ── Scheduled Blocking Getters ─────────────────────────────
|
||||
bool get scheduleEnabled => _scheduleEnabled;
|
||||
@@ -309,6 +314,7 @@ class SessionManager extends ChangeNotifier {
|
||||
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
|
||||
}
|
||||
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
|
||||
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
|
||||
|
||||
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
|
||||
if (lastAppEndMs > 0) {
|
||||
@@ -375,12 +381,12 @@ class SessionManager extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// App session expiry check
|
||||
// App session countdown / expiry check
|
||||
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
|
||||
if (DateTime.now().isAfter(_appSessionEnd!)) {
|
||||
_appSessionExpiredFlag = true;
|
||||
changed = true;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (isCooldownActive) {
|
||||
@@ -396,7 +402,7 @@ class SessionManager extends ChangeNotifier {
|
||||
if (sched != _lastScheduleState) {
|
||||
_lastScheduleState = sched;
|
||||
changed = true;
|
||||
|
||||
|
||||
// Show notification when schedule becomes active
|
||||
if (sched && !_scheduleNotificationShown) {
|
||||
_scheduleNotificationShown = true;
|
||||
@@ -420,10 +426,11 @@ class SessionManager extends ChangeNotifier {
|
||||
// (i.e., when loading an expired session from a previous app session)
|
||||
if (showNotification && !_sessionEndNotificationShown) {
|
||||
_sessionEndNotificationShown = true;
|
||||
|
||||
|
||||
// Check if user wants session end notifications
|
||||
final notifySessionEnd = _prefs?.getBool('set_notify_session_end') ?? false;
|
||||
|
||||
final notifySessionEnd =
|
||||
_prefs?.getBool('set_notify_session_end') ?? false;
|
||||
|
||||
if (notifySessionEnd) {
|
||||
NotificationService().showNotification(
|
||||
id: 999,
|
||||
@@ -432,7 +439,7 @@ class SessionManager extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_isSessionActive = false;
|
||||
_sessionExpiry = null;
|
||||
_lastSessionEnd = DateTime.now();
|
||||
@@ -448,7 +455,8 @@ class SessionManager extends ChangeNotifier {
|
||||
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
|
||||
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
|
||||
_isSessionActive = true;
|
||||
_sessionEndNotificationShown = false; // Reset notification flag for new session
|
||||
_sessionEndNotificationShown =
|
||||
false; // Reset notification flag for new session
|
||||
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
|
||||
notifyListeners();
|
||||
return true;
|
||||
@@ -482,8 +490,10 @@ class SessionManager extends ChangeNotifier {
|
||||
_appSessionEnd = end;
|
||||
_appSessionExpiredFlag = false;
|
||||
_appExtensionUsed = false;
|
||||
_lastAppSessionMinutes = minutes;
|
||||
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
|
||||
_prefs?.setBool(_keyAppSessionExtUsed, false);
|
||||
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'notification_service.dart';
|
||||
|
||||
/// Stores and retrieves all user-configurable app settings.
|
||||
class SettingsService extends ChangeNotifier {
|
||||
static const _keyBlurExplore = 'set_blur_explore';
|
||||
static const _keyBlurReels = 'set_blur_reels';
|
||||
static const _keyTapToUnblur = 'set_tap_to_unblur';
|
||||
static const _keyRequireLongPress = 'set_require_long_press';
|
||||
static const _keyShowBreathGate = 'set_show_breath_gate';
|
||||
static const _keyRequireWordChallenge = 'set_require_word_challenge';
|
||||
static const _keyRequireLongPress = 'set_require_long_press';
|
||||
static const _keyBreathGateSeconds = 'breath_gate_seconds';
|
||||
static const _keyWordChallengeCount = 'word_challenge_count';
|
||||
static const _keyEnableTextSelection = 'set_enable_text_selection';
|
||||
static const _keyEnabledTabs = 'set_enabled_tabs';
|
||||
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
||||
@@ -18,23 +23,42 @@ class SettingsService extends ChangeNotifier {
|
||||
// Focus / playback
|
||||
static const _keyBlockAutoplay = 'block_autoplay';
|
||||
|
||||
// Extras (Phase 2)
|
||||
static const _keyVideoDownloadEnabled = 'video_download_enabled';
|
||||
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
|
||||
|
||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||
static const _keyV2GhostModeEnabled = 'v2_ghost_mode_enabled';
|
||||
static const _keyV2AdBlockerDomEnabled = 'v2_adblock_dom_enabled';
|
||||
static const _keyV2ContentHiderEnabled = 'v2_content_hider_enabled';
|
||||
|
||||
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
|
||||
static const _keyContentStories = 'content_stories';
|
||||
static const _keyContentPosts = 'content_posts';
|
||||
static const _keyContentReels = 'content_reels';
|
||||
static const _keyContentSuggested = 'content_suggested';
|
||||
|
||||
// Grayscale mode - now supports multiple schedules
|
||||
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
||||
static const _keyGrayscaleSchedules = 'grayscale_schedules';
|
||||
|
||||
// Content filtering / UI hiding
|
||||
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
|
||||
static const _keyHideLikeCounts = 'hide_like_counts';
|
||||
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
||||
static const _keyHideShopTab = 'hide_shop_tab';
|
||||
|
||||
// Minimal mode
|
||||
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
||||
|
||||
|
||||
// Minimal mode state tracking for smart restore
|
||||
static const _keyMinimalModePrevDisableReels = 'minimal_mode_prev_disable_reels';
|
||||
static const _keyMinimalModePrevDisableExplore = 'minimal_mode_prev_disable_explore';
|
||||
static const _keyMinimalModePrevBlurExplore = 'minimal_mode_prev_blur_explore';
|
||||
static const _keyMinimalModePrevDisableReels =
|
||||
'minimal_mode_prev_disable_reels';
|
||||
static const _keyMinimalModePrevDisableExplore =
|
||||
'minimal_mode_prev_disable_explore';
|
||||
static const _keyMinimalModePrevBlurExplore =
|
||||
'minimal_mode_prev_blur_explore';
|
||||
static const _keyMinimalModePrevBlockHomeFeedScroll =
|
||||
'minimal_mode_prev_block_home_feed_scroll';
|
||||
|
||||
// Reels History
|
||||
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
||||
@@ -46,6 +70,14 @@ class SettingsService extends ChangeNotifier {
|
||||
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
||||
static const _keyNotifyPersistent = 'set_notify_persistent';
|
||||
|
||||
// Focus mode settings
|
||||
static const _keyGhostMode = 'ghost_mode';
|
||||
static const _keyNoAds = 'no_ads';
|
||||
static const _keyNoStories = 'no_stories';
|
||||
static const _keyNoReels = 'no_reels';
|
||||
static const _keyNoAutoplay = 'no_autoplay';
|
||||
static const _keyNoDMs = 'no_dms';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
bool _blurExplore = true;
|
||||
@@ -54,19 +86,33 @@ class SettingsService extends ChangeNotifier {
|
||||
bool _requireLongPress = true;
|
||||
bool _showBreathGate = true;
|
||||
bool _requireWordChallenge = true;
|
||||
int _breathGateSeconds = 10;
|
||||
int _wordChallengeCount = 30;
|
||||
bool _enableTextSelection = false;
|
||||
bool _showInstaSettings = true;
|
||||
bool _isDarkMode = true; // Default to dark as per existing app theme
|
||||
|
||||
bool _blockAutoplay = true;
|
||||
|
||||
bool _videoDownloadEnabled = false;
|
||||
bool _hideSuggestedPosts = false;
|
||||
|
||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||
bool _v2GhostModeEnabled = false;
|
||||
bool _v2AdBlockerDomEnabled = false;
|
||||
bool _v2ContentHiderEnabled = false;
|
||||
|
||||
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
|
||||
bool _contentStories = false;
|
||||
bool _contentPosts = false;
|
||||
bool _contentReels = false;
|
||||
bool _contentSuggested = false;
|
||||
|
||||
// Grayscale mode - now supports multiple schedules
|
||||
bool _grayscaleEnabled = false;
|
||||
|
||||
// Grayscale schedules - list of {enabled, startTime, endTime}
|
||||
// startTime and endTime are in format "HH:MM"
|
||||
List<Map<String, dynamic>> _grayscaleSchedules = [];
|
||||
|
||||
bool _hideSponsoredPosts = false;
|
||||
// Content filtering / UI hiding
|
||||
bool _hideLikeCounts = false;
|
||||
bool _hideFollowerCounts = false;
|
||||
bool _hideShopTab = false;
|
||||
@@ -74,12 +120,14 @@ class SettingsService extends ChangeNotifier {
|
||||
// These are now controlled internally by minimal mode
|
||||
bool _disableReelsEntirely = false;
|
||||
bool _disableExploreEntirely = false;
|
||||
bool _blockHomeFeedScroll = false;
|
||||
bool _minimalModeEnabled = false;
|
||||
|
||||
// Tracking for smart restore
|
||||
bool _prevDisableReels = false;
|
||||
bool _prevDisableExplore = false;
|
||||
bool _prevBlurExplore = false;
|
||||
bool _prevBlockHomeFeedScroll = false;
|
||||
|
||||
bool _reelsHistoryEnabled = true;
|
||||
|
||||
@@ -90,6 +138,14 @@ class SettingsService extends ChangeNotifier {
|
||||
bool _notifySessionEnd = false;
|
||||
bool _notifyPersistent = false;
|
||||
|
||||
// Focus mode settings
|
||||
bool _ghostMode = false;
|
||||
bool _noAds = false;
|
||||
bool _noStories = false;
|
||||
bool _noReels = false;
|
||||
bool _noAutoplay = false;
|
||||
bool _noDMs = false;
|
||||
|
||||
List<String> _enabledTabs = [
|
||||
'Home',
|
||||
'Search',
|
||||
@@ -105,12 +161,28 @@ class SettingsService extends ChangeNotifier {
|
||||
bool get requireLongPress => _requireLongPress;
|
||||
bool get showBreathGate => _showBreathGate;
|
||||
bool get requireWordChallenge => _requireWordChallenge;
|
||||
int get breathGateSeconds => _breathGateSeconds;
|
||||
int get wordChallengeCount => _wordChallengeCount;
|
||||
bool get enableTextSelection => _enableTextSelection;
|
||||
bool get showInstaSettings => _showInstaSettings;
|
||||
List<String> get enabledTabs => _enabledTabs;
|
||||
bool get isFirstRun => _isFirstRun;
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
bool get blockAutoplay => _blockAutoplay;
|
||||
|
||||
// Extras (Phase 2)
|
||||
bool get videoDownloadEnabled => _videoDownloadEnabled;
|
||||
bool get hideSuggestedPosts => _hideSuggestedPosts;
|
||||
|
||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||
bool get v2GhostModeEnabled => _v2GhostModeEnabled;
|
||||
bool get v2AdBlockerDomEnabled => _v2AdBlockerDomEnabled;
|
||||
bool get v2ContentHiderEnabled => _v2ContentHiderEnabled;
|
||||
|
||||
bool get contentStories => _contentStories;
|
||||
bool get contentPosts => _contentPosts;
|
||||
bool get contentReels => _contentReels;
|
||||
bool get contentSuggested => _contentSuggested;
|
||||
bool get notifyDMs => _notifyDMs;
|
||||
bool get notifyActivity => _notifyActivity;
|
||||
bool get notifySessionEnd => _notifySessionEnd;
|
||||
@@ -119,14 +191,22 @@ class SettingsService extends ChangeNotifier {
|
||||
bool get grayscaleEnabled => _grayscaleEnabled;
|
||||
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
|
||||
|
||||
bool get hideSponsoredPosts => _hideSponsoredPosts;
|
||||
bool get hideLikeCounts => _hideLikeCounts;
|
||||
bool get hideFollowerCounts => _hideFollowerCounts;
|
||||
bool get hideShopTab => _hideShopTab;
|
||||
|
||||
// Focus mode settings
|
||||
bool get ghostMode => _ghostMode;
|
||||
bool get noAds => _noAds;
|
||||
bool get noStories => _noStories;
|
||||
bool get noReels => _noReels;
|
||||
bool get noAutoplay => _noAutoplay;
|
||||
bool get noDMs => _noDMs;
|
||||
|
||||
// These are now controlled by minimal mode only
|
||||
bool get disableReelsEntirely => _minimalModeEnabled ? true : _disableReelsEntirely;
|
||||
bool get disableExploreEntirely => _minimalModeEnabled ? true : _disableExploreEntirely;
|
||||
bool get disableReelsEntirely => _disableReelsEntirely;
|
||||
bool get disableExploreEntirely => _disableExploreEntirely;
|
||||
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
|
||||
bool get minimalModeEnabled => _minimalModeEnabled;
|
||||
|
||||
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
||||
@@ -136,22 +216,23 @@ class SettingsService extends ChangeNotifier {
|
||||
bool get isGrayscaleActiveNow {
|
||||
if (_grayscaleEnabled) return true;
|
||||
if (_grayscaleSchedules.isEmpty) return false;
|
||||
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentMinutes = now.hour * 60 + now.minute;
|
||||
|
||||
|
||||
for (final schedule in _grayscaleSchedules) {
|
||||
if (schedule['enabled'] != true) continue;
|
||||
|
||||
|
||||
try {
|
||||
final startParts = (schedule['startTime'] as String).split(':');
|
||||
final endParts = (schedule['endTime'] as String).split(':');
|
||||
|
||||
|
||||
if (startParts.length != 2 || endParts.length != 2) continue;
|
||||
|
||||
final startMinutes = int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
|
||||
|
||||
final startMinutes =
|
||||
int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
|
||||
final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
|
||||
|
||||
|
||||
// Handle overnight schedules (e.g., 21:00 to 06:00)
|
||||
if (endMinutes < startMinutes) {
|
||||
// Overnight: active if current time is >= start OR < end
|
||||
@@ -182,43 +263,80 @@ class SettingsService extends ChangeNotifier {
|
||||
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
||||
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
|
||||
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
||||
_breathGateSeconds = (_prefs!.getInt(_keyBreathGateSeconds) ?? 10)
|
||||
.clamp(3, 60)
|
||||
.toInt();
|
||||
_wordChallengeCount = _normaliseWordChallengeCount(
|
||||
_prefs!.getInt(_keyWordChallengeCount) ?? 30,
|
||||
);
|
||||
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
||||
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
|
||||
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
|
||||
|
||||
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
|
||||
|
||||
// Extras (Phase 2) - defaults OFF for safety/non-invasive behavior
|
||||
_videoDownloadEnabled = _prefs!.getBool(_keyVideoDownloadEnabled) ?? false;
|
||||
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
||||
|
||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
||||
_v2GhostModeEnabled = _prefs!.getBool(_keyV2GhostModeEnabled) ?? false;
|
||||
_v2AdBlockerDomEnabled =
|
||||
_prefs!.getBool(_keyV2AdBlockerDomEnabled) ?? false;
|
||||
_v2ContentHiderEnabled =
|
||||
_prefs!.getBool(_keyV2ContentHiderEnabled) ?? false;
|
||||
|
||||
_contentStories = _prefs!.getBool(_keyContentStories) ?? false;
|
||||
_contentPosts = _prefs!.getBool(_keyContentPosts) ?? false;
|
||||
_contentReels = _prefs!.getBool(_keyContentReels) ?? false;
|
||||
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
|
||||
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
||||
|
||||
// Load grayscale schedules
|
||||
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
|
||||
if (schedulesJson != null) {
|
||||
try {
|
||||
_grayscaleSchedules = List<Map<String, dynamic>>.from(
|
||||
(jsonDecode(schedulesJson) as List).map((e) => Map<String, dynamic>.from(e))
|
||||
(jsonDecode(schedulesJson) as List).map(
|
||||
(e) => Map<String, dynamic>.from(e),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
_grayscaleSchedules = [];
|
||||
}
|
||||
}
|
||||
|
||||
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
|
||||
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
||||
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
||||
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
||||
|
||||
// Load minimal mode
|
||||
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
||||
|
||||
|
||||
// Load previous states for smart restore
|
||||
_prevDisableReels = _prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
||||
_prevDisableExplore = _prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
||||
_prevDisableReels =
|
||||
_prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
||||
_prevDisableExplore =
|
||||
_prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
||||
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
|
||||
_prevBlockHomeFeedScroll =
|
||||
_prefs!.getBool(_keyMinimalModePrevBlockHomeFeedScroll) ?? false;
|
||||
|
||||
// These are now internal states, not user-facing settings
|
||||
_disableReelsEntirely = _prefs!.getBool('internal_disable_reels_entirely') ?? false;
|
||||
_disableExploreEntirely = _prefs!.getBool('internal_disable_explore_entirely') ?? false;
|
||||
_disableReelsEntirely =
|
||||
_prefs!.getBool('internal_disable_reels_entirely') ?? false;
|
||||
_disableExploreEntirely =
|
||||
_prefs!.getBool('internal_disable_explore_entirely') ?? false;
|
||||
_blockHomeFeedScroll =
|
||||
_prefs!.getBool('internal_block_home_feed_scroll') ?? false;
|
||||
|
||||
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
|
||||
|
||||
// Focus mode settings
|
||||
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
|
||||
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
|
||||
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
|
||||
_noReels = _prefs!.getBool(_keyNoReels) ?? false;
|
||||
_noAutoplay = _prefs!.getBool(_keyNoAutoplay) ?? false;
|
||||
_noDMs = _prefs!.getBool(_keyNoDMs) ?? false;
|
||||
|
||||
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
|
||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
||||
@@ -245,12 +363,12 @@ class SettingsService extends ChangeNotifier {
|
||||
|
||||
Future<void> setBlurExplore(bool v) async {
|
||||
_blurExplore = v;
|
||||
// Sync blur explore with blur reels - enabling one enables the other
|
||||
if (v && !_blurReels) {
|
||||
_blurReels = true;
|
||||
await _prefs?.setBool(_keyBlurReels, true);
|
||||
}
|
||||
await _prefs?.setBool(_keyBlurExplore, v);
|
||||
|
||||
if (_minimalModeEnabled) {
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -289,6 +407,32 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setBreathGateSeconds(int seconds) async {
|
||||
final clamped = seconds.clamp(3, 60);
|
||||
_breathGateSeconds = clamped.toInt();
|
||||
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
|
||||
// Defer notifyListeners to next microtask to avoid rebuild conflicts
|
||||
Future.microtask(notifyListeners);
|
||||
}
|
||||
|
||||
Future<void> setWordChallengeCount(int count) async {
|
||||
_wordChallengeCount = _normaliseWordChallengeCount(count);
|
||||
await _prefs?.setInt(_keyWordChallengeCount, _wordChallengeCount);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int resolvedWordChallengeCount() {
|
||||
if (_wordChallengeCount != 0) return _wordChallengeCount;
|
||||
final now = DateTime.now().microsecondsSinceEpoch;
|
||||
return 10 + (now % 26);
|
||||
}
|
||||
|
||||
static int _normaliseWordChallengeCount(int count) {
|
||||
if (count == 0) return 0;
|
||||
const allowed = [20, 25, 30, 35];
|
||||
return allowed.contains(count) ? count : 30;
|
||||
}
|
||||
|
||||
Future<void> setEnableTextSelection(bool v) async {
|
||||
_enableTextSelection = v;
|
||||
await _prefs?.setBool(_keyEnableTextSelection, v);
|
||||
@@ -307,13 +451,29 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Extras (Phase 2) ──────────────────────────────────────────────────────
|
||||
|
||||
Future<void> setVideoDownloadEnabled(bool v) async {
|
||||
_videoDownloadEnabled = v;
|
||||
await _prefs?.setBool(_keyVideoDownloadEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideSuggestedPosts(bool v) async {
|
||||
_hideSuggestedPosts = v;
|
||||
await _prefs?.setBool(_keyHideSuggestedPosts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleEnabled(bool v) async {
|
||||
_grayscaleEnabled = v;
|
||||
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setGrayscaleSchedules(List<Map<String, dynamic>> schedules) async {
|
||||
Future<void> setGrayscaleSchedules(
|
||||
List<Map<String, dynamic>> schedules,
|
||||
) async {
|
||||
_grayscaleSchedules = schedules;
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
|
||||
notifyListeners();
|
||||
@@ -321,14 +481,23 @@ class SettingsService extends ChangeNotifier {
|
||||
|
||||
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
|
||||
_grayscaleSchedules.add(schedule);
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
await _prefs?.setString(
|
||||
_keyGrayscaleSchedules,
|
||||
jsonEncode(_grayscaleSchedules),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateGrayscaleSchedule(int index, Map<String, dynamic> schedule) async {
|
||||
Future<void> updateGrayscaleSchedule(
|
||||
int index,
|
||||
Map<String, dynamic> schedule,
|
||||
) async {
|
||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
||||
_grayscaleSchedules[index] = schedule;
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
await _prefs?.setString(
|
||||
_keyGrayscaleSchedules,
|
||||
jsonEncode(_grayscaleSchedules),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -336,20 +505,76 @@ class SettingsService extends ChangeNotifier {
|
||||
Future<void> removeGrayscaleSchedule(int index) async {
|
||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
||||
_grayscaleSchedules.removeAt(index);
|
||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(_grayscaleSchedules));
|
||||
await _prefs?.setString(
|
||||
_keyGrayscaleSchedules,
|
||||
jsonEncode(_grayscaleSchedules),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setHideSponsoredPosts(bool v) async {
|
||||
_hideSponsoredPosts = v;
|
||||
await _prefs?.setBool(_keyHideSponsoredPosts, v);
|
||||
Future<void> setHideShopTab(bool v) async {
|
||||
_hideShopTab = v;
|
||||
await _prefs?.setBool(_keyHideShopTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideLikeCounts(bool v) async {
|
||||
_hideLikeCounts = v;
|
||||
await _prefs?.setBool(_keyHideLikeCounts, v);
|
||||
// ── FocusGram v2 overlay setters ──────────────────────────────────────────
|
||||
Future<void> setV2GhostModeEnabled(bool v) async {
|
||||
_v2GhostModeEnabled = v;
|
||||
await _prefs?.setBool(_keyV2GhostModeEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setV2AdBlockerDomEnabled(bool v) async {
|
||||
_v2AdBlockerDomEnabled = v;
|
||||
await _prefs?.setBool(_keyV2AdBlockerDomEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setV2ContentHiderEnabled(bool v) async {
|
||||
_v2ContentHiderEnabled = v;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setContentStoriesEnabled(bool v) async {
|
||||
if (v && !_v2ContentHiderEnabled) {
|
||||
_v2ContentHiderEnabled = true;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||
}
|
||||
_contentStories = v;
|
||||
await _prefs?.setBool(_keyContentStories, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setContentPostsEnabled(bool v) async {
|
||||
if (v && !_v2ContentHiderEnabled) {
|
||||
_v2ContentHiderEnabled = true;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||
}
|
||||
_contentPosts = v;
|
||||
await _prefs?.setBool(_keyContentPosts, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setContentReelsEnabled(bool v) async {
|
||||
if (v && !_v2ContentHiderEnabled) {
|
||||
_v2ContentHiderEnabled = true;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||
}
|
||||
_contentReels = v;
|
||||
await _prefs?.setBool(_keyContentReels, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setContentSuggestedEnabled(bool v) async {
|
||||
if (v && !_v2ContentHiderEnabled) {
|
||||
_v2ContentHiderEnabled = true;
|
||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
||||
}
|
||||
_contentSuggested = v;
|
||||
await _prefs?.setBool(_keyContentSuggested, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -359,62 +584,138 @@ class SettingsService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setHideShopTab(bool v) async {
|
||||
_hideShopTab = v;
|
||||
await _prefs?.setBool(_keyHideShopTab, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Setter for internal disable reels state (used by minimal mode submenu)
|
||||
/// Auto-disables minimal mode if all features are turned off
|
||||
Future<void> setDisableReelsEntirelyInternal(bool v) async {
|
||||
_disableReelsEntirely = v;
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', v);
|
||||
|
||||
// Check if minimal mode should auto-disable
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Setter for internal disable explore state (used by minimal mode submenu)
|
||||
/// Auto-disables minimal mode if all features are turned off
|
||||
Future<void> setDisableExploreEntirelyInternal(bool v) async {
|
||||
_disableExploreEntirely = v;
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', v);
|
||||
|
||||
// Check if minimal mode should auto-disable
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Setter for home feed scroll blocking state (used by minimal mode submenu).
|
||||
Future<void> setBlockHomeFeedScrollInternal(bool v) async {
|
||||
_blockHomeFeedScroll = v;
|
||||
await _prefs?.setBool('internal_block_home_feed_scroll', v);
|
||||
|
||||
await _checkAndAutoDisableMinimalMode();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Helper: Auto-disable minimal mode if all its features are disabled
|
||||
/// This ensures minimal mode auto-turns-off when user disables all sub-features
|
||||
///
|
||||
/// NOTE: We must check the RAW state variables here, NOT the public getters
|
||||
/// (disableReelsEntirely/disableExploreEntirely), because those getters
|
||||
/// unconditionally return true when _minimalModeEnabled is true, which would
|
||||
/// make the "all disabled" condition impossible to reach.
|
||||
Future<void> _checkAndAutoDisableMinimalMode() async {
|
||||
if (!_minimalModeEnabled) return;
|
||||
|
||||
// Check the RAW saved state, not the getters
|
||||
final rawReels =
|
||||
_prefs?.getBool('internal_disable_reels_entirely') ??
|
||||
_disableReelsEntirely;
|
||||
final rawExplore =
|
||||
_prefs?.getBool('internal_disable_explore_entirely') ??
|
||||
_disableExploreEntirely;
|
||||
|
||||
final rawHomeFeedScroll =
|
||||
_prefs?.getBool('internal_block_home_feed_scroll') ??
|
||||
_blockHomeFeedScroll;
|
||||
|
||||
final allDisabled =
|
||||
!rawReels && !rawExplore && !rawHomeFeedScroll && !_blurExplore;
|
||||
|
||||
if (allDisabled) {
|
||||
_minimalModeEnabled = false;
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Smart minimal mode toggle with state preservation
|
||||
Future<void> setMinimalModeEnabled(bool v) async {
|
||||
if (v) {
|
||||
// Turning ON - save current states BEFORE enabling minimal mode
|
||||
// ── Turning ON ──────────────────────────────────────────────────────────
|
||||
// Save current pre-minimal-mode states so we can restore them later
|
||||
_prevDisableReels = _disableReelsEntirely;
|
||||
_prevDisableExplore = _disableExploreEntirely;
|
||||
_prevBlurExplore = _blurExplore;
|
||||
|
||||
_prevBlockHomeFeedScroll = _blockHomeFeedScroll;
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
|
||||
await _prefs?.setBool(_keyMinimalModePrevDisableExplore, _prevDisableExplore);
|
||||
await _prefs?.setBool(
|
||||
_keyMinimalModePrevDisableExplore,
|
||||
_prevDisableExplore,
|
||||
);
|
||||
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
|
||||
|
||||
// Enable all minimal mode settings
|
||||
await _prefs?.setBool(
|
||||
_keyMinimalModePrevBlockHomeFeedScroll,
|
||||
_prevBlockHomeFeedScroll,
|
||||
);
|
||||
|
||||
_minimalModeEnabled = true;
|
||||
_disableReelsEntirely = true;
|
||||
_disableExploreEntirely = true;
|
||||
_blurExplore = true;
|
||||
|
||||
_blockHomeFeedScroll = true;
|
||||
_blurExplore = true; // blurExplore is controlled by minimal mode while ON
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, true);
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', true);
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', true);
|
||||
await _prefs?.setBool('internal_block_home_feed_scroll', true);
|
||||
await _prefs?.setBool(_keyBlurExplore, true);
|
||||
} else {
|
||||
// Turning OFF - restore to PREVIOUS states (before minimal mode was turned on)
|
||||
// ── Turning OFF ─────────────────────────────────────────────────────────
|
||||
// Restore states that were saved BEFORE minimal mode was enabled.
|
||||
// _prevDisableReels/Explore were saved at the moment minimal mode turned ON.
|
||||
_minimalModeEnabled = false;
|
||||
|
||||
// Simply restore to the states that were saved BEFORE minimal mode was enabled
|
||||
_disableReelsEntirely = _prevDisableReels;
|
||||
_disableExploreEntirely = _prevDisableExplore;
|
||||
_blockHomeFeedScroll = _prevBlockHomeFeedScroll;
|
||||
// For blurExplore: use _prevBlurExplore if it was saved, otherwise fall back
|
||||
// to the saved prefs value (covers the case where no prev was saved).
|
||||
_blurExplore = _prevBlurExplore;
|
||||
|
||||
// Save the restored states
|
||||
|
||||
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
||||
await _prefs?.setBool('internal_disable_reels_entirely', _disableReelsEntirely);
|
||||
await _prefs?.setBool('internal_disable_explore_entirely', _disableExploreEntirely);
|
||||
await _prefs?.setBool(
|
||||
'internal_disable_reels_entirely',
|
||||
_disableReelsEntirely,
|
||||
);
|
||||
await _prefs?.setBool(
|
||||
'internal_disable_explore_entirely',
|
||||
_disableExploreEntirely,
|
||||
);
|
||||
await _prefs?.setBool(
|
||||
'internal_block_home_feed_scroll',
|
||||
_blockHomeFeedScroll,
|
||||
);
|
||||
await _prefs?.setBool(_keyBlurExplore, _blurExplore);
|
||||
|
||||
// After restoring, check whether the user had ALL minimal features OFF
|
||||
// already — if so, minimal mode should stay off (no-op).
|
||||
if (!_disableReelsEntirely &&
|
||||
!_disableExploreEntirely &&
|
||||
!_blockHomeFeedScroll &&
|
||||
!_blurExplore) {
|
||||
// All features are off — minimal mode correctly stays off. No action needed.
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -441,24 +742,69 @@ class SettingsService extends ChangeNotifier {
|
||||
Future<void> setNotifyDMs(bool v) async {
|
||||
_notifyDMs = v;
|
||||
await _prefs?.setBool(_keyNotifyDMs, v);
|
||||
if (v) await NotificationService().requestPermissionsNow();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifyActivity(bool v) async {
|
||||
_notifyActivity = v;
|
||||
await _prefs?.setBool(_keyNotifyActivity, v);
|
||||
if (v) await NotificationService().requestPermissionsNow();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifySessionEnd(bool v) async {
|
||||
_notifySessionEnd = v;
|
||||
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
||||
if (v) await NotificationService().requestPermissionsNow();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNotifyPersistent(bool v) async {
|
||||
_notifyPersistent = v;
|
||||
await _prefs?.setBool(_keyNotifyPersistent, v);
|
||||
if (v) {
|
||||
await NotificationService().requestPermissionsNow();
|
||||
} else {
|
||||
await NotificationService().cancelPersistentNotification(id: 5001);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Focus mode settings ──────────────────────────────────────────────────────
|
||||
Future<void> setGhostMode(bool v) async {
|
||||
_ghostMode = v;
|
||||
await _prefs?.setBool(_keyGhostMode, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoAds(bool v) async {
|
||||
_noAds = v;
|
||||
await _prefs?.setBool(_keyNoAds, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoStories(bool v) async {
|
||||
_noStories = v;
|
||||
await _prefs?.setBool(_keyNoStories, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoReels(bool v) async {
|
||||
_noReels = v;
|
||||
await _prefs?.setBool(_keyNoReels, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoAutoplay(bool v) async {
|
||||
_noAutoplay = v;
|
||||
await _prefs?.setBool(_keyNoAutoplay, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setNoDMs(bool v) async {
|
||||
_noDMs = v;
|
||||
await _prefs?.setBool(_keyNoDMs, v);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -517,7 +517,7 @@ class DisciplineChallenge {
|
||||
];
|
||||
|
||||
/// Shows the word challenge dialog. Returns true if successful.
|
||||
static Future<bool> show(BuildContext context, {int count = 15}) async {
|
||||
static Future<bool> show(BuildContext context, {int count = 30}) async {
|
||||
final list = List<String>.from(_words)..shuffle();
|
||||
final challenge = list.take(count).join(' ');
|
||||
final controller = TextEditingController();
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'script_registry_v2_overlay.dart';
|
||||
|
||||
class ScriptEngineV2Overlay {
|
||||
final InAppWebViewController controller;
|
||||
final SharedPreferences prefs;
|
||||
|
||||
final Map<String, String> _cache = {};
|
||||
|
||||
ScriptEngineV2Overlay({required this.controller, required this.prefs});
|
||||
|
||||
Future<void> initDocumentStartScripts() async {
|
||||
for (final s in V2OverlayScriptRegistry.all) {
|
||||
final enabled = _getEnabled(s.id);
|
||||
s.enabled = enabled;
|
||||
|
||||
if (!enabled) continue;
|
||||
|
||||
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
|
||||
final code = await _load(s.assetPath);
|
||||
if (code == null) continue;
|
||||
|
||||
await controller.addUserScript(
|
||||
userScript: UserScript(
|
||||
source: code,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
allowedOriginRules: {'https://www.instagram.com'},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> injectDocumentEndScripts() async {
|
||||
for (final s in V2OverlayScriptRegistry.all) {
|
||||
final enabled = _getEnabled(s.id);
|
||||
s.enabled = enabled;
|
||||
if (!enabled) continue;
|
||||
|
||||
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END) {
|
||||
final code = await _load(s.assetPath);
|
||||
if (code == null) continue;
|
||||
try {
|
||||
await controller.evaluateJavascript(source: code);
|
||||
} catch (_) {
|
||||
// Best-effort injection; never crash UI.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _pushContentFlagsIfNeeded();
|
||||
}
|
||||
|
||||
Future<void> toggle(V2OverlayScriptId id, bool enabled) async {
|
||||
await prefs.setBool(_enabledKey(id), enabled);
|
||||
|
||||
// For DOCUMENT_START scripts, require reload for clean removal.
|
||||
if (V2OverlayScriptRegistry.byId(id).injectionTime ==
|
||||
UserScriptInjectionTime.AT_DOCUMENT_START) {
|
||||
await controller.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// For DOCUMENT_END scripts: just reload too to ensure DOM effects stop.
|
||||
await controller.reload();
|
||||
}
|
||||
|
||||
bool _getEnabled(V2OverlayScriptId id) {
|
||||
return prefs.getBool(_enabledKey(id)) ??
|
||||
(id == V2OverlayScriptId.themeDetector);
|
||||
}
|
||||
|
||||
String _enabledKey(V2OverlayScriptId id) => 'fg_v2_${id.name}_enabled';
|
||||
|
||||
Future<void> _pushContentFlagsIfNeeded() async {
|
||||
final contentScriptEnabled = _getEnabled(V2OverlayScriptId.contentHider);
|
||||
|
||||
final contentFlags = <String, bool>{
|
||||
'stories': prefs.getBool('content_stories') ?? false,
|
||||
'posts': prefs.getBool('content_posts') ?? false,
|
||||
'reels': prefs.getBool('content_reels') ?? false,
|
||||
'suggested': prefs.getBool('content_suggested') ?? false,
|
||||
};
|
||||
|
||||
// Apply DOM content hider flags
|
||||
if (contentScriptEnabled) {
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgContent?.applyAll(${jsonEncode(contentFlags)});',
|
||||
);
|
||||
}
|
||||
|
||||
// Also push network filter flags used by fetch_interceptor.js
|
||||
// so toggles actually affect request/response behavior.
|
||||
final noAds =
|
||||
(prefs.getBool('no_ads') ?? false) ||
|
||||
(prefs.getBool(_enabledKey(V2OverlayScriptId.adBlockerDom)) ?? false);
|
||||
final blockFeedPosts = contentFlags['posts'] ?? false;
|
||||
final blockSuggested = contentFlags['suggested'] ?? false;
|
||||
final blockReels = contentFlags['reels'] ?? false;
|
||||
final blockAutoplay =
|
||||
prefs.getBool(_enabledKey(V2OverlayScriptId.autoplayBlocker)) ?? false;
|
||||
|
||||
await controller.evaluateJavascript(
|
||||
source:
|
||||
'window.__fgSetFilterConfig?.(${jsonEncode({
|
||||
// Strictly requested: when Hide Feed Posts is ON, block ALL graphql/query.
|
||||
'blockGraphQLQueryWhenFeedPosts': blockFeedPosts,
|
||||
|
||||
// Ads blocker: use existing FocusGram "noAds" toggle (wired elsewhere in prefs).
|
||||
'blockAds': noAds,
|
||||
'blockSponsored': noAds,
|
||||
|
||||
'blockSuggested': blockSuggested,
|
||||
|
||||
// Keep video blocking controlled by existing toggles if desired.
|
||||
'blockVideos': blockReels,
|
||||
'blockAutoplay': blockAutoplay,
|
||||
})});',
|
||||
);
|
||||
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgSetBlockAutoplay?.($blockAutoplay);',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _load(String assetPath) async {
|
||||
if (_cache.containsKey(assetPath)) return _cache[assetPath];
|
||||
try {
|
||||
final code = await rootBundle.loadString(assetPath);
|
||||
_cache[assetPath] = code;
|
||||
return code;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
enum V2OverlayScriptId {
|
||||
ghostMode,
|
||||
themeDetector,
|
||||
adBlockerDom,
|
||||
contentHider,
|
||||
fetchInterceptor,
|
||||
autoplayBlocker,
|
||||
}
|
||||
|
||||
class V2OverlayInstaScript {
|
||||
final V2OverlayScriptId id;
|
||||
final String name;
|
||||
final String assetPath;
|
||||
final UserScriptInjectionTime injectionTime;
|
||||
bool enabled;
|
||||
|
||||
V2OverlayInstaScript({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.assetPath,
|
||||
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
this.enabled = false,
|
||||
});
|
||||
}
|
||||
|
||||
class V2OverlayScriptRegistry {
|
||||
static final List<V2OverlayInstaScript> all = [
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.ghostMode,
|
||||
name: 'ghost_mode',
|
||||
assetPath: 'assets/scripts/ghost_mode.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.themeDetector,
|
||||
name: 'theme_detector',
|
||||
assetPath: 'assets/scripts/theme_detector.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: true,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.adBlockerDom,
|
||||
name: 'ad_blocker_dom',
|
||||
assetPath: 'assets/scripts/ad_blocker_dom.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: false,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.contentHider,
|
||||
name: 'content_hider',
|
||||
assetPath: 'assets/scripts/content_hider.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: false,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.fetchInterceptor,
|
||||
name: 'fetch_interceptor',
|
||||
assetPath: 'assets/scripts/fetch_interceptor.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
),
|
||||
V2OverlayInstaScript(
|
||||
id: V2OverlayScriptId.autoplayBlocker,
|
||||
name: 'autoplay_blocker',
|
||||
assetPath: 'assets/scripts/autoplay_blocker.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
),
|
||||
];
|
||||
|
||||
static V2OverlayInstaScript byId(V2OverlayScriptId id) {
|
||||
return all.firstWhere((s) => s.id == id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/remote_popup_service.dart';
|
||||
|
||||
class RemotePopupHandler {
|
||||
static Future<void> checkAndShow(BuildContext context) async {
|
||||
final popup = await RemotePopupService.fetchPopup();
|
||||
if (popup == null) return;
|
||||
|
||||
final shouldShow = await RemotePopupService.shouldShow(popup);
|
||||
if (!shouldShow) return;
|
||||
|
||||
await RemotePopupService.markShown(popup);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) {
|
||||
return AlertDialog(
|
||||
title: Text(popup.title),
|
||||
content: Text(popup.body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(popup.buttonText),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
Categories:
|
||||
- Connectivity
|
||||
- Social Network
|
||||
License: AGPL-3.0-only
|
||||
AuthorName: Ujwal Chapagain
|
||||
AuthorEmail: notujwal@proton.me
|
||||
SourceCode: https://github.com/Ujwal223/FocusGram
|
||||
IssueTracker: https://github.com/Ujwal223/FocusGram/issues
|
||||
Changelog: https://github.com/Ujwal223/FocusGram/blob/main/CHANGELOG.md
|
||||
|
||||
AutoName: FocusGram
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/Ujwal223/FocusGram
|
||||
|
||||
Builds:
|
||||
- versionName: 1.0.0
|
||||
versionCode: 3
|
||||
commit: v1.0.0
|
||||
output: build/app/outputs/flutter-apk/app-release.apk
|
||||
srclibs:
|
||||
- flutter@stable
|
||||
prebuild:
|
||||
- flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release.yml)
|
||||
- '[[ $flutterVersion ]]'
|
||||
- git -C $$flutter$$ checkout -f $flutterVersion
|
||||
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||
- .flutter/bin/flutter config --no-analytics
|
||||
- .flutter/bin/flutter pub get
|
||||
scanignore:
|
||||
- .flutter/bin/cache
|
||||
scandelete:
|
||||
- .flutter
|
||||
- .pub-cache
|
||||
build:
|
||||
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||
- .flutter/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags
|
||||
VercodeOperation:
|
||||
- '%c * 10 + 1'
|
||||
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
|
||||
CurrentVersion: 1.0.0
|
||||
CurrentVersionCode: 3
|
||||
+16
-16
@@ -37,10 +37,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: app_settings
|
||||
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
|
||||
sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.1"
|
||||
version: "7.0.0"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -213,10 +213,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
|
||||
sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.69.2"
|
||||
version: "0.71.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -290,10 +290,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.1"
|
||||
version: "0.14.4"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -372,10 +372,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
|
||||
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.2"
|
||||
version: "8.1.0"
|
||||
gtk:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -596,10 +596,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "9.0.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -668,18 +668,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.0.1"
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.1"
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -764,10 +764,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+18
-12
@@ -2,7 +2,7 @@ name: focusgram
|
||||
description: "FocusGram is a free, open-source digital wellness tool for Android. Blocks distracting content, sets time limits, and helps you use social media intentionally."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.1.0
|
||||
version: 2.0.0
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.7
|
||||
@@ -11,11 +11,11 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely
|
||||
# WebView engine
|
||||
flutter_inappwebview: ^6.1.5
|
||||
|
||||
# Local key-value persistence — latest stable
|
||||
shared_preferences: ^2.5.4
|
||||
shared_preferences: ^2.5.5
|
||||
|
||||
# Date/time formatting for daily resets — latest stable
|
||||
intl: ^0.20.2
|
||||
@@ -28,26 +28,26 @@ dependencies:
|
||||
|
||||
# URL launcher for About page links — latest stable
|
||||
url_launcher: ^6.3.2
|
||||
package_info_plus: ^8.1.2
|
||||
package_info_plus: ^9.0.0
|
||||
# Handling Instagram deep links — latest stable
|
||||
app_links: ^6.3.2
|
||||
app_links: ^6.4.1
|
||||
# Open system settings — latest stable
|
||||
app_settings: ^6.1.1
|
||||
google_fonts: ^8.0.2
|
||||
http: ^1.3.0
|
||||
permission_handler: ^12.0.1
|
||||
app_settings: ^7.0.0
|
||||
google_fonts: ^8.1.0
|
||||
http: ^1.6.0
|
||||
permission_handler: ^11.4.0
|
||||
# Image/file picker for story uploads on Android
|
||||
image_picker: ^1.1.2
|
||||
image_picker: ^1.2.0
|
||||
flutter_windowmanager_plus: ^1.0.1
|
||||
|
||||
# Charts for on-device screen time dashboard (MIT)
|
||||
fl_chart: ^0.69.0
|
||||
fl_chart: ^0.71.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
flutter_launcher_icons: ^0.14.4
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -55,6 +55,12 @@ flutter:
|
||||
assets:
|
||||
- assets/images/focusgram.png
|
||||
- assets/images/focusgram.ico
|
||||
- assets/scripts/ghost_mode.js
|
||||
- assets/scripts/ad_blocker_dom.js
|
||||
- assets/scripts/content_hider.js
|
||||
- assets/scripts/theme_detector.js
|
||||
- assets/scripts/fetch_interceptor.js
|
||||
- assets/scripts/autoplay_blocker.js
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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,69 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt
|
||||
//
|
||||
// Ghost mode WebView integration notes
|
||||
|
||||
package com.focusgram.focusgram
|
||||
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WEBVIEW WIDGET INTEGRATION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// In your WebView widget (wherever InAppWebView is constructed):
|
||||
//
|
||||
// class InstagramWebView extends StatefulWidget { ... }
|
||||
//
|
||||
// class _InstagramWebViewState extends State<InstagramWebView> {
|
||||
// late GhostModeService _ghost;
|
||||
//
|
||||
// @override
|
||||
// void initState() {
|
||||
// super.initState();
|
||||
// _ghost = GhostModeService();
|
||||
// _ghost.load().then((_) {
|
||||
// setState(() {});
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// return InAppWebView(
|
||||
// initialUrlRequest: URLRequest(
|
||||
// url: WebUri('https://www.instagram.com'),
|
||||
// ),
|
||||
// initialSettings: _ghost.buildWebViewSettings(),
|
||||
// initialUserScripts: UnmodifiableListView(_ghost.buildUserScripts()),
|
||||
// onWebViewCreated: (controller) {
|
||||
// _ghost.onWebViewCreated(controller);
|
||||
// },
|
||||
// onLoadStop: (controller, url) async {
|
||||
// await _ghost.onPageLoaded(url?.uriValue);
|
||||
// },
|
||||
// shouldInterceptRequest: (controller, request) {
|
||||
// return _ghost.shouldInterceptRequest(controller, request);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PUBSPEC ADDITIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// dependencies:
|
||||
// flutter_inappwebview: ^6.1.5 # already present
|
||||
// shared_preferences: ^2.3.0
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DEBUGGING: HOW TO VERIFY GHOST MODE WORKING
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// 1. Enable WebView remote debugging:
|
||||
// In main.dart: if (kDebugMode) { InAppWebViewController.setWebContentsDebuggingEnabled(true); }
|
||||
//
|
||||
// 2. Open chrome://inspect in desktop Chrome while app runs on USB device.
|
||||
//
|
||||
// 3. In DevTools console, run:
|
||||
// window.fetch('/api/v1/media/seen/test/', {method:'POST'})
|
||||
// .then(r => r.text()).then(console.log)
|
||||
// → Should print: {"status":"ok"} (blocked, not sent)
|
||||
//
|
||||
// 4. Check Network tab — blocked requests should NOT appear (they resolve locally).
|
||||
//
|
||||
// 5. For story view test: open a Story, check Network tab for any request to
|
||||
// /media/seen/ or /viewed_story/ — should be absent.
|
||||
@@ -0,0 +1,66 @@
|
||||
# ── pubspec.yaml additions for FocusGram Phase 1 ──────────────────────────
|
||||
#
|
||||
# Merge these into your existing pubspec.yaml
|
||||
#
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# WebView — already in project
|
||||
flutter_inappwebview: ^6.1.5
|
||||
|
||||
# Persistence
|
||||
shared_preferences: ^2.3.2
|
||||
sqflite: ^2.3.3+1 # Phase 2 history DB — add now, use later
|
||||
path_provider: ^2.1.4
|
||||
|
||||
# Network (Phase 2 download manager — add now)
|
||||
dio: ^5.7.0
|
||||
|
||||
# Gallery save (Phase 2)
|
||||
gal: ^2.3.0
|
||||
|
||||
# Permissions (Phase 2)
|
||||
permission_handler: ^11.3.1
|
||||
|
||||
|
||||
flutter:
|
||||
assets:
|
||||
- assets/scripts/ghost_mode.js
|
||||
- assets/scripts/theme_detector.js
|
||||
- assets/scripts/ad_blocker_dom.js
|
||||
- assets/scripts/content_hider.js
|
||||
- assets/scripts/media_detector.js # empty for now
|
||||
- assets/scripts/history_tracker.js # empty for now
|
||||
- assets/blocklists/easylist_mini.txt # Phase 1.5 — download and bundle
|
||||
|
||||
|
||||
# ── AndroidManifest.xml additions ─────────────────────────────────────────
|
||||
#
|
||||
# In android/app/src/main/AndroidManifest.xml, inside <application>:
|
||||
#
|
||||
# <activity
|
||||
# android:name=".MainActivity"
|
||||
# android:windowSoftInputMode="adjustResize"
|
||||
# android:hardwareAccelerated="true" ← ADD THIS
|
||||
# android:exported="true">
|
||||
#
|
||||
# Also add permissions:
|
||||
# <uses-permission android:name="android.permission.INTERNET"/>
|
||||
# <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
# android:maxSdkVersion="28"/>
|
||||
# <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
# <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
|
||||
|
||||
# ── android/app/src/main/res/values/styles.xml ────────────────────────────
|
||||
#
|
||||
# Add to your launch theme for true edge-to-edge:
|
||||
#
|
||||
# <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
# <item name="android:statusBarColor">@android:color/transparent</item>
|
||||
# <item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
# <item name="android:windowTranslucentStatus">false</item>
|
||||
# <item name="android:windowTranslucentNavigation">false</item>
|
||||
# <item name="android:enforceNavigationBarContrast">false</item> ← Android 10+
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* FocusGram DOM Ad Blocker
|
||||
* SHould have Removed 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();
|
||||
});
|
||||
|
||||
const feed = document.querySelector('main') ?? document.body;
|
||||
observer.observe(feed, { childList: true, subtree: true });
|
||||
})();
|
||||
@@ -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') && !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 });
|
||||
})();
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import '../theme/system_ui_manager.dart';
|
||||
|
||||
typedef ActivityCallback = void Function(Map<String, dynamic> event);
|
||||
|
||||
class ChannelRegistry {
|
||||
final ActivityCallback? onActivityEvent;
|
||||
|
||||
const ChannelRegistry({this.onActivityEvent});
|
||||
|
||||
// ── Build all JavaScript channels ─────────────────────────────────────────
|
||||
Set<JavaScriptChannel> build() {
|
||||
return {
|
||||
_ghostChannel(),
|
||||
_themeChannel(),
|
||||
_contentChannel(),
|
||||
_activityChannel(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
JavaScriptChannel _ghostChannel() => JavaScriptChannel(
|
||||
name: 'GhostChannel',
|
||||
onMessageReceived: (msg) {
|
||||
try {
|
||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
||||
if (kDebugMode) {
|
||||
debugPrint('[Ghost] ${data['type']} — ${data['url'] ?? ''}');
|
||||
}
|
||||
// In release: silent. Could surface to a debug overlay in dev builds.
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
|
||||
JavaScriptChannel _themeChannel() => JavaScriptChannel(
|
||||
name: 'ThemeChannel',
|
||||
onMessageReceived: (msg) {
|
||||
SystemUiManager.applyFromThemePayload(msg.message);
|
||||
},
|
||||
);
|
||||
|
||||
JavaScriptChannel _contentChannel() => JavaScriptChannel(
|
||||
name: 'ContentChannel',
|
||||
onMessageReceived: (msg) {
|
||||
// 'ready' signal — engine pushes flags back via evaluateJavascript
|
||||
// handled in ScriptEngine.injectDocumentEndScripts()
|
||||
if (kDebugMode) debugPrint('[Content] ${msg.message}');
|
||||
},
|
||||
);
|
||||
|
||||
JavaScriptChannel _activityChannel() => JavaScriptChannel(
|
||||
name: 'ActivityChannel',
|
||||
onMessageReceived: (msg) {
|
||||
try {
|
||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
||||
onActivityEvent?.call(data);
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* FocusGram Content Hider
|
||||
* Toggleable visibility for: stories tray, feed posts, suggested content.
|
||||
* Flutter controls via window.__fgContent.*
|
||||
* Injected at DOCUMENT_END.
|
||||
*
|
||||
* Improvements:
|
||||
* - Better story tray detection using multiple strategies
|
||||
* - Overlay for hidden feed content with loading indicator
|
||||
* - Improved suggested posts detection
|
||||
* - Fixed reels hiding to avoid blank feed issues
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STYLE_ID = 'fg-content-hider';
|
||||
const OVERLAY_ID = 'fg-content-overlay';
|
||||
let hideStories = false;
|
||||
let hidePosts = false;
|
||||
let hideSuggested = false;
|
||||
let hideReels = false;
|
||||
|
||||
// ─── CSS rules ────────────────────────────────────────────────────────────
|
||||
|
||||
const buildCSS = () => {
|
||||
let css = '';
|
||||
|
||||
if (hideStories) {
|
||||
// Story tray: IG mobile web renders as a scrollable <ul> of circles
|
||||
// near the top of the main feed. We target the outermost container
|
||||
// by its scroll behaviour and presence of story-like items.
|
||||
css += `
|
||||
/* Story tray */
|
||||
div[style*="overflow-x"] > ul,
|
||||
div[role="menu"] > ul,
|
||||
section > div > div:first-child ul[style*="scroll"] {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (hidePosts) {
|
||||
// Feed articles — but NOT DM threads or profile pages
|
||||
// Only apply on /, /reels/ — not /direct/ or /p/ or /@username/
|
||||
css += `
|
||||
/* Feed posts */
|
||||
main article {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (hideReels) {
|
||||
css += `
|
||||
/* Reels in feed */
|
||||
article:has(video) {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
return css;
|
||||
};
|
||||
|
||||
const applyCSS = () => {
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = buildCSS();
|
||||
};
|
||||
|
||||
// ─── JS-based removal for suggested (CSS can't catch dynamic text) ────────
|
||||
|
||||
const removeSuggested = () => {
|
||||
if (!hideSuggested) return;
|
||||
document.querySelectorAll('article, section, div').forEach((el) => {
|
||||
const firstLeaf = el.querySelector('span:not(:has(*)), h4');
|
||||
if (!firstLeaf) return;
|
||||
const t = firstLeaf.textContent.trim().toLowerCase();
|
||||
if (
|
||||
t === 'suggested for you' ||
|
||||
t === 'you might like' ||
|
||||
t === 'suggested posts' ||
|
||||
t === 'posts you might like'
|
||||
) {
|
||||
(el.closest('article') ?? el).remove();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Story tray JS fallback (for when CSS selector misses) ───────────────
|
||||
|
||||
const hideStoryTrayJS = () => {
|
||||
if (!hideStories) return;
|
||||
document.querySelectorAll('ul').forEach((ul) => {
|
||||
const items = ul.querySelectorAll('li');
|
||||
if (items.length < 2) return;
|
||||
// Story bubbles: li contains a button with a circular image
|
||||
const first = items[0];
|
||||
const hasCircleImg =
|
||||
first.querySelector('canvas') ||
|
||||
first.querySelector('img') ||
|
||||
first.querySelector('button');
|
||||
const isHorizontal = ul.scrollWidth > ul.clientWidth;
|
||||
if (hasCircleImg && isHorizontal) {
|
||||
ul.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Public API — Flutter calls these via evaluateJavascript ─────────────
|
||||
|
||||
window.__fgContent = {
|
||||
setHideStories: (val) => {
|
||||
hideStories = !!val;
|
||||
applyCSS();
|
||||
hideStoryTrayJS();
|
||||
},
|
||||
setHidePosts: (val) => {
|
||||
hidePosts = !!val;
|
||||
applyCSS();
|
||||
},
|
||||
setHideReels: (val) => {
|
||||
hideReels = !!val;
|
||||
applyCSS();
|
||||
},
|
||||
setHideSuggested: (val) => {
|
||||
hideSuggested = !!val;
|
||||
if (val) removeSuggested();
|
||||
},
|
||||
applyAll: (flags) => {
|
||||
hideStories = !!flags.stories;
|
||||
hidePosts = !!flags.posts;
|
||||
hideReels = !!flags.reels;
|
||||
hideSuggested = !!flags.suggested;
|
||||
applyCSS();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideStories) hideStoryTrayJS();
|
||||
},
|
||||
};
|
||||
|
||||
// ─── MutationObserver to re-apply on SPA navigation ──────────────────────
|
||||
|
||||
let lastUrl = location.href;
|
||||
const mo = new MutationObserver(() => {
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
setTimeout(() => {
|
||||
applyCSS();
|
||||
if (hideSuggested) removeSuggested();
|
||||
if (hideStories) hideStoryTrayJS();
|
||||
}, 400);
|
||||
}
|
||||
if (hideSuggested) removeSuggested();
|
||||
});
|
||||
|
||||
mo.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Signal ready — Flutter will call applyAll() with stored prefs
|
||||
if (window.ContentChannel) {
|
||||
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* FocusGram Unified Feed Filter via Fetch Interception
|
||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
||||
*
|
||||
* This script intercepts GraphQL fetch calls and filters feed content based on:
|
||||
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
|
||||
* - Sponsored posts (ad_action_link, ad_header_style)
|
||||
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
|
||||
* - Videos/Reels (is_video, media_type, clips_metadata)
|
||||
* - Autoplay blocking (video autoplay prevention)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Configuration flags (set by Flutter via prefs)
|
||||
window.__fgFilterConfig = {
|
||||
blockAds: false,
|
||||
blockSponsored: false,
|
||||
blockSuggested: false,
|
||||
blockVideos: false,
|
||||
blockAutoplay: false,
|
||||
};
|
||||
|
||||
// Helper: Check if a node is an ad
|
||||
const isAdNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
return !!(
|
||||
node.is_ad ||
|
||||
node.ad_id ||
|
||||
node.ad_action_link ||
|
||||
node.ad_action_links?.length > 0 ||
|
||||
node.is_paid_partnership ||
|
||||
node.sponsor_tags?.length > 0 ||
|
||||
(node.commerciality_status === 'ad') ||
|
||||
(node.commerciality_status === 'shoppable_feed_ad') ||
|
||||
(node.product_type === 'ad') ||
|
||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
||||
node.__typename === 'GraphAdStory' ||
|
||||
node.__typename === 'XDTAdFeedUnit' ||
|
||||
(node.__typename?.toLowerCase().includes('ad'))
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is sponsored
|
||||
const isSponsoredNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
return !!(
|
||||
(node.ad_action_link && node.ad_action_link.href) ||
|
||||
(node.ad_header_style && node.ad_header_style !== 'none')
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is suggested content
|
||||
const isSuggestedNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
return !!(
|
||||
node.is_suggested ||
|
||||
node.is_suggested_for_you ||
|
||||
(node.__typename && node.__typename.includes('Suggested'))
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check if a node is a video/reel
|
||||
const isVideoNode = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
return !!(
|
||||
node.is_video ||
|
||||
(node.media_type === 2) ||
|
||||
node.clips_metadata ||
|
||||
(node.__typename && (
|
||||
node.__typename.includes('Clips') ||
|
||||
node.__typename.includes('Video')
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
// Helper: Check for media in carousel
|
||||
const hasVideoInCarousel = (node) => {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
if (node.media_type === 8) {
|
||||
const edges = node.edge_sidecar_to_children?.edges || [];
|
||||
return edges.some(edge => isVideoNode(edge.node));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Main filter function for feed nodes
|
||||
const shouldFilterNode = (node) => {
|
||||
const config = window.__fgFilterConfig;
|
||||
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
|
||||
// Check ads
|
||||
if (config.blockAds && isAdNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check sponsored (separate from ads)
|
||||
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check suggested content
|
||||
if (config.blockSuggested && isSuggestedNode(node)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check videos/reels
|
||||
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Recursively filter GraphQL response edges
|
||||
const filterEdges = (edges, path = []) => {
|
||||
if (!Array.isArray(edges)) return edges;
|
||||
|
||||
return edges.filter(edge => {
|
||||
if (!edge || !edge.node) return true;
|
||||
const node = edge.node;
|
||||
|
||||
// Keep the edge if it doesn't match any filter
|
||||
if (!shouldFilterNode(node)) return true;
|
||||
|
||||
// Log filtered content for debugging
|
||||
if (window.__fgDebugFilter) {
|
||||
const type = node.__typename || 'Unknown';
|
||||
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Recursively walk GraphQL response and filter edges
|
||||
const walkAndFilter = (obj, visited = new Set()) => {
|
||||
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
|
||||
visited.add(obj);
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach(item => walkAndFilter(item, visited));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for edges array (common GraphQL pattern)
|
||||
if (obj.edges && Array.isArray(obj.edges)) {
|
||||
obj.edges = filterEdges(obj.edges);
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key) && key !== '__typename') {
|
||||
const val = obj[key];
|
||||
if (val && typeof val === 'object') {
|
||||
walkAndFilter(val, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Override fetch
|
||||
const _fetch = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async function (input, init) {
|
||||
const url = typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input?.url ?? '';
|
||||
|
||||
// Call original fetch
|
||||
let response = await _fetch(input, init);
|
||||
|
||||
// Only intercept GraphQL feed queries
|
||||
if (!url.includes('/graphql') && !url.includes('/api/v1/feed')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone response to read body
|
||||
const cloned = response.clone();
|
||||
|
||||
try {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = await cloned.json();
|
||||
|
||||
// Filter the response data
|
||||
walkAndFilter(data);
|
||||
|
||||
// Return modified response
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
} catch (e) {
|
||||
// On error, return original response
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
// Preserve native function appearance
|
||||
Object.defineProperty(window, 'fetch', {
|
||||
value: window.fetch,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
||||
|
||||
// Allow Flutter to update config flags
|
||||
window.__fgSetFilterConfig = function (config) {
|
||||
if (typeof config === 'object') {
|
||||
Object.assign(window.__fgFilterConfig, config);
|
||||
if (window.__fgDebugFilter) {
|
||||
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Enable debug logging
|
||||
window.__fgDebugFilter = false;
|
||||
})();
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* FocusGram Ghost Mode
|
||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
||||
* Blocks story-seen, message-seen, and online-presence signals.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ─── Seen API patterns ────────────────────────────────────────────────────
|
||||
const SEEN_PATTERNS = [
|
||||
/\/api\/v1\/media\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/stories\/reel\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
|
||||
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
|
||||
];
|
||||
|
||||
// ─── Activity patterns (like, comment) — intercepted for local history ────
|
||||
const ACTIVITY_PATTERNS = [
|
||||
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
|
||||
/\/api\/v1\/web\/comments\/add\//,
|
||||
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
|
||||
];
|
||||
|
||||
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
|
||||
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
|
||||
|
||||
const fakeOkResponse = () =>
|
||||
new Response(JSON.stringify({ status: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// ─── Fetch override ───────────────────────────────────────────────────────
|
||||
const _fetch = window.fetch.bind(window);
|
||||
|
||||
const patchedFetch = async function (input, init) {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input?.url ?? '';
|
||||
|
||||
// Block seen
|
||||
if (isSeen(url)) {
|
||||
if (window.GhostChannel) {
|
||||
window.GhostChannel.postMessage(
|
||||
JSON.stringify({ type: 'seen_blocked', url })
|
||||
);
|
||||
}
|
||||
return fakeOkResponse();
|
||||
}
|
||||
|
||||
// Intercept activity for local history
|
||||
if (isActivity(url) && window.ActivityChannel) {
|
||||
const body = init?.body;
|
||||
const bodyText =
|
||||
body instanceof URLSearchParams
|
||||
? body.toString()
|
||||
: typeof body === 'string'
|
||||
? body
|
||||
: '';
|
||||
window.ActivityChannel.postMessage(
|
||||
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
|
||||
);
|
||||
}
|
||||
|
||||
return _fetch(input, init);
|
||||
};
|
||||
|
||||
// Disguise as native
|
||||
Object.defineProperty(window, 'fetch', {
|
||||
value: patchedFetch,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
||||
window.fetch[Symbol.toStringTag] = 'fetch';
|
||||
|
||||
// ─── XMLHttpRequest override ──────────────────────────────────────────────
|
||||
const _XHROpen = XMLHttpRequest.prototype.open;
|
||||
const _XHRSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
||||
this._fg_url = url ?? '';
|
||||
this._fg_method = (method ?? '').toUpperCase();
|
||||
return _XHROpen.call(this, method, url, ...args);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function (body) {
|
||||
if (this._fg_url && isSeen(this._fg_url)) {
|
||||
// Fire readyState 4 with fake success without actually sending
|
||||
const self = this;
|
||||
setTimeout(() => {
|
||||
Object.defineProperty(self, 'readyState', { get: () => 4 });
|
||||
Object.defineProperty(self, 'status', { get: () => 200 });
|
||||
Object.defineProperty(self, 'responseText', {
|
||||
get: () => '{"status":"ok"}',
|
||||
});
|
||||
Object.defineProperty(self, 'response', {
|
||||
get: () => '{"status":"ok"}',
|
||||
});
|
||||
self.dispatchEvent(new Event('readystatechange'));
|
||||
self.dispatchEvent(new Event('load'));
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
return _XHRSend.call(this, body);
|
||||
};
|
||||
|
||||
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
|
||||
const _WS = window.WebSocket;
|
||||
|
||||
function PatchedWebSocket(url, protocols) {
|
||||
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
||||
const _send = ws.send.bind(ws);
|
||||
|
||||
ws.send = function (data) {
|
||||
if (typeof data === 'string') {
|
||||
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (
|
||||
parsed?.op === '4' ||
|
||||
parsed?.op === 'seen' ||
|
||||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
|
||||
) {
|
||||
return; // drop
|
||||
}
|
||||
} catch (_) {}
|
||||
// Text-based seen signal check
|
||||
if (data.includes('"seen"') && data.includes('"thread_id"')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return _send(data);
|
||||
};
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
// Preserve WebSocket prototype chain so IG's ws checks pass
|
||||
PatchedWebSocket.prototype = _WS.prototype;
|
||||
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
|
||||
PatchedWebSocket.OPEN = _WS.OPEN;
|
||||
PatchedWebSocket.CLOSING = _WS.CLOSING;
|
||||
PatchedWebSocket.CLOSED = _WS.CLOSED;
|
||||
window.WebSocket = PatchedWebSocket;
|
||||
|
||||
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
|
||||
// Only applied if user enables online-status hiding
|
||||
// Wrapped in a named fn so Flutter can call it:
|
||||
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
|
||||
window.__fgEnableOnlineHide = function () {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
get: () => 'hidden',
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
get: () => true,
|
||||
configurable: true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
|
||||
window.__fgDisableOnlineHide = function () {
|
||||
// Restore by deleting the overrides (falls back to native getter)
|
||||
delete document.visibilityState;
|
||||
delete document.hidden;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
};
|
||||
|
||||
// Signal to Flutter that ghost mode JS is active
|
||||
if (window.GhostChannel) {
|
||||
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,283 @@
|
||||
// lib/services/ghost_mode_script.dart
|
||||
// Injected at AT_DOCUMENT_START — before Instagram's JS caches fetch/XHR refs
|
||||
|
||||
const String kGhostModeJS = r"""
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ─── BLOCKED REST ENDPOINTS ───────────────────────────────────────────────
|
||||
// Patterns matched against full request URL
|
||||
const URL_BLOCKLIST = [
|
||||
// Story viewed receipts
|
||||
/\/api\/v1\/media\/seen\//,
|
||||
/\/api\/v1\/feed\/viewed_story\//,
|
||||
/\/api\/v1\/feed\/reels_tray\/seen\//,
|
||||
|
||||
// DM read receipts (REST fallback path)
|
||||
/\/api\/v1\/direct_v2\/threads\/[^/]+\/mark_item_seen\//,
|
||||
/\/api\/v1\/direct_v2\/mark_item_seen\//,
|
||||
|
||||
// Ephemeral photo/video reply viewed (Anti-Reply Image)
|
||||
/\/api\/v1\/direct_v2\/threads\/[^/]+\/items\/[^/]+\/mark_visual_item_seen\//,
|
||||
/\/api\/v1\/direct_v2\/visual_thread\/[^/]+\/seen\//,
|
||||
|
||||
// Voice message listened receipt
|
||||
/\/api\/v1\/direct_v2\/threads\/[^/]+\/items\/[^/]+\/mark_audio_seen\//,
|
||||
|
||||
// Live join broadcast notification
|
||||
/\/api\/v1\/live\/[^/]+\/join\//,
|
||||
/\/api\/v1\/live\/[^/]+\/get_join_requests\//,
|
||||
/\/api\/v1\/live\/[^/]+\/start_broadcast\//,
|
||||
|
||||
// Analytics / tracking
|
||||
/\/api\/v1\/qe\//,
|
||||
/\/api\/v1\/launcher\/sync\//,
|
||||
/\/api\/v1\/logging\//,
|
||||
/\/api\/v1\/fb_onetap_logging\//,
|
||||
/\/ajax\/bz/,
|
||||
/\/ajax\/logging\//,
|
||||
/\/api\/v1\/stats\//,
|
||||
/\/api\/v1\/fbanalytics\//,
|
||||
/\/api\/v1\/growth\/account_linked_now\//,
|
||||
];
|
||||
|
||||
// ─── BLOCKED GRAPHQL OPERATIONS ───────────────────────────────────────────
|
||||
// Instagram web uses GraphQL for many actions — match by operation name in body
|
||||
const GRAPHQL_OP_BLOCKLIST = [
|
||||
// Story seen
|
||||
'MarkStorySeen',
|
||||
'markStorySeen',
|
||||
'ReelSeenMutation',
|
||||
'reel_seen',
|
||||
'IgFeedSeen',
|
||||
|
||||
// DM read receipts
|
||||
'MarkDirectThreadItemSeen',
|
||||
'markDirectThreadItemSeen',
|
||||
'DirectMarkItemSeen',
|
||||
'DirectThreadMarkSeen',
|
||||
|
||||
// Ephemeral media seen
|
||||
'MarkVisualMessageSeen',
|
||||
'DirectMarkVisualItemSeen',
|
||||
|
||||
// Voice message listened
|
||||
'MarkAudioMessageSeen',
|
||||
'AudioSeenMutation',
|
||||
|
||||
// Live join
|
||||
'LiveJoinBroadcast',
|
||||
'JoinLiveBroadcast',
|
||||
'MarkLiveViewer',
|
||||
|
||||
// Analytics mutations
|
||||
'LogImpression',
|
||||
'LogClick',
|
||||
'FeedbackSeenMutation',
|
||||
];
|
||||
|
||||
// ─── HELPERS ──────────────────────────────────────────────────────────────
|
||||
function shouldBlockUrl(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const path = new URL(url, location.origin).pathname + new URL(url, location.origin).search;
|
||||
return URL_BLOCKLIST.some(p => p.test(path));
|
||||
} catch {
|
||||
return URL_BLOCKLIST.some(p => p.test(url));
|
||||
}
|
||||
}
|
||||
|
||||
function shouldBlockGraphQL(body) {
|
||||
if (!body) return false;
|
||||
let str = '';
|
||||
if (typeof body === 'string') {
|
||||
str = body;
|
||||
} else if (body instanceof URLSearchParams) {
|
||||
str = body.toString();
|
||||
}
|
||||
return GRAPHQL_OP_BLOCKLIST.some(op => str.includes(op));
|
||||
}
|
||||
|
||||
function isGraphQLEndpoint(url) {
|
||||
return url.includes('/graphql') || url.includes('/api/graphql');
|
||||
}
|
||||
|
||||
function fakeOk(body) {
|
||||
return new Response(
|
||||
JSON.stringify(body || { status: 'ok', result: 'success' }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ─── FETCH INTERCEPT ──────────────────────────────────────────────────────
|
||||
const _fetch = window.fetch;
|
||||
window.fetch = async function (input, init) {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof Request
|
||||
? input.url
|
||||
: String(input);
|
||||
|
||||
if (shouldBlockUrl(url)) {
|
||||
return fakeOk();
|
||||
}
|
||||
|
||||
// Clone body for GraphQL inspection without consuming it
|
||||
if (isGraphQLEndpoint(url) && init) {
|
||||
let bodyStr = '';
|
||||
if (typeof init.body === 'string') {
|
||||
bodyStr = init.body;
|
||||
} else if (init.body instanceof URLSearchParams) {
|
||||
bodyStr = init.body.toString();
|
||||
} else if (init.body instanceof FormData) {
|
||||
// FormData: iterate entries to build string
|
||||
try {
|
||||
init.body.forEach((v, k) => { bodyStr += k + '=' + v + '&'; });
|
||||
} catch {}
|
||||
}
|
||||
if (shouldBlockGraphQL(bodyStr)) {
|
||||
return fakeOk();
|
||||
}
|
||||
}
|
||||
|
||||
return _fetch.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ─── XHR INTERCEPT ───────────────────────────────────────────────────────
|
||||
const _xhrOpen = XMLHttpRequest.prototype.open;
|
||||
const _xhrSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
this.__ghostUrl = url;
|
||||
return _xhrOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function (body) {
|
||||
const url = this.__ghostUrl || '';
|
||||
|
||||
const blockByUrl = shouldBlockUrl(url);
|
||||
const blockByOp = isGraphQLEndpoint(url) && shouldBlockGraphQL(
|
||||
typeof body === 'string' ? body : ''
|
||||
);
|
||||
|
||||
if (blockByUrl || blockByOp) {
|
||||
const self = this;
|
||||
// Must use defineProperty because readyState etc are read-only
|
||||
Object.defineProperty(self, 'readyState', { get: () => 4, configurable: true });
|
||||
Object.defineProperty(self, 'status', { get: () => 200, configurable: true });
|
||||
Object.defineProperty(self, 'responseText', {
|
||||
get: () => '{"status":"ok"}',
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(self, 'response', {
|
||||
get: () => '{"status":"ok"}',
|
||||
configurable: true,
|
||||
});
|
||||
setTimeout(() => {
|
||||
try { self.onreadystatechange && self.onreadystatechange(); } catch {}
|
||||
try { self.onload && self.onload(); } catch {}
|
||||
// Fire events
|
||||
['readystatechange', 'load'].forEach(t => {
|
||||
try { self.dispatchEvent(new Event(t)); } catch {}
|
||||
});
|
||||
}, 10);
|
||||
return;
|
||||
}
|
||||
|
||||
return _xhrSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// ─── WEBSOCKET INTERCEPT (typing + live join) ─────────────────────────────
|
||||
// Instagram uses MQTT over WebSocket for real-time events.
|
||||
// Typing indicator = MQTT PUBLISH to topic containing typing/activity tokens.
|
||||
// Live join viewer notification = MQTT PUBLISH with live topic.
|
||||
const _OrigWS = window.WebSocket;
|
||||
|
||||
function GhostWebSocket(url, protocols) {
|
||||
const ws = protocols ? new _OrigWS(url, protocols) : new _OrigWS(url);
|
||||
const _wsSend = ws.send.bind(ws);
|
||||
|
||||
ws.send = function (data) {
|
||||
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
||||
|
||||
// MQTT packet type in top 4 bits of byte 0
|
||||
// PUBLISH = 0x3x (0x30 QoS0, 0x32 QoS1, 0x34 QoS2)
|
||||
const packetType = bytes[0] & 0xF0;
|
||||
if (packetType === 0x30) {
|
||||
// Read remaining length (byte 1, simplified for short packets)
|
||||
// MQTT topic starts at byte 4 (2 byte remaining-len + 2 byte topic-len)
|
||||
try {
|
||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
||||
// Block typing / activity indicator publishes
|
||||
if (
|
||||
decoded.includes('/t_fs') || // foreground state (typing)
|
||||
decoded.includes('activity_indicator') ||
|
||||
decoded.includes('is_typing') ||
|
||||
decoded.includes('direct_typing') ||
|
||||
decoded.includes('/live/viewer') || // live join notification
|
||||
decoded.includes('live_viewer_list')
|
||||
) {
|
||||
return; // Drop packet silently
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} else if (typeof data === 'string') {
|
||||
// Some WS implementations send JSON
|
||||
if (
|
||||
data.includes('typing') ||
|
||||
data.includes('live_viewer') ||
|
||||
data.includes('is_typing')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return _wsSend(data);
|
||||
};
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
// Preserve static properties
|
||||
GhostWebSocket.prototype = _OrigWS.prototype;
|
||||
Object.assign(GhostWebSocket, {
|
||||
CONNECTING: _OrigWS.CONNECTING,
|
||||
OPEN: _OrigWS.OPEN,
|
||||
CLOSING: _OrigWS.CLOSING,
|
||||
CLOSED: _OrigWS.CLOSED,
|
||||
});
|
||||
window.WebSocket = GhostWebSocket;
|
||||
|
||||
// ─── KILL SERVICE WORKER ──────────────────────────────────────────────────
|
||||
// SW runs in separate context — bypasses all JS intercepts above.
|
||||
// Kill registration so our fetch/XHR overrides are the only intercept layer.
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Block new registrations
|
||||
navigator.serviceWorker.register = function () {
|
||||
return Promise.reject(new Error('[GhostMode] SW blocked'));
|
||||
};
|
||||
// Unregister any already registered
|
||||
navigator.serviceWorker.getRegistrations().then(regs => {
|
||||
regs.forEach(r => r.unregister());
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ─── BEACON API BLOCK ────────────────────────────────────────────────────
|
||||
// Instagram uses sendBeacon for analytics on page unload
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon = function (url) {
|
||||
if (shouldBlockUrl(url)) return true; // Lie — say it succeeded
|
||||
// Block all beacon calls to ig domains — analytics only
|
||||
if (url.includes('instagram.com') || url.includes('facebook.com')) return true;
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
console.log('[FocusGram] GhostMode active');
|
||||
})();
|
||||
""";
|
||||
@@ -0,0 +1,233 @@
|
||||
// lib/services/ghost_mode_service.dart
|
||||
//
|
||||
// Three-layer ghost mode:
|
||||
// 1. AT_DOCUMENT_START JS injection — overrides fetch/XHR/WS before IG code runs
|
||||
// 2. shouldInterceptRequest — native Android intercept (catches SW requests too)
|
||||
// 3. FLAG_SECURE — anti-screenshot at OS level (disabled per user request)
|
||||
//
|
||||
// Usage:
|
||||
// final service = GhostModeService();
|
||||
// await service.load(); // reads saved prefs
|
||||
//
|
||||
// InAppWebView(
|
||||
// initialUserScripts: service.buildUserScripts(),
|
||||
// onWebViewCreated: (c) => service.onWebViewCreated(c),
|
||||
// shouldInterceptRequest: service.shouldInterceptRequest,
|
||||
// )
|
||||
//
|
||||
// // Anti-screenshot: disabled per user request
|
||||
// // service.applyWindowFlags(context);
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'ghost_mode_script.dart';
|
||||
|
||||
// ─── Feature flags ────────────────────────────────────────────────────────────
|
||||
class GhostFeatures {
|
||||
bool hideStoryViews;
|
||||
bool hideReadReceipts;
|
||||
bool hideLiveJoin;
|
||||
bool hideTypingIndicator;
|
||||
bool hideVoiceListened;
|
||||
bool hideReplyImageViewed;
|
||||
bool disableAnalytics;
|
||||
|
||||
GhostFeatures({
|
||||
this.hideStoryViews = true,
|
||||
this.hideReadReceipts = true,
|
||||
this.hideLiveJoin = true,
|
||||
this.hideTypingIndicator = true,
|
||||
this.hideVoiceListened = true,
|
||||
this.hideReplyImageViewed = true,
|
||||
this.disableAnalytics = true,
|
||||
});
|
||||
|
||||
static const _keys = {
|
||||
'hideStoryViews': 'gm_story',
|
||||
'hideReadReceipts': 'gm_read',
|
||||
'hideLiveJoin': 'gm_live',
|
||||
'hideTypingIndicator': 'gm_typing',
|
||||
'hideVoiceListened': 'gm_voice',
|
||||
'hideReplyImageViewed': 'gm_reply',
|
||||
'disableAnalytics': 'gm_analytics',
|
||||
};
|
||||
|
||||
Future<void> save() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await Future.wait([
|
||||
p.setBool(_keys['hideStoryViews']!, hideStoryViews),
|
||||
p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
|
||||
p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
|
||||
p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
|
||||
p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
|
||||
p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed),
|
||||
p.setBool(_keys['disableAnalytics']!, disableAnalytics),
|
||||
]);
|
||||
}
|
||||
|
||||
static Future<GhostFeatures> load() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return GhostFeatures(
|
||||
hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
|
||||
hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
|
||||
hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
|
||||
hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
|
||||
hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
|
||||
hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true,
|
||||
disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Native URL blocklist (mirrors JS side — belt & suspenders) ───────────────
|
||||
final _nativeBlocklist = [
|
||||
RegExp(r'/api/v1/media/seen/'),
|
||||
RegExp(r'/api/v1/feed/viewed_story/'),
|
||||
RegExp(r'/api/v1/feed/reels_tray/seen/'),
|
||||
RegExp(r'/api/v1/direct_v2/threads/[^/]+/mark_item_seen/'),
|
||||
RegExp(r'/api/v1/direct_v2/mark_item_seen/'),
|
||||
RegExp(r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_visual_item_seen/'),
|
||||
RegExp(r'/api/v1/direct_v2/visual_thread/[^/]+/seen/'),
|
||||
RegExp(r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_audio_seen/'),
|
||||
RegExp(r'/api/v1/live/[^/]+/join/'),
|
||||
RegExp(r'/api/v1/live/[^/]+/get_join_requests/'),
|
||||
RegExp(r'/api/v1/qe/'),
|
||||
RegExp(r'/api/v1/launcher/sync/'),
|
||||
RegExp(r'/api/v1/logging/'),
|
||||
RegExp(r'/api/v1/stats/'),
|
||||
RegExp(r'/api/v1/fb_onetap_logging/'),
|
||||
RegExp(r'/ajax/bz'),
|
||||
RegExp(r'/ajax/logging/'),
|
||||
];
|
||||
|
||||
final Uint8List _fakeOkBody = Uint8List.fromList('{"status":"ok"}'.codeUnits);
|
||||
|
||||
// ─── Main service ─────────────────────────────────────────────────────────────
|
||||
class GhostModeService {
|
||||
GhostFeatures features = GhostFeatures();
|
||||
InAppWebViewController? _controller;
|
||||
|
||||
Future<void> load() async {
|
||||
features = await GhostFeatures.load();
|
||||
}
|
||||
|
||||
// ─── WebView setup ────────────────────────────────────────────────────────
|
||||
|
||||
/// Call from InAppWebView.onWebViewCreated
|
||||
void onWebViewCreated(InAppWebViewController controller) {
|
||||
_controller = controller;
|
||||
}
|
||||
|
||||
/// Pass to InAppWebView.initialUserScripts
|
||||
/// AT_DOCUMENT_START = injected before ANY page script — critical for
|
||||
/// overriding fetch/XHR before Instagram caches original refs.
|
||||
List<UserScript> buildUserScripts() {
|
||||
if (!_anyGhostEnabled()) return [];
|
||||
return [
|
||||
UserScript(
|
||||
source: _buildConfiguredScript(),
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
forMainFrameOnly: false, // Apply to iframes too
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Pass to InAppWebView.shouldInterceptRequest
|
||||
/// Works at native Android level — catches requests from service workers too.
|
||||
Future<WebResourceResponse?> shouldInterceptRequest(
|
||||
InAppWebViewController controller,
|
||||
WebResourceRequest request,
|
||||
) async {
|
||||
if (!_anyGhostEnabled()) return null;
|
||||
final path = request.url.path;
|
||||
if (_nativeBlocklist.any((re) => re.hasMatch(path))) {
|
||||
return WebResourceResponse(
|
||||
statusCode: 200,
|
||||
reasonPhrase: 'OK',
|
||||
contentType: 'application/json',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
data: _fakeOkBody,
|
||||
);
|
||||
}
|
||||
return null; // Let through
|
||||
}
|
||||
|
||||
/// InAppWebViewSettings required for shouldInterceptRequest to fire
|
||||
InAppWebViewSettings buildWebViewSettings() {
|
||||
return InAppWebViewSettings(
|
||||
useShouldInterceptRequest: true, // Enable native intercept callback
|
||||
useShouldOverrideUrlLoading: true,
|
||||
javaScriptEnabled: true,
|
||||
disableDefaultErrorPage: true,
|
||||
useHybridComposition:
|
||||
true, // Needed for FLAG_SECURE to work (though disabled)
|
||||
// Disable service worker cache that can replay seen-events offline
|
||||
cacheEnabled: false, // Start clean — optional, tradeoff vs perf
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Anti-screenshot ────────────────────────────────────────────────────────
|
||||
// Anti-screenshot disabled per user request
|
||||
|
||||
Future<void> applyWindowFlags(BuildContext context) async {
|
||||
// Anti-screenshot disabled per user request
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> clearWindowFlags() async {
|
||||
// Anti-screenshot disabled per user request
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Re-inject after page nav (SPA navigation doesn't re-run userScripts) ──
|
||||
|
||||
/// Call from InAppWebView.onLoadStop
|
||||
Future<void> onPageLoaded(Uri? url) async {
|
||||
if (_controller == null || !_anyGhostEnabled()) return;
|
||||
// Re-inject on each navigation — SPA route changes don't re-fire userScripts
|
||||
await _controller!.evaluateJavascript(source: _buildConfiguredScript());
|
||||
}
|
||||
|
||||
// ─── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
bool _anyGhostEnabled() =>
|
||||
features.hideStoryViews ||
|
||||
features.hideReadReceipts ||
|
||||
features.hideLiveJoin ||
|
||||
features.hideTypingIndicator ||
|
||||
features.hideVoiceListened ||
|
||||
features.hideReplyImageViewed ||
|
||||
features.disableAnalytics;
|
||||
|
||||
/// Build JS with feature flags baked in — disabled features skip their blocks
|
||||
String _buildConfiguredScript() {
|
||||
// Prepend a config object that the script reads
|
||||
// The kGhostModeJS already handles all features unconditionally.
|
||||
// If you need per-feature toggles, swap the const for a builder function.
|
||||
//
|
||||
// For now: only inject if ghost mode is on at all.
|
||||
// Per-feature granularity can be added by replacing URL_BLOCKLIST
|
||||
// sections conditionally — left as extension point.
|
||||
return '''
|
||||
window.__GHOST_CONFIG__ = ${_configJson()};
|
||||
$kGhostModeJS
|
||||
''';
|
||||
}
|
||||
|
||||
String _configJson() {
|
||||
return '''{
|
||||
"hideStoryViews": ${features.hideStoryViews},
|
||||
"hideReadReceipts": ${features.hideReadReceipts},
|
||||
"hideLiveJoin": ${features.hideLiveJoin},
|
||||
"hideTypingIndicator": ${features.hideTypingIndicator},
|
||||
"hideVoiceListened": ${features.hideVoiceListened},
|
||||
"hideReplyImageViewed": ${features.hideReplyImageViewed},
|
||||
"disableAnalytics": ${features.disableAnalytics}
|
||||
}''';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../injection/script_engine.dart';
|
||||
import '../injection/script_registry.dart';
|
||||
import '../channels/channel_registry.dart';
|
||||
import '../webview/webview_config.dart';
|
||||
import '../services/ghost_mode_service.dart';
|
||||
|
||||
class InstagramWebView extends StatefulWidget {
|
||||
const InstagramWebView({super.key});
|
||||
|
||||
@override
|
||||
State<InstagramWebView> createState() => InstagramWebViewState();
|
||||
}
|
||||
|
||||
class InstagramWebViewState extends State<InstagramWebView> {
|
||||
InAppWebViewController? _controller;
|
||||
ScriptEngine? _engine;
|
||||
GhostModeService? _ghostMode;
|
||||
bool _loading = true;
|
||||
|
||||
// ── Public API — call from Settings screen ─────────────────────────────
|
||||
Future<void> toggleScript(ScriptId id, bool enabled) async {
|
||||
await _engine?.toggle(id, enabled);
|
||||
}
|
||||
|
||||
Future<void> setContentFlag(String flag, bool value) async {
|
||||
await _engine?.setContentFlag(flag, value);
|
||||
}
|
||||
|
||||
Future<void> setOnlineHide(bool enabled) async {
|
||||
await _engine?.setOnlineHide(enabled);
|
||||
}
|
||||
|
||||
// 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
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
InAppWebView(
|
||||
initialUrlRequest: WebViewConfig.initialRequest,
|
||||
initialSettings:
|
||||
_ghostMode?.buildWebViewSettings() ?? WebViewConfig.settings,
|
||||
|
||||
// ── ContentBlockers — merged base + EasyList rules ──────────────
|
||||
contentBlockers: WebViewConfig.baseContentBlockers,
|
||||
|
||||
// ── User Scripts — AT_DOCUMENT_START critical for ghost mode ─────
|
||||
initialUserScripts: UnmodifiableListView(
|
||||
_ghostMode?.buildUserScripts() ?? [],
|
||||
),
|
||||
|
||||
// ── JavaScript channels ─────────────────────────────────────────
|
||||
javascriptChannels: ChannelRegistry(
|
||||
onActivityEvent: (event) {
|
||||
// Forward to history DB in Phase 2
|
||||
debugPrint('[Activity] $event');
|
||||
},
|
||||
).build(),
|
||||
|
||||
onWebViewCreated: (controller) async {
|
||||
_controller = controller;
|
||||
|
||||
//Interceptor for adblock
|
||||
shouldInterceptRequest:
|
||||
(controller, request) async {
|
||||
final url = request.url.toString();
|
||||
|
||||
const adDomains = [
|
||||
'an.facebook.com',
|
||||
'connect.facebook.net',
|
||||
'pixel.facebook.com',
|
||||
'graph.facebook.com/logging',
|
||||
'www.instagram.com/ajax/bz',
|
||||
'www.instagram.com/api/v1/web/comet/logcalls',
|
||||
'doubleclick.net',
|
||||
'googletagmanager.com',
|
||||
'scorecardresearch.com',
|
||||
];
|
||||
|
||||
if (adDomains.any(url.contains)) {
|
||||
return WebResourceResponse(
|
||||
contentType: 'application/json',
|
||||
httpStatus: WebResourceResponseHTTPStatus(statusCode: 200),
|
||||
data: Uint8List.fromList(utf8.encode('{}')),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Initialize GhostModeService
|
||||
_ghostMode = GhostModeService();
|
||||
await _ghostMode!.load();
|
||||
|
||||
// Initialize existing script engine for other scripts
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_engine = ScriptEngine(controller: controller, prefs: prefs);
|
||||
|
||||
// Inject DOCUMENT_START scripts (ghost mode, etc.)
|
||||
await _engine!.initDocumentStartScripts();
|
||||
},
|
||||
|
||||
onLoadStop: (controller, url) async {
|
||||
// Inject DOCUMENT_END scripts
|
||||
await _engine?.injectDocumentEndScripts();
|
||||
|
||||
// Re-inject ghost mode scripts on SPA navigation
|
||||
await _ghostMode?.onPageLoaded(url?.uriValue);
|
||||
|
||||
setState(() => _loading = false);
|
||||
},
|
||||
|
||||
onLoadStart: (controller, url) {
|
||||
setState(() => _loading = true);
|
||||
},
|
||||
|
||||
onProgressChanged: (controller, progress) {
|
||||
if (progress >= 80 && _loading) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Navigation policy ───────────────────────────────────────────
|
||||
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
||||
final url = navigationAction.request.url?.toString() ?? '';
|
||||
|
||||
// Block external redirects — keep user inside instagram.com
|
||||
if (!url.contains('instagram.com') &&
|
||||
!url.contains('cdninstagram.com') &&
|
||||
!url.contains('fbcdn.net') &&
|
||||
url.startsWith('http')) {
|
||||
// TODO: open in external browser via url_launcher
|
||||
return NavigationActionPolicy.CANCEL;
|
||||
}
|
||||
|
||||
return NavigationActionPolicy.ALLOW;
|
||||
},
|
||||
|
||||
// ── Re-inject on SPA navigation ─────────────────────────────────
|
||||
// Instagram is a SPA — URL changes via pushState don't trigger
|
||||
// onLoadStop. Re-inject DOM scripts on URL change.
|
||||
onUpdateVisitedHistory: (controller, url, isReload) async {
|
||||
if (!isReload!) {
|
||||
await _engine?.injectDocumentEndScripts();
|
||||
}
|
||||
},
|
||||
|
||||
// ── Native intercept for service worker requests ────────────────
|
||||
shouldInterceptRequest: (controller, request) async {
|
||||
return await _ghostMode?.shouldInterceptRequest(
|
||||
controller,
|
||||
request,
|
||||
) ??
|
||||
null;
|
||||
},
|
||||
),
|
||||
|
||||
// ── Subtle loading indicator ──────────────────────────────────────
|
||||
if (_loading)
|
||||
const LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'core/theme/system_ui_manager.dart';
|
||||
import 'core/webview/instagram_webview.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Enable web contents debugging for ghost mode verification
|
||||
if (kDebugMode) {
|
||||
InAppWebViewController.setWebContentsDebuggingEnabled(true);
|
||||
}
|
||||
|
||||
await SystemUiManager.enableEdgeToEdge();
|
||||
runApp(const FocusGramApp());
|
||||
}
|
||||
|
||||
class FocusGramApp extends StatelessWidget {
|
||||
const FocusGramApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'FocusGram',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0095F6)),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MainScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
const MainScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> {
|
||||
final _webViewKey = GlobalKey<InstagramWebViewState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
// backgroundColor transparent — lets WebView color bleed to system bars
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
// bottom: false — let WebView extend behind nav bar for true edge-to-edge
|
||||
bottom: false,
|
||||
child: InstagramWebView(key: _webViewKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'script_registry.dart';
|
||||
|
||||
class ScriptEngine {
|
||||
final InAppWebViewController controller;
|
||||
final SharedPreferences prefs;
|
||||
|
||||
// Cache raw JS per asset path to avoid repeated rootBundle reads
|
||||
final Map<String, String> _cache = {};
|
||||
|
||||
ScriptEngine({required this.controller, required this.prefs});
|
||||
|
||||
// ── Init: restore enabled state from prefs, inject DOCUMENT_START scripts ─
|
||||
// Call this from onWebViewCreated (for DOCUMENT_START scripts via addUserScript)
|
||||
Future<void> initDocumentStartScripts() async {
|
||||
for (final script in ScriptRegistry.all) {
|
||||
// Restore enabled state
|
||||
final saved = prefs.getBool('script_${script.id.name}');
|
||||
if (saved != null) script.enabled = saved;
|
||||
|
||||
if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START &&
|
||||
script.enabled) {
|
||||
final code = await _load(script.assetPath);
|
||||
if (code == null) continue;
|
||||
await controller.addUserScript(
|
||||
UserScript(
|
||||
source: code,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
allowedOriginRules: {'https://www.instagram.com'},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize script configurations after scripts are loaded
|
||||
await _initializeScriptConfigs();
|
||||
}
|
||||
|
||||
// ── Initialize script configurations from saved preferences ────────────────
|
||||
Future<void> _initializeScriptConfigs() async {
|
||||
// Fetch interceptor config
|
||||
final fetchInterceptor = ScriptRegistry.byId(ScriptId.fetchInterceptor);
|
||||
if (fetchInterceptor.enabled) {
|
||||
await _updateFetchInterceptorConfig();
|
||||
}
|
||||
|
||||
// Autoplay blocker config
|
||||
final autoplayBlocker = ScriptRegistry.byId(ScriptId.autoplayBlocker);
|
||||
if (autoplayBlocker.enabled) {
|
||||
await _updateAutoplayBlockerConfig();
|
||||
}
|
||||
|
||||
// Content hider flags
|
||||
await _pushContentFlags();
|
||||
}
|
||||
|
||||
// ── Called from onLoadStop: inject all DOCUMENT_END enabled scripts ────────
|
||||
Future<void> injectDocumentEndScripts() async {
|
||||
for (final script in ScriptRegistry.all.where(
|
||||
(s) =>
|
||||
s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END &&
|
||||
s.enabled,
|
||||
)) {
|
||||
await _inject(script);
|
||||
}
|
||||
// After content_hider is injected, push saved content flags
|
||||
await _pushContentFlags();
|
||||
}
|
||||
|
||||
// ── Toggle a script on/off ─────────────────────────────────────────────────
|
||||
Future<void> toggle(ScriptId id, bool enabled) async {
|
||||
final script = ScriptRegistry.byId(id);
|
||||
script.enabled = enabled;
|
||||
await prefs.setBool('script_${id.name}', enabled);
|
||||
|
||||
if (!enabled) {
|
||||
if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
|
||||
await controller.removeUserScriptsByGroupName(id.name);
|
||||
}
|
||||
// For DOM scripts: reload so mutations stop
|
||||
await controller.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
|
||||
final code = await _load(script.assetPath);
|
||||
if (code == null) return;
|
||||
await controller.addUserScript(
|
||||
UserScript(
|
||||
source: code,
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
groupName: id.name,
|
||||
allowedOriginRules: {'https://www.instagram.com'},
|
||||
),
|
||||
);
|
||||
await controller.reload();
|
||||
} else {
|
||||
await _inject(script);
|
||||
}
|
||||
|
||||
// Re-initialize configurations after toggle
|
||||
await _initializeScriptConfigs();
|
||||
}
|
||||
|
||||
// ── Content hider flags ────────────────────────────────────────────────────
|
||||
Future<void> setContentFlag(String flag, bool value) async {
|
||||
await prefs.setBool('content_$flag', value);
|
||||
await _pushContentFlags();
|
||||
}
|
||||
|
||||
Future<void> _pushContentFlags() async {
|
||||
final contentHider = ScriptRegistry.byId(ScriptId.contentHider);
|
||||
if (!contentHider.enabled) return;
|
||||
|
||||
final flags = {
|
||||
'stories': prefs.getBool('content_stories') ?? false,
|
||||
'posts': prefs.getBool('content_posts') ?? false,
|
||||
'reels': prefs.getBool('content_reels') ?? false,
|
||||
'suggested': prefs.getBool('content_suggested') ?? false,
|
||||
};
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgContent?.applyAll(${jsonEncode(flags)})',
|
||||
);
|
||||
}
|
||||
|
||||
// ── Fetch interceptor configuration ────────────────────────────────────────
|
||||
Future<void> setFetchInterceptorConfig({
|
||||
bool? blockAds,
|
||||
bool? blockSponsored,
|
||||
bool? blockSuggested,
|
||||
bool? blockVideos,
|
||||
bool? blockAutoplay,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final config = {
|
||||
'blockAds': blockAds ?? prefs.getBool('fetch_block_ads') ?? false,
|
||||
'blockSponsored':
|
||||
blockSponsored ?? prefs.getBool('fetch_block_sponsored') ?? false,
|
||||
'blockSuggested':
|
||||
blockSuggested ?? prefs.getBool('fetch_block_suggested') ?? false,
|
||||
'blockVideos':
|
||||
blockVideos ?? prefs.getBool('fetch_block_videos') ?? false,
|
||||
'blockAutoplay':
|
||||
blockAutoplay ?? prefs.getBool('fetch_block_autoplay') ?? false,
|
||||
};
|
||||
|
||||
// Save individual prefs
|
||||
await prefs.setBool('fetch_block_ads', config['blockAds']!);
|
||||
await prefs.setBool('fetch_block_sponsored', config['blockSponsored']!);
|
||||
await prefs.setBool('fetch_block_suggested', config['blockSuggested']!);
|
||||
await prefs.setBool('fetch_block_videos', config['blockVideos']!);
|
||||
await prefs.setBool('fetch_block_autoplay', config['blockAutoplay']!);
|
||||
|
||||
// Apply to webview
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgSetFilterConfig?.(${jsonEncode(config)})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateFetchInterceptorConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await setFetchInterceptorConfig(
|
||||
blockAds: prefs.getBool('fetch_block_ads'),
|
||||
blockSponsored: prefs.getBool('fetch_block_sponsored'),
|
||||
blockSuggested: prefs.getBool('fetch_block_suggested'),
|
||||
blockVideos: prefs.getBool('fetch_block_videos'),
|
||||
blockAutoplay: prefs.getBool('fetch_block_autoplay'),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Autoplay blocker configuration ─────────────────────────────────────────
|
||||
Future<void> setAutoplayBlockerEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('autoplay_blocker_enabled', enabled);
|
||||
|
||||
// Apply to webview
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgSetBlockAutoplay?.(${jsonEncode(enabled)})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _updateAutoplayBlockerConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await setAutoplayBlockerEnabled(
|
||||
prefs.getBool('autoplay_blocker_enabled') ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Online status hide ─────────────────────────────────────────────────────
|
||||
Future<void> setOnlineHide(bool enabled) async {
|
||||
await prefs.setBool('ghost_online_hide', enabled);
|
||||
if (enabled) {
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgEnableOnlineHide?.()',
|
||||
);
|
||||
} else {
|
||||
await controller.evaluateJavascript(
|
||||
source: 'window.__fgDisableOnlineHide?.()',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
Future<void> _inject(InstaScript script) async {
|
||||
final code = await _load(script.assetPath);
|
||||
if (code == null) return;
|
||||
try {
|
||||
await controller.evaluateJavascript(source: code);
|
||||
} catch (e) {
|
||||
// Script failed — log but don't crash
|
||||
debugPrint('[ScriptEngine] Failed to inject ${script.id.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _load(String assetPath) async {
|
||||
if (_cache.containsKey(assetPath)) return _cache[assetPath];
|
||||
try {
|
||||
final code = await rootBundle.loadString(assetPath);
|
||||
_cache[assetPath] = code;
|
||||
return code;
|
||||
} catch (e) {
|
||||
debugPrint('[ScriptEngine] Asset not found: $assetPath');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
enum ScriptId {
|
||||
ghostMode,
|
||||
themeDetector,
|
||||
contentHider,
|
||||
fetchInterceptor,
|
||||
autoplayBlocker,
|
||||
mediaDetector,
|
||||
historyTracker,
|
||||
}
|
||||
|
||||
class InstaScript {
|
||||
final ScriptId id;
|
||||
final String name;
|
||||
final String description;
|
||||
final String assetPath;
|
||||
final UserScriptInjectionTime injectionTime;
|
||||
bool enabled;
|
||||
|
||||
InstaScript({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.assetPath,
|
||||
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
this.enabled = false,
|
||||
});
|
||||
}
|
||||
|
||||
class ScriptRegistry {
|
||||
static final List<InstaScript> all = [
|
||||
// ── DOCUMENT_START — must be before IG's JS loads ──
|
||||
InstaScript(
|
||||
id: ScriptId.fetchInterceptor,
|
||||
name: 'Ad & Content Blocker',
|
||||
description:
|
||||
'Blocks ads, sponsored, suggested content, videos, and prevents autoplay via GraphQL interception.',
|
||||
assetPath: 'assets/scripts/fetch_interceptor.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
),
|
||||
InstaScript(
|
||||
id: ScriptId.autoplayBlocker,
|
||||
name: 'Autoplay Blocker',
|
||||
description: 'Prevents video autoplay.',
|
||||
assetPath: 'assets/scripts/autoplay_blocker.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
||||
enabled: false,
|
||||
),
|
||||
|
||||
// ── DOCUMENT_END — DOM must be ready ──
|
||||
InstaScript(
|
||||
id: ScriptId.themeDetector,
|
||||
name: 'Theme Detector',
|
||||
description: 'Reads page colors and syncs system UI bars.',
|
||||
assetPath: 'assets/scripts/theme_detector.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: true, // always on — needed for native feel
|
||||
),
|
||||
InstaScript(
|
||||
id: ScriptId.contentHider,
|
||||
name: 'Content Hider',
|
||||
description: 'Toggleable hide for stories, posts, reels, suggested.',
|
||||
assetPath: 'assets/scripts/content_hider.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: false,
|
||||
),
|
||||
// Phase 2 scripts — registered but empty asset paths for now
|
||||
InstaScript(
|
||||
id: ScriptId.mediaDetector,
|
||||
name: 'Media Downloader',
|
||||
description: 'Injects download buttons on photos and reels.',
|
||||
assetPath: 'assets/scripts/media_detector.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: false,
|
||||
),
|
||||
InstaScript(
|
||||
id: ScriptId.historyTracker,
|
||||
name: 'History Tracker',
|
||||
description: 'Locally tracks reels watched and actions taken.',
|
||||
assetPath: 'assets/scripts/history_tracker.js',
|
||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
||||
enabled: false,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class SystemUiManager {
|
||||
// ── Apply colors read from JS ThemeDetector ─────────────────────────────
|
||||
static void applyFromThemePayload(String jsonPayload) {
|
||||
try {
|
||||
final data = jsonDecode(jsonPayload) as Map<String, dynamic>;
|
||||
final isDark = data['isDark'] as bool? ?? false;
|
||||
final bodyHex =
|
||||
data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
|
||||
final navHex = data['navHex'] as String? ?? bodyHex;
|
||||
|
||||
final bodyColor = _parseHex(bodyHex);
|
||||
final navColor = _parseHex(navHex);
|
||||
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: bodyColor,
|
||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
||||
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
|
||||
systemNavigationBarColor: navColor,
|
||||
systemNavigationBarIconBrightness: isDark
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Fallback to safe defaults
|
||||
applyLight();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fallback presets ─────────────────────────────────────────────────────
|
||||
static void applyLight() {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Color(0xFFFFFFFF),
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Color(0xFFFFFFFF),
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void applyDark() {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Color(0xFF000000),
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
systemNavigationBarColor: Color(0xFF000000),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Edge-to-edge setup — call once in main() ─────────────────────────────
|
||||
static Future<void> enableEdgeToEdge() async {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||
applyLight(); // default until theme detector fires
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
static Color _parseHex(String hex) {
|
||||
final clean = hex.replaceAll('#', '');
|
||||
if (clean.length == 6) {
|
||||
return Color(int.parse('FF$clean', radix: 16));
|
||||
} else if (clean.length == 8) {
|
||||
return Color(int.parse(clean, radix: 16));
|
||||
}
|
||||
return Colors.white;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* FocusGram Theme Detector
|
||||
* Reads Instagram's background + bottom nav color and reports to Flutter.
|
||||
* Injected at DOCUMENT_END so DOM is ready.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const parseRgb = (str) => {
|
||||
// Parses "rgb(255, 255, 255)" or "rgba(0, 0, 0, 1)" → { r, g, b, a }
|
||||
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
||||
if (!m) return null;
|
||||
return {
|
||||
r: parseInt(m[1]),
|
||||
g: parseInt(m[2]),
|
||||
b: parseInt(m[3]),
|
||||
a: m[4] !== undefined ? parseFloat(m[4]) : 1,
|
||||
};
|
||||
};
|
||||
|
||||
const toHex = ({ r, g, b }) =>
|
||||
'#' +
|
||||
[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
const detectColors = () => {
|
||||
// Background — Instagram sets it on <body> or a root div
|
||||
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
||||
|
||||
// Bottom nav — IG mobile web renders a fixed bottom bar
|
||||
// Target by role="navigation" or position:fixed at bottom
|
||||
let navBg = bodyBg;
|
||||
const navCandidates = document.querySelectorAll(
|
||||
'nav, [role="navigation"], div[style*="bottom"]'
|
||||
);
|
||||
for (const el of navCandidates) {
|
||||
const style = getComputedStyle(el);
|
||||
if (
|
||||
style.position === 'fixed' &&
|
||||
parseInt(style.bottom) <= 10 &&
|
||||
style.backgroundColor !== 'rgba(0, 0, 0, 0)'
|
||||
) {
|
||||
navBg = style.backgroundColor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const bodyColor = parseRgb(bodyBg);
|
||||
const navColor = parseRgb(navBg);
|
||||
|
||||
if (!bodyColor) return;
|
||||
|
||||
// Determine dark/light
|
||||
const luminance = (0.299 * bodyColor.r + 0.587 * bodyColor.g + 0.114 * bodyColor.b) / 255;
|
||||
const isDark = luminance < 0.5;
|
||||
|
||||
const payload = {
|
||||
bodyHex: toHex(bodyColor),
|
||||
navHex: navColor ? toHex(navColor) : toHex(bodyColor),
|
||||
isDark,
|
||||
};
|
||||
|
||||
if (window.ThemeChannel) {
|
||||
window.ThemeChannel.postMessage(JSON.stringify(payload));
|
||||
}
|
||||
};
|
||||
|
||||
// Run on load
|
||||
detectColors();
|
||||
|
||||
// Watch for Instagram's dark mode toggle (adds/removes class on <html>)
|
||||
const observer = new MutationObserver(detectColors);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style', 'color-scheme'],
|
||||
});
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style'],
|
||||
});
|
||||
|
||||
// Also run after navigation (Instagram is SPA, URL changes without reload)
|
||||
let lastUrl = location.href;
|
||||
new MutationObserver(() => {
|
||||
if (location.href !== lastUrl) {
|
||||
lastUrl = location.href;
|
||||
setTimeout(detectColors, 300); // small delay for IG to render new page
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
class WebViewConfig {
|
||||
// ── User agent — exactly as user specified ────────────────────────────────
|
||||
static const String userAgent =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
|
||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
|
||||
'Version/26.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
static const String instagramUrl = 'https://www.instagram.com/';
|
||||
|
||||
// ── Base InAppWebView settings ────────────────────────────────────────────
|
||||
static InAppWebViewSettings get settings => InAppWebViewSettings(
|
||||
// Identity
|
||||
userAgent: userAgent,
|
||||
|
||||
// Performance
|
||||
hardwareAcceleration: true,
|
||||
// useHybridComposition: false breaks some Android 12+ devices — keep true
|
||||
useHybridComposition: true,
|
||||
cacheEnabled: true,
|
||||
cacheMode: CacheMode.LOAD_DEFAULT,
|
||||
|
||||
// Media
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
allowsPictureInPictureMediaPlayback: true,
|
||||
|
||||
// UX — feel like native, not browser
|
||||
overScrollMode: OverScrollMode.NEVER,
|
||||
verticalScrollBarEnabled: false,
|
||||
horizontalScrollBarEnabled: false,
|
||||
supportZoom: false,
|
||||
builtInZoomControls: false,
|
||||
displayZoomControls: false,
|
||||
scrollsToTop: true,
|
||||
|
||||
// JS & storage — IG needs all of these
|
||||
javaScriptEnabled: true,
|
||||
javaScriptCanOpenWindowsAutomatically: false,
|
||||
domStorageEnabled: true,
|
||||
databaseEnabled: true,
|
||||
allowFileAccessFromFileURLs: false,
|
||||
allowUniversalAccessFromFileURLs: false,
|
||||
|
||||
// Compat
|
||||
mixedContentMode: MixedContentMode.COMPATIBILITY_MODE,
|
||||
safeBrowsingEnabled:
|
||||
false, // IG known-safe domain, no need for extra latency
|
||||
// Disable Chrome custom tabs popup (links open in WebView)
|
||||
suppressesIncrementalRendering: false,
|
||||
|
||||
// iOS specific
|
||||
allowsBackForwardNavigationGestures: true,
|
||||
allowsLinkPreview: false,
|
||||
isFraudulentWebsiteWarningEnabled: false,
|
||||
|
||||
// Android specific
|
||||
forceDark: ForceDark.AUTO, // respect system dark mode
|
||||
algorithmicDarkeningAllowed: true,
|
||||
);
|
||||
|
||||
// ── ContentBlocker rules — ad network blocking ─────────────────────────
|
||||
// These are baked-in rules targeting known ad/tracking domains.
|
||||
// Full EasyList parsing is handled separately and merged at runtime.
|
||||
// This set is always-on regardless of user toggle.
|
||||
static List<ContentBlocker> get baseContentBlockers => [
|
||||
// Meta ad infrastructure
|
||||
_block('.*connect\\.facebook\\.net.*'),
|
||||
_block('.*graph\\.facebook\\.com.*ads.*'),
|
||||
_block('.*an\\.facebook\\.com.*'),
|
||||
|
||||
// Google ad networks
|
||||
_block('.*doubleclick\\.net.*'),
|
||||
_block('.*googleadservices\\.com.*'),
|
||||
_block('.*googlesyndication\\.com.*'),
|
||||
_block('.*adservice\\.google\\..*'),
|
||||
|
||||
// Common trackers
|
||||
_block('.*scorecardresearch\\.com.*'),
|
||||
_block('.*quantserve\\.com.*'),
|
||||
_block('.*chartbeat\\.com.*'),
|
||||
_block('.*newrelic\\.com.*'),
|
||||
|
||||
// Ad servers
|
||||
_block('.*ads\\.yahoo\\.com.*'),
|
||||
_block('.*advertising\\.com.*'),
|
||||
_block('.*adnxs\\.com.*'),
|
||||
_block('.*adsrvr\\.org.*'),
|
||||
_block('.*taboola\\.com.*'),
|
||||
_block('.*outbrain\\.com.*'),
|
||||
_block('.*pubmatic\\.com.*'),
|
||||
_block('.*rubiconproject\\.com.*'),
|
||||
_block('.*openx\\.net.*'),
|
||||
_block('.*casalemedia\\.com.*'),
|
||||
_block('.*criteo\\.com.*'),
|
||||
_block('.*criteo\\.net.*'),
|
||||
|
||||
// Pixel trackers
|
||||
_block('.*pixel\\.quantserve\\.com.*'),
|
||||
_block('.*pixel\\.facebook\\.com.*'),
|
||||
|
||||
// IG-specific ad endpoints (safe to block — don't affect core IG)
|
||||
_block('.*\\.instagram\\.com.*\\/ads\\/.*'),
|
||||
];
|
||||
|
||||
static ContentBlocker _block(String pattern) => ContentBlocker(
|
||||
trigger: ContentBlockerTrigger(urlFilter: pattern),
|
||||
action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
|
||||
);
|
||||
|
||||
// ── URLRequest for initial load ───────────────────────────────────────────
|
||||
static URLRequest get initialRequest => URLRequest(
|
||||
url: WebUri(instagramUrl),
|
||||
headers: {'Accept-Language': 'en-US,en;q=0.9', 'DNT': '1'},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user