29 Commits

Author SHA1 Message Date
Ujwal 5e5869aada Merge branch 'main' of https://github.com/Ujwal223/FocusGram 2026-02-28 16:18:41 +05:45
Ujwal 0e97678536 Trying to Get App in F-Droid. 2026-02-28 16:04:00 +05:45
Ujwal Chapagain 3e1e4a6e3a Stable Release: Updated README.md accordingly
Added Screenshots
2026-02-27 04:21:06 +05:45
Ujwal 7992d65bc8 RELEASE: moved from beta to First stable release.
Check CHANGELOG.md for full changelog
2026-02-27 04:14:40 +05:45
Ujwal eecb823e62 RELEASE: Moved from beta to First stable release.
(Full Changes in CHANGELOG.md)
2026-02-27 04:13:34 +05:45
Ujwal Chapagain 3227ca1414 update downloads badge caching 2026-02-26 22:41:58 +05:45
Ujwal 4a1ff5a9fe Fix Play Core: ProGuard stripping + scanignore 2026-02-25 22:15:54 +05:45
Ujwal 6db4e0fe92 update: F-Droid Build yml update. (not needed here, but still...) 2026-02-25 20:36:22 +05:45
Ujwal 7622106695 Added Flutter as a git submodule 2026-02-25 20:35:34 +05:45
Ujwal 97e8d12f86 build: removed gms class from proguard. 2026-02-25 19:31:36 +05:45
Ujwal 3ff20e329a Update: Getting Ready for F-Droid 2026-02-25 18:15:08 +05:45
Ujwal 24f43603ad leftover from past commit 2026-02-25 18:04:43 +05:45
Ujwal f87597276c add release workflow for fdroid flutter version pinning 2026-02-25 18:04:02 +05:45
Ujwal Chapagain b5ef642683 Delete PRD.md 2026-02-25 09:01:37 +05:45
Ujwal Chapagain f497730015 Added App Screenshots in Readme 2026-02-24 23:27:36 +05:45
Ujwal 4e6c3f122a same as previous commit 2026-02-24 13:26:30 +05:45
Ujwal e751e14a6b feat: Add Google Play Core split install and task management components, and Flutter generated plugin registrant. 2026-02-24 12:35:26 +05:45
Ujwal 5b8d59e98b hardcode versionCode 2 for fdroid compatibility 2026-02-24 11:54:39 +05:45
Ujwal 719badb2f5 feat: fdroid release 2026-02-24 11:27:44 +05:45
Ujwal Chapagain c0354ae7aa added screenshots 2026-02-24 03:22:16 +05:45
Ujwal 3769ff2c38 version increase 2026-02-24 03:12:52 +05:45
Ujwal d4be176f73 fix: removed the proprietary Google Play Core dependencies to make compliant with F-Droid! 2026-02-24 02:46:50 +05:45
Ujwal 5232b8b0a9 UPDATES: updated UI from sidebar to topbar(again)
fixed external redirect on instagram m's settings.
fixed bug where it opened app session instead of reel session.
hided vertical scroll bar.
removed custom bottom bar.
fixed bug where it wasnt showing searchbar in /explore.

FIXED/ADDED/IMPROVED A LOT MORE THINGS.

Ready for Release
2026-02-24 00:04:23 +05:45
Ujwal 878e625f0e Added Notification Bridge.
Added an onboarding screen.
Fixed problem wherebottom bar blocked sending message.
added a option to go to instagram's settings page in our settings page.
added a notifications icon in bottombar.
Other few Improvements and Bug fixes.
2026-02-23 11:37:15 +05:45
Ujwal e23731d9e8 feat:
Added Extra features like "Ghost Mode"
Added Logout Functionality
Chagned UA to latest version of iOS.
Added app icons.
Improved reels player and fixed bugs.
Discipline challenge is now compulsory for watching reels.
Now the UI doesnt feel like it is in a cheap browser.
2026-02-23 10:53:38 +05:45
Ujwal fe2d793b93 improvement/;
bugfixes
 added word libraby (~500 words)
2026-02-23 00:23:37 +05:45
Ujwal 354f7413d1 feat:
added "Scheduled Blocking"
improved reel blocking logic
changed from topbar to sidepanel
improved seddings page
added about page
2026-02-22 23:59:20 +05:45
Ujwal 9ab4fc503a improvement: improved UI Elements and some optimizations 2026-02-22 22:26:33 +05:45
Ujwal a848b9222d first commit 2026-02-22 22:00:52 +05:45
96 changed files with 1014 additions and 10021 deletions
+45
View File
@@ -0,0 +1,45 @@
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
-10
View File
@@ -1,10 +0,0 @@
import os, re
from pathlib import Path
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")
-8
View File
@@ -1,8 +0,0 @@
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))
+54
View File
@@ -0,0 +1,54 @@
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 }}"
-95
View File
@@ -1,95 +0,0 @@
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@v4
with:
fetch-depth: 0
- name: Set up Java 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- name: Set up Android SDK
uses: android-actions/setup-android@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
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" \
- 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 .github/scripts/get_version.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: python3 .github/scripts/get_notes.py
- name: Build release APK
run: flutter build apk --release
- name: Rename APK
run: mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/focusgram-release.apk
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: focusgram-apk-${{ steps.meta.outputs.tag }}
path: build/app/outputs/flutter-apk/focusgram-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
with:
tag_name: ${{ steps.meta.outputs.tag }}
name: FocusGram ${{ steps.meta.outputs.tag }}
body_path: release_notes.md
files: build/app/outputs/flutter-apk/focusgram-release.apk
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+12
View File
@@ -0,0 +1,12 @@
name: release
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.38.7'
+2 -4
View File
@@ -14,9 +14,6 @@ migrate_working_dir/
PRD.md PRD.md
.agents/ .agents/
TODO.md TODO.md
v2/FOCUSGRAM_V2_PLAN.md
v2/FocusGram_Feed_Filtering_Reference.docx
# IntelliJ related # IntelliJ related
*.iml *.iml
@@ -28,9 +25,10 @@ v2/FocusGram_Feed_Filtering_Reference.docx
# VS Code which you may wish to be included in version control, so this line # VS Code which you may wish to be included in version control, so this line
# is commented out by default. # is commented out by default.
#.vscode/ #.vscode/
RELEASE_GUIDE.md
android/key.properties android/key.properties
android/fdroid-config.properties
android/app/*.jks android/app/*.jks
upload-keystore.jks
# Flutter/Dart/Pub related # Flutter/Dart/Pub related
**/doc/api/ **/doc/api/
+14 -18
View File
@@ -1,23 +1,19 @@
## FocusGram 2.0.0 ## FocusGram 1.0.0
First stable release.
### What's new ### What's new
- Minimal Mode — Feed and DMs only, everything else gone
- NEW: Added Media Downloader for downloading images and videos - Disable Reels / Disable Explore toggles
- NEW: Added Ghost Mode - Autoplay blocker
- NEW: Added a toggle for scroll lock in minimal mode - Screen Time Dashboard with 7-day chart
- NEW: Added Option to Choose Duration of Mindfulness Gate - Grayscale Mode with optional daily schedule
- NEW: Added ability to customize number of words in typing challenge - Removed the Browser Like Feel
- UPDATED: Redesigned Focus Control Flyout - Moved from webview_flutter to flutter_inappwebview
- UPDATED: Settings and Reordered items - Changed UA
- UPDATED: Added more time Choices for reels session
- UPDATED: Improved Permission Request invocation in onboarding page.
- UPDATED: Improved Notification Alerts
### Bug fixes ### Bug fixes
- Fixed: back button on homepage didnt exit the app. - Message input bar no longer hidden behind keyboard in DMs
- Fixed: Only First image of multiple imaged posts was blurred. - Fixed a bug where sending message was not possible.
- FIxed: Couldn't scroll the home feed after enabling minimal mode - Reels scrolling is now smooth
- Perfomance Optimizations - Perfomance Optimizations
- A lof of other Minor fixes .
+15 -29
View File
@@ -7,14 +7,11 @@
**Use social media on your terms.** **Use social media on your terms.**
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_3.0-blue.svg)](LICENSE) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_3.0-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-2.0.0-white)](https://flutter.dev) [![Flutter](https://img.shields.io/badge/Flutter-3.38-blue?logo=flutter&logoColor=white)](https://flutter.dev)
[![Downloads](https://img.shields.io/github/downloads/ujwal223/focusgram/total?label=downloads&color=blue&cacheSeconds=30)](https://github.com/ujwal223/focusgram/releases) [![Downloads](https://img.shields.io/github/downloads/ujwal223/focusgram/total?label=downloads&color=blue&cacheSeconds=300)](https://github.com/ujwal223/focusgram/releases)
[![F-Droid](https://img.shields.io/badge/F--Droid-later-blue)](https://f-droid.org)
<a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram'> [Download APK](https://github.com/ujwal223/focusgram/releases) · [View Changelog](CHANGELOG.md) · [Report a Bug](https://github.com/ujwal223/focusgram/issues)
<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)
</div> </div>
@@ -24,12 +21,9 @@ 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. 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.
> FocusGram is free and always will be. If it's saved you some time, show your support by buying me a momo 👉👈.
>
> [![Buy Me a Momo](https://img.shields.io/badge/-%F0%9F%A5%9F%20Buy%20Me%20a%20Momo-FF6B35?style=for-the-badge&labelColor=1a1a1a)](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" /> <img width="1920" height="1080" alt="FocusGram App Screenshots" src="https://github.com/user-attachments/assets/cffd4012-4cf3-4ba8-aa1a-883e1f85478e" />
--- ---
## What it does ## What it does
@@ -37,14 +31,14 @@ FocusGram is an Android app that loads the Instagram website with the distractin
**Focus tools** **Focus tools**
- Block Reels entirely, or allow them in timed sessions (115 min) with daily limits and cooldowns - Block Reels entirely, or allow them in timed sessions (115 min) with daily limits and cooldowns
- Autoplay blocker — videos won't play until you tap them - Autoplay blocker — videos don't play until you tap them
- Minimal Mode — strips everything down to Feed and DMs - Minimal Mode — strips everything down to Feed and DMs
**Content filtering** **Content filtering**
- Hide the Explore tab, Reels tab, or Shop tab individually - Hide the Explore tab, Reels tab, or Shop tab individually
- Disable Explore and blur posts entirely - Disable Explore and suggested content entirely
- Disable Reels entirely - Disable Reels Entirely
**Habit tools** **Habit tools**
@@ -54,7 +48,7 @@ FocusGram is an Android app that loads the Instagram website with the distractin
**The app itself** **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 - No blank loading screen — content loads in the background before you get there
- Instant updates via pull-to-refresh - Instant updates via pull-to-refresh
- Dark mode follows your system - Dark mode follows your system
@@ -68,11 +62,8 @@ FocusGram is an Android app that loads the Instagram website with the distractin
2. Download `focusgram-release.apk` 2. Download `focusgram-release.apk`
3. Open the file and allow "Install from unknown sources" if prompted 3. Open the file and allow "Install from unknown sources" if prompted
### Uptodown ### F-Droid
1. Go to the [FocusGram on Uptodown](https://focusgram.en.uptodown.com/android) page Submission is in progress. Updates will publish automatically once accepted.
2. Click "Get the Latest Version"
3. Click "Download"
4. Open the file and allow "Install from unknown sources" if prompted
--- ---
@@ -84,13 +75,14 @@ FocusGram has no access to your Instagram account credentials. It loads `instagr
- No crash reporting - No crash reporting
- No third-party SDKs - No third-party SDKs
- No data leaves your device - No data leaves your device
- All settings and history are stored locally using Android's standard storage APIs
--- ---
## Frequently asked questions ## Frequently asked questions
**Will this get my account banned?** **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. 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.
**Is this a mod of Instagram's app?** **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. 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.
@@ -108,10 +100,6 @@ Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](htt
### Requirements ### Requirements
- Flutter stable channel (3.38+) - Flutter stable channel (3.38+)
- Android SDK - Android SDK
- NDK 28.2.12676356
- Android SDK cmdline tools 20
- Android build tools 34 and 35
- JDK 17 (Eclipse Adoptium 17.0.17+)
### Build ### Build
```bash ```bash
@@ -125,17 +113,15 @@ FocusGram uses a standard Android System WebView to load `instagram.com`. All fe
- CSS injection (element hiding, grayscale, scroll behaviour) - CSS injection (element hiding, grayscale, scroll behaviour)
- URL interception via NavigationDelegate (Reels blocking, Explore blocking) - URL interception via NavigationDelegate (Reels blocking, Explore blocking)
### Permissions 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 | | Permission | Reason |
|---|---| |---|---|
| `INTERNET` | Load instagram.com | | `INTERNET` | Load instagram.com |
| `RECEIVE_BOOT_COMPLETED` | Keep session timers accurate after device restart | | `RECEIVE_BOOT_COMPLETED` | Keep session timers accurate after device restart |
| `WAKE_LOCK` | Keep device awake during active Focus sessions |
| `FOREGROUND_SERVICE` | Run background service for session tracking |
### Stack ### Stack
| | | | | |
|---|---| |---|---|
| Framework | Flutter (Dart) | | Framework | Flutter (Dart) |
-4
View File
@@ -9,10 +9,6 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- v2/**
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`
-2
View File
@@ -12,5 +12,3 @@ GeneratedPluginRegistrant.java
key.properties key.properties
**/*.keystore **/*.keystore
**/*.jks **/*.jks
upload-keystore.jks
-190
View File
@@ -1,190 +0,0 @@
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
+5 -5
View File
@@ -10,7 +10,7 @@ plugins {
android { android {
namespace = "com.ujwal.focusgram" namespace = "com.ujwal.focusgram"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
buildToolsVersion = "35.0.0" buildToolsVersion = "34.0.0"
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
val keystorePropertiesFile = rootProject.file("key.properties") val keystorePropertiesFile = rootProject.file("key.properties")
@@ -42,10 +42,10 @@ android {
applicationId = "com.ujwal.focusgram" applicationId = "com.ujwal.focusgram"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 24 minSdk = flutter.minSdkVersion
targetSdk = 35 targetSdk = flutter.targetSdkVersion
versionCode = 4 versionCode = 3
versionName = "2.0.0" versionName = "1.0.0"
} }
buildTypes { buildTypes {
File diff suppressed because one or more lines are too long
-61
View File
@@ -1,61 +0,0 @@
/**
* FocusGram DOM Ad Blocker (Fallback)
*
* DEPRECATED: Use fetch_interceptor.js for reliable ad blocking.
*
* This script provides DOM-based ad removal as a FALLBACK for ads that slip through
* GraphQL filtering. It's not reliable because Instagram has already rendered the content.
*
* Injected at DOCUMENT_END
* Removes sponsored/posts/tracking elements from the DOM.
*/
(function () {
'use strict';
const AD_SIGNALS = [
'Sponsored',
'paid partnership',
'Promoted',
];
const textMatchesSignal = (txt) => {
if (!txt) return false;
const t = txt.trim().toLowerCase();
return AD_SIGNALS.some((s) => t === s.toLowerCase());
};
const removeSponsoredArticles = () => {
try {
// aria-label routes (best-effort; localization may break)
document.querySelectorAll('a[aria-label]').forEach((a) => {
const aria = a.getAttribute('aria-label') || '';
if (textMatchesSignal(aria)) {
const article = a.closest('article');
if (article) article.remove();
}
});
// Text-based removal inside feed articles (best-effort)
document.querySelectorAll('article').forEach((article) => {
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const txt = node.nodeValue;
if (textMatchesSignal(txt)) {
article.remove();
break;
}
}
});
// Suggested content is intentionally left alone. Removing suggested
// units after Instagram has virtualized the feed can snap the viewport
// back to the top on some accounts.
} catch (_) {}
};
const observer = new MutationObserver(() => removeSponsoredArticles());
observer.observe(document.body, { childList: true, subtree: true });
removeSponsoredArticles();
})();
-129
View File
@@ -1,129 +0,0 @@
/**
* FocusGram Autoplay Blocker
* Injected at DOCUMENT_START — before Instagram's JS loads.
* Prevents video autoplay by:
* 1. Blocking play() calls on video elements
* 2. Disabling autoplay attribute
* 3. Removing preload attributes
*/
(function () {
'use strict';
// This script is only registered when the setting is enabled, so default ON.
window.__fgBlockAutoplay = typeof window.__fgBlockAutoplay === 'boolean'
? window.__fgBlockAutoplay : true;
const ALLOW_KEY = '__fgUserStartedPlayback';
let userGestureUntil = 0;
function isReelRoute() {
const path = window.location.pathname || '';
return path.indexOf('/reel/') >= 0 || path === '/reels' || path.indexOf('/reels/') >= 0;
}
function isUserGestureActive() {
return Date.now() < userGestureUntil;
}
function markUserGesture(target) {
userGestureUntil = Date.now() + 1200;
try {
let video = target && target.closest ? target.closest('video') : null;
if (!video && target && target.querySelector) video = target.querySelector('video');
if (video) video[ALLOW_KEY] = true;
} catch (_) {}
}
document.addEventListener('pointerdown', function (event) {
markUserGesture(event.target);
}, true);
document.addEventListener('touchstart', function (event) {
markUserGesture(event.target);
}, true);
document.addEventListener('click', function (event) {
markUserGesture(event.target);
}, true);
// Override HTMLMediaElement.play() to check our flag
const _play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () {
if (
window.__fgBlockAutoplay &&
!isReelRoute() &&
this[ALLOW_KEY] !== true &&
!isUserGestureActive()
) {
// Return a resolved promise to avoid breaking Instagram's code
try { this.pause(); } catch (_) {}
return Promise.resolve();
}
return _play.call(this);
};
// Override autoplay property setter
const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
const _originalAutoplaySetter = _videoDescriptor.set;
Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
set: function (value) {
if (window.__fgBlockAutoplay && value) {
// Silently ignore autoplay attempts when blocking is enabled
return;
}
if (_originalAutoplaySetter) {
_originalAutoplaySetter.call(this, value);
}
},
get: function () {
if (_videoDescriptor.get) {
return _videoDescriptor.get.call(this);
}
return this.getAttribute('autoplay') !== null;
},
enumerable: _videoDescriptor.enumerable,
configurable: true,
});
// On page load and SPA navigation, scan for video elements and remove autoplay
const removeAutoplayFromVideos = () => {
document.querySelectorAll('video, [role="video"]').forEach(el => {
if (window.__fgBlockAutoplay && !isReelRoute() && el[ALLOW_KEY] !== true) {
el.autoplay = false;
el.removeAttribute('autoplay');
el.removeAttribute('preload');
try { el.preload = 'none'; } catch (_) {}
if (el.paused === false) {
el.pause();
}
}
});
};
// Run on load and when document changes
removeAutoplayFromVideos();
if (!window.__fgAutoplayObserver) {
let _timer = null;
window.__fgAutoplayObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(removeAutoplayFromVideos, 500);
});
window.__fgAutoplayObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// Allow Flutter to toggle
window.__fgSetBlockAutoplay = function (enabled) {
window.__fgBlockAutoplay = !!enabled;
if (enabled) {
removeAutoplayFromVideos();
}
};
document.addEventListener('play', function (event) {
if (event.target && event.target.tagName === 'VIDEO' && isUserGestureActive()) {
event.target[ALLOW_KEY] = true;
}
}, true);
})();
-304
View File
@@ -1,304 +0,0 @@
/**
* FocusGram Content Hider
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
* Flutter controls via window.__fgContent.*
* Injected at DOCUMENT_END.
*
* Key fixes applied:
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
* - Stories tray detection strengthened for fresh SPA navigations
* - Suggested posts detection uses multiple text-node matching strategies
*/
(function () {
'use strict';
if (window.__fgContent && window.__fgContent.__focusgramReady) {
return;
}
const STYLE_ID = 'fg-content-hider';
let hideStories = false;
let hidePosts = false;
let hideSuggested = false;
let hideReels = false;
// ─── CSS rules ─────────────────────────────────────────────────────────────
function buildCSS() {
const selectors = [];
if (hideStories) {
selectors.push(
'[role="list"]:has([aria-label*="tory"])',
'[role="listbox"]:has([aria-label*="tory"])',
'[role="menu"] > ul',
'section > div > div:first-child [style*="overflow"]',
'[role="list"] [style*="overflow"]',
);
}
if (hidePosts) {
selectors.push(
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
);
}
// hideReels CSS is intentionally NOT added here.
// We use DOM removal instead (see removeReels()) so that room is never left
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
return selectors.length
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
: '';
}
function applyCSS() {
if (document.body) {
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
}
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = buildCSS();
}
// ─── Story tray JS ─────────────────────────────────────────────────────────
function hideStoryTray() {
if (!hideStories) return;
// Strategy 1: <ul> children of a named list or menu
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
try {
const items = ul.querySelectorAll('li, button, a');
if (items.length < 2) return;
ul.style.setProperty('display', 'none', 'important');
} catch (_) {}
});
// Strategy 2: horizontally scrolling container with circle items
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
try {
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
if (cands.length < 2) return;
const s0 = window.getComputedStyle(cands[0]);
if (s0.width && parseFloat(s0.width) <= 90) {
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
}
} catch (_) {}
});
}
// ─── Suggested posts ───────────────────────────────────────────────────────
function removeSuggested() {
if (!hideSuggested) return;
var SIGNALS = [
'suggested for you',
'suggested posts',
'suggested reels',
'suggested',
'because you watched',
'because you follow',
'you might like',
'posts you might like',
'accounts you might like',
'recommendations',
];
function norm(s) {
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function hasSignal(s) {
var t = norm(s);
if (!t) return false;
return SIGNALS.some(function (signal) {
if (signal === 'suggested') return t === signal;
return t.indexOf(signal) >= 0;
});
}
function hideContainer(from) {
var parent = from;
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
var role = parent.getAttribute && parent.getAttribute('role');
var tag = parent.tagName;
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
if (
tag === 'ARTICLE' ||
tag === 'SECTION' ||
role === 'listitem' ||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
) {
parent.style.setProperty('display', 'none', 'important');
parent.setAttribute('data-fg-hidden-suggested', '1');
return true;
}
parent = parent.parentElement;
}
return false;
}
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
try {
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
var ownLabel = node.getAttribute('aria-label');
if (hasSignal(ownLabel)) { hideContainer(node); return; }
var text = norm(node.innerText || node.textContent || '');
if (
text.indexOf('suggested for you') >= 0 ||
text.indexOf('suggested posts') >= 0 ||
text.indexOf('suggested reels') >= 0 ||
text.indexOf('because you watched') >= 0 ||
text.indexOf('because you follow') >= 0
) {
hideContainer(node);
}
} catch (_) {}
});
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
try {
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
hideContainer(el);
}
} catch (_) {}
});
}
// ─── Reels DOM REMOVE (not display:none) ─────────────────────────────────
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
// gap cleanly and lets the feed flow naturally.
function removeReels() {
if (!hideReels) return;
var toRemove = [];
document.querySelectorAll('article').forEach(function (el) {
try {
// Fast path: check for a reel-signal attribute first
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
if (mt === '2') { toRemove.push(el); return; }
// Fallback: text-node scan for /reels/ markers
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var n;
while ((n = walker.nextNode())) {
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
toRemove.push(el); break;
}
}
} catch (_) {}
});
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
}
// ─── Public API ────────────────────────────────────────────────────────────
window.__fgContent = {
__focusgramReady: true,
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
setHideSuggested: function (val) {
hideSuggested = !!val;
applyCSS();
if (val) removeSuggested();
},
setHideReels: function (val) {
hideReels = !!val;
applyCSS();
if (val) removeReels();
},
applyAll: function (flags) {
hideStories = !!flags.stories;
hidePosts = !!flags.posts;
hideReels = !!flags.reels;
hideSuggested = !!flags.suggested;
applyCSS();
if (hideSuggested) removeSuggested();
if (hideStories) hideStoryTray();
if (hideReels) removeReels();
},
};
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
// pushState/replaceState don't fire any DOM event we can listen for.
// Hook the methods themselves so we know a navigation happened, then debounce
// re-apply. This also catches the case where the MutationObserver was on `body`
// and that node got replaced by Instagram's SPA re-render.
function scheduleReapply() {
clearTimeout(window.__fg_applyTimer);
window.__fg_applyTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 250);
}
var _origPush = history.pushState;
var _origReplace = history.replaceState;
history.pushState = function () {
_origPush.apply(this, arguments);
scheduleReapply();
};
history.replaceState = function () {
_origReplace.apply(this, arguments);
scheduleReapply();
};
// Reinforce on popstate too (user hits back/forward)
window.addEventListener('popstate', scheduleReapply, { passive: true });
// For pushState on the same URL (rare but possible) poll path briefly
window.addEventListener('pageshow', scheduleReapply, { passive: true });
window.addEventListener('focus', scheduleReapply, { passive: true });
// ─── MutationObserver ───────────────────────────────────────────────────────
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
// re-applies everything on each cycle. Does NOT guard on a per-element timer
// that would never re-fire after the body is replaced by SPA re-render.
if (!window.__fgContentObserver) {
window.__fgContentObserver = new MutationObserver(function () {
clearTimeout(window.__fg_moTimer);
window.__fg_moTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 300);
});
// `document.documentElement` survives SPA navigations (body gets replaced
// but <html> stays). Observing it catches both subtree mutations and, via
// the SPA heartbeat above, re-applies after pushState.
window.__fgContentObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// ─── Initial run ────────────────────────────────────────────────────────────
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
// Signal ready — Flutter will call applyAll() with stored prefs
if (window.ContentChannel) {
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
-315
View File
@@ -1,315 +0,0 @@
/**
* 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;
})();
-179
View File
@@ -1,179 +0,0 @@
/**
* FocusGram Ghost Mode
* Injected at DOCUMENT_START — before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals.
*/
(function () {
'use strict';
// ─── Seen API patterns ────────────────────────────────────────────────────
const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//,
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
];
// ─── Activity patterns (like, comment) — intercepted for local history ────
const ACTIVITY_PATTERNS = [
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
/\/api\/v1\/web\/comments\/add\//,
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
];
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
const fakeOkResponse = () =>
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// ─── Fetch override ───────────────────────────────────────────────────────
const _fetch = window.fetch.bind(window);
const patchedFetch = async function (input, init) {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Block seen
if (isSeen(url)) {
if (window.GhostChannel) {
window.GhostChannel.postMessage(
JSON.stringify({ type: 'seen_blocked', url })
);
}
return fakeOkResponse();
}
// Intercept activity for local history
if (isActivity(url) && window.ActivityChannel) {
const body = init?.body;
const bodyText =
body instanceof URLSearchParams
? body.toString()
: typeof body === 'string'
? body
: '';
window.ActivityChannel.postMessage(
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
);
}
return _fetch(input, init);
};
// Disguise as native
Object.defineProperty(window, 'fetch', {
value: patchedFetch,
writable: true,
configurable: true,
enumerable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
window.fetch[Symbol.toStringTag] = 'fetch';
// ─── XMLHttpRequest override ──────────────────────────────────────────────
const _XHROpen = XMLHttpRequest.prototype.open;
const _XHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._fg_url = url ?? '';
this._fg_method = (method ?? '').toUpperCase();
return _XHROpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function (body) {
if (this._fg_url && isSeen(this._fg_url)) {
// Fire readyState 4 with fake success without actually sending
const self = this;
setTimeout(() => {
Object.defineProperty(self, 'readyState', { get: () => 4 });
Object.defineProperty(self, 'status', { get: () => 200 });
Object.defineProperty(self, 'responseText', {
get: () => '{"status":"ok"}',
});
Object.defineProperty(self, 'response', {
get: () => '{"status":"ok"}',
});
self.dispatchEvent(new Event('readystatechange'));
self.dispatchEvent(new Event('load'));
}, 10);
return;
}
return _XHRSend.call(this, body);
};
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) {
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
const _send = ws.send.bind(ws);
ws.send = function (data) {
if (typeof data === 'string') {
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
try {
const parsed = JSON.parse(data);
if (
parsed?.op === '4' ||
parsed?.op === 'seen' ||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
) {
return; // drop
}
} catch (_) {}
// Text-based seen signal check
if (data.includes('"seen"') && data.includes('"thread_id"')) {
return;
}
}
return _send(data);
};
return ws;
}
// Preserve WebSocket prototype chain so IG's ws checks pass
PatchedWebSocket.prototype = _WS.prototype;
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
PatchedWebSocket.OPEN = _WS.OPEN;
PatchedWebSocket.CLOSING = _WS.CLOSING;
PatchedWebSocket.CLOSED = _WS.CLOSED;
window.WebSocket = PatchedWebSocket;
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
// Only applied if user enables online-status hiding
// Wrapped in a named fn so Flutter can call it:
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
window.__fgEnableOnlineHide = function () {
Object.defineProperty(document, 'visibilityState', {
get: () => 'hidden',
configurable: true,
});
Object.defineProperty(document, 'hidden', {
get: () => true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
};
window.__fgDisableOnlineHide = function () {
// Restore by deleting the overrides (falls back to native getter)
delete document.visibilityState;
delete document.hidden;
document.dispatchEvent(new Event('visibilitychange'));
};
// Signal to Flutter that ghost mode JS is active
if (window.GhostChannel) {
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
-47
View File
@@ -1,47 +0,0 @@
/**
* 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();
})();
})();
View File
@@ -0,0 +1,5 @@
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.
@@ -0,0 +1 @@
Same as1st version. just version pump
@@ -0,0 +1,7 @@
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.
@@ -0,0 +1,10 @@
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.
@@ -0,0 +1 @@
A digital wellness wrapper for Instagram.
@@ -0,0 +1 @@
FocusGram
+6 -6
View File
@@ -92,16 +92,16 @@ class _SkeletonScreenState extends State<SkeletonScreen>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( Container(
height: 80, height: 80,
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 6, itemCount: 6,
itemBuilder: (context, index) => Padding( itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12), padding: const EdgeInsets.only(right: 12),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 56, width: 56,
@@ -111,13 +111,13 @@ class _SkeletonScreenState extends State<SkeletonScreen>
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 4),
Container( Container(
width: 32, width: 32,
height: 6, height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(4),
), ),
), ),
], ],
+13 -7
View File
@@ -35,11 +35,12 @@ class NativeBottomNav extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark; final isDark = theme.brightness == Brightness.dark;
final bgColor = theme.colorScheme.surface.withValues( final bgColor =
alpha: isDark ? 0.95 : 0.98, theme.colorScheme.surface.withValues(alpha: isDark ? 0.95 : 0.98);
); final iconColorInactive =
final iconColorInactive = isDark ? Colors.white70 : Colors.black54; isDark ? Colors.white70 : Colors.black54;
final iconColorActive = theme.colorScheme.primary; final iconColorActive =
theme.colorScheme.primary;
final tabs = <_NavItem>[ final tabs = <_NavItem>[
_NavItem( _NavItem(
@@ -102,7 +103,8 @@ class NativeBottomNav extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: tabs.map((item) { children: tabs.map((item) {
final color = item.active ? iconColorActive : iconColorInactive; final color =
item.active ? iconColorActive : iconColorInactive;
final opacity = item.enabled ? 1.0 : 0.35; final opacity = item.enabled ? 1.0 : 0.35;
return Expanded( return Expanded(
@@ -127,7 +129,10 @@ class NativeBottomNav extends StatelessWidget {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
item.label, item.label,
style: TextStyle(fontSize: 10, color: color), style: TextStyle(
fontSize: 10,
color: color,
),
), ),
], ],
), ),
@@ -159,3 +164,4 @@ class _NavItem {
required this.enabled, required this.enabled,
}); });
} }
@@ -17,7 +17,9 @@ class InstagramPreloader {
_headlessWebView = HeadlessInAppWebView( _headlessWebView = HeadlessInAppWebView(
keepAlive: keepAlive, keepAlive: keepAlive,
initialUrlRequest: URLRequest(url: WebUri('https://www.instagram.com/')), initialUrlRequest: URLRequest(
url: WebUri('https://www.instagram.com/'),
),
initialSettings: InAppWebViewSettings( initialSettings: InAppWebViewSettings(
userAgent: userAgent, userAgent: userAgent,
mediaPlaybackRequiresUserGesture: true, mediaPlaybackRequiresUserGesture: true,
@@ -67,3 +69,4 @@ class InstagramPreloader {
isReady = false; isReady = false;
} }
} }
@@ -31,8 +31,7 @@ class ReelsHistoryEntry {
url: (json['url'] as String?) ?? '', url: (json['url'] as String?) ?? '',
title: (json['title'] as String?) ?? 'Instagram Reel', title: (json['title'] as String?) ?? 'Instagram Reel',
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '', thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
visitedAt: visitedAt: DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
DateTime.now().toUtc(), DateTime.now().toUtc(),
); );
} }
@@ -115,3 +114,4 @@ class ReelsHistoryService {
await prefs.setString(_prefsKey, jsonEncode(jsonList)); await prefs.setString(_prefsKey, jsonEncode(jsonList));
} }
} }
@@ -32,7 +32,10 @@ class _UpdateBannerState extends State<UpdateBanner> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.secondaryContainer, color: colorScheme.secondaryContainer,
border: Border( border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant, width: 0.5), bottom: BorderSide(
color: colorScheme.outlineVariant,
width: 0.5,
),
), ),
), ),
child: Column( child: Column(
@@ -118,11 +121,10 @@ class _UpdateBannerState extends State<UpdateBanner> {
text = text.replaceAll(RegExp(r'#{1,6}\s'), ''); 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'); text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1');
text = text.replaceAll( text =
RegExp(r'\[([^\]]+)\]\([^)]+\)'), text.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'\1'); // links -> text
r'\1',
); // links -> text
text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1'); text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1');
return text.trim(); return text.trim();
} }
} }
@@ -56,9 +56,8 @@ class UpdateCheckerService extends ChangeNotifier {
return; return;
} }
final cleanVersion = gitVersionTag.startsWith('v') final cleanVersion =
? gitVersionTag.substring(1) gitVersionTag.startsWith('v') ? gitVersionTag.substring(1) : gitVersionTag;
: gitVersionTag;
var trimmed = body.trim(); var trimmed = body.trim();
if (trimmed.length > 1500) { if (trimmed.length > 1500) {
-17
View File
@@ -1,17 +0,0 @@
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,
});
}
-6
View File
@@ -17,7 +17,6 @@ import 'screens/cooldown_gate_screen.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'features/update_checker/update_checker_service.dart'; import 'features/update_checker/update_checker_service.dart';
import 'features/preloader/instagram_preloader.dart'; import 'features/preloader/instagram_preloader.dart';
import 'widgets/remote_popup_handler.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -109,10 +108,6 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
super.initState(); super.initState();
_appLinks = AppLinks(); _appLinks = AppLinks();
_initDeepLinks(); _initDeepLinks();
WidgetsBinding.instance.addPostFrameCallback((_) {
RemotePopupHandler.checkAndShow(context);
});
} }
Future<void> _initDeepLinks() async { Future<void> _initDeepLinks() async {
@@ -150,7 +145,6 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
// Step 3: Breath gate // Step 3: Breath gate
if (settings.showBreathGate && !_breathCompleted) { if (settings.showBreathGate && !_breathCompleted) {
return BreathGateScreen( return BreathGateScreen(
durationSeconds: settings.breathGateSeconds,
onFinish: () => setState(() => _breathCompleted = true), onFinish: () => setState(() => _breathCompleted = true),
); );
} }
+4 -20
View File
@@ -27,25 +27,7 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
55, 55,
60, 60,
]; ];
int _selectedIndex = 0; // default: 5 min unless a previous choice exists int _selectedIndex = 2; // default: 15 min
late final FixedExtentScrollController _scrollController;
@override
void initState() {
super.initState();
final lastMinutes = context.read<SessionManager>().lastAppSessionMinutes;
final lastIndex = _minuteOptions.indexOf(lastMinutes);
_selectedIndex = lastIndex >= 0 ? lastIndex : 0;
_scrollController = FixedExtentScrollController(
initialItem: _selectedIndex,
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -136,10 +118,12 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
perspective: 0.003, perspective: 0.003,
squeeze: 1.1, squeeze: 1.1,
diameterRatio: 2.5, diameterRatio: 2.5,
controller: _scrollController,
onSelectedItemChanged: (i) { onSelectedItemChanged: (i) {
setState(() => _selectedIndex = i); setState(() => _selectedIndex = i);
}, },
controller: FixedExtentScrollController(
initialItem: _selectedIndex,
),
childDelegate: ListWheelChildListDelegate( childDelegate: ListWheelChildListDelegate(
children: _minuteOptions.asMap().entries.map((entry) { children: _minuteOptions.asMap().entries.map((entry) {
final isSelected = entry.key == _selectedIndex; final isSelected = entry.key == _selectedIndex;
+5 -9
View File
@@ -1,16 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async'; import 'dart:async';
/// A mindfulness screen shown before Instagram opens. /// A mindfulness screen shown before the app opens.
/// Forces the user to take a deep 10-second breath.
class BreathGateScreen extends StatefulWidget { class BreathGateScreen extends StatefulWidget {
final VoidCallback onFinish; final VoidCallback onFinish;
final int durationSeconds;
const BreathGateScreen({ const BreathGateScreen({super.key, required this.onFinish});
super.key,
required this.onFinish,
this.durationSeconds = 10,
});
@override @override
State<BreathGateScreen> createState() => _BreathGateScreenState(); State<BreathGateScreen> createState() => _BreathGateScreenState();
@@ -20,15 +16,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
with TickerProviderStateMixin { with TickerProviderStateMixin {
late AnimationController _controller; late AnimationController _controller;
late Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
late int _secondsRemaining; int _secondsRemaining = 10;
Timer? _timer; Timer? _timer;
bool _canContinue = false; bool _canContinue = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
// 10-second breathing animation: 5s in, 5s out
_controller = AnimationController( _controller = AnimationController(
vsync: this, vsync: this,
duration: const Duration(seconds: 5), duration: const Duration(seconds: 5),
-154
View File
@@ -1,154 +0,0 @@
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,
),
),
);
}
}
+2 -7
View File
@@ -18,11 +18,7 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
Future<void> Function() action, Future<void> Function() action,
) async { ) async {
if (sm.isScheduledBlockActive) { if (sm.isScheduledBlockActive) {
final settings = context.read<SettingsService>(); final ok = await DisciplineChallenge.show(context, count: 35);
final ok = await DisciplineChallenge.show(
context,
count: settings.resolvedWordChallengeCount(),
);
if (!context.mounted || !ok) return; if (!context.mounted || !ok) return;
} }
await action(); await action();
@@ -325,8 +321,7 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () async {
final sm = context.read<SessionManager>(); final sm = context.read<SessionManager>();
final settings = context.read<SettingsService>(); int wordCount = 15;
int wordCount = settings.resolvedWordChallengeCount();
// If we are at 0 quota, increase difficulty to 35 words // If we are at 0 quota, increase difficulty to 35 words
if (widget.title.contains('Daily Reel Limit') && if (widget.title.contains('Daily Reel Limit') &&
sm.dailyRemainingSeconds <= 0) { sm.dailyRemainingSeconds <= 0) {
File diff suppressed because it is too large Load Diff
+28 -66
View File
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
final PageController _pageController = PageController(); final PageController _pageController = PageController();
int _currentPage = 0; int _currentPage = 0;
// Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications // Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications
static const int _kTotalPages = 5; static const int _kTotalPages = 5;
static const int _kBlurPage = 3; static const int _kBlurPage = 3;
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State<OnboardingPage> {
final List<Widget> slides = [ final List<Widget> slides = [
// ── Page 0: Welcome ───────────────────────────────────────────────── // ── Page 0: Welcome ─────────────────────────────────────────────────
_StaticSlide( _StaticSlide(
icon: Icons.auto_awesome_rounded, icon: Icons.auto_awesome,
color: const Color(0xFF4F8DFF), color: Colors.blue,
title: 'Welcome to FocusGram', title: 'Welcome to FocusGram',
description: description:
'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.', 'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
), ),
// ── Page 1: Focus controls ─────────────────────────────────────────── // ── Page 1: Session Management ───────────────────────────────────────
_StaticSlide( _StaticSlide(
icon: Icons.timer_outlined, icon: Icons.timer,
color: const Color(0xFFFFB74D), color: Colors.orange,
title: 'Time With Intent', title: 'Session Management',
description: description:
'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.', 'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
), ),
// ── Page 2: Open links ─────────────────────────────────────────────── // ── Page 2: Open links ───────────────────────────────────────────────
_StaticSlide( _StaticSlide(
icon: Icons.link_rounded, icon: Icons.link,
color: const Color(0xFF35C2D6), color: Colors.cyan,
title: 'Open Links in FocusGram', title: 'Open Links in FocusGram',
description: description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.', 'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
@@ -63,11 +63,11 @@ class _OnboardingPageState extends State<OnboardingPage> {
// ── Page 4: Notifications ──────────────────────────────────────────── // ── Page 4: Notifications ────────────────────────────────────────────
_StaticSlide( _StaticSlide(
icon: Icons.notifications_active_outlined, icon: Icons.notifications_active,
color: const Color(0xFF5DD18A), color: Colors.green,
title: 'Useful Alerts Only', title: 'Stay Notified',
description: description:
'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.', 'We need notification permissions to alert you when your session is over or a new message arrives.',
isPermissionPage: true, isPermissionPage: true,
permission: Permission.notification, permission: Permission.notification,
), ),
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
), ),
), ),
), ),
const SizedBox(height: 28), const SizedBox(height: 32),
// CTA button // CTA button
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 32), padding: const EdgeInsets.symmetric(horizontal: 32),
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
final isBlur = _currentPage == _kBlurPage; final isBlur = _currentPage == _kBlurPage;
String label; String label;
if (isNotif) { if (isLast) {
label = 'Allow & Start'; label = 'Get Started';
} else if (isLink) { } else if (isLink) {
label = 'Configure'; label = 'Configure';
} else if (isNotif) {
label = 'Allow Notifications';
} else if (isBlur) { } else if (isBlur) {
label = 'Save & Continue'; label = 'Save & Continue';
} else if (isLast) {
label = 'Get Started';
} else { } else {
label = 'Next'; label = 'Next';
} }
@@ -143,8 +143,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
); );
} else if (isNotif) { } else if (isNotif) {
await Permission.notification.request(); await Permission.notification.request();
await NotificationService() await NotificationService().init();
.requestPermissionsNow();
} }
if (!context.mounted) return; if (!context.mounted) return;
@@ -179,19 +178,9 @@ class _OnboardingPageState extends State<OnboardingPage> {
// Skip button (available on all pages except last) // Skip button (available on all pages except last)
if (_currentPage < _kTotalPages - 1) if (_currentPage < _kTotalPages - 1)
TextButton( TextButton(
onPressed: () { onPressed: () => _finish(context),
if (_currentPage == _kNotifPage) {
_finish(context);
} else {
_pageController.animateToPage(
_kTotalPages - 1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: const Text( child: const Text(
'Skip setup', 'Skip',
style: TextStyle(color: Colors.white38, fontSize: 14), style: TextStyle(color: Colors.white38, fontSize: 14),
), ),
), ),
@@ -233,27 +222,18 @@ class _StaticSlide extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(28, 40, 28, 160), padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( Icon(icon, size: 120, color: color),
width: 112, const SizedBox(height: 48),
height: 112,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: color.withValues(alpha: 0.28)),
),
child: Icon(icon, size: 54, color: color),
),
const SizedBox(height: 36),
Text( Text(
title, title,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 30, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -263,28 +243,10 @@ class _StaticSlide extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 16, fontSize: 18,
height: 1.5, height: 1.5,
), ),
), ),
if (isPermissionPage || isAppSettingsPage) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Text(
isPermissionPage
? 'Permission is optional and can be changed later.'
: 'This opens Android settings; return here when done.',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
),
],
], ],
), ),
); );
+1 -3
View File
@@ -104,18 +104,16 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
sessionActive: true, sessionActive: true,
blurExplore: false, blurExplore: false,
blurReels: false, blurReels: false,
tapToUnblur: false,
enableTextSelection: true, enableTextSelection: true,
hideSuggestedPosts: false, hideSuggestedPosts: false,
hideSponsoredPosts: false, hideSponsoredPosts: false,
hideLikeCounts: false, hideLikeCounts: false,
hideFollowerCounts: false, hideFollowerCounts: false,
// hideStoriesBar removed per user request hideStoriesBar: false,
hideExploreTab: false, hideExploreTab: false,
hideReelsTab: false, hideReelsTab: false,
hideShopTab: false, hideShopTab: false,
disableReelsEntirely: false, disableReelsEntirely: false,
blockHomeFeedScroll: false,
), ),
); );
}, },
+11 -16
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/session_manager.dart'; import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../utils/discipline_challenge.dart'; import '../utils/discipline_challenge.dart';
class SessionModal extends StatefulWidget { class SessionModal extends StatefulWidget {
@@ -64,12 +63,12 @@ class _SessionModalState extends State<SessionModal> {
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600), style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( Row(
spacing: 8, mainAxisAlignment: MainAxisAlignment.spaceBetween,
runSpacing: 8, children: [1, 5, 10, 15].map((m) {
children: [1, 3, 5, 10, 15, 20, 30].map((m) { return Expanded(
return SizedBox( child: Padding(
width: 72, padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton( child: ElevatedButton(
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted) onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
? null ? null
@@ -81,6 +80,7 @@ class _SessionModalState extends State<SessionModal> {
), ),
child: Text('${m}m'), child: Text('${m}m'),
), ),
),
); );
}).toList(), }).toList(),
), ),
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
Slider( Slider(
value: _customMinutes, value: _customMinutes,
min: 1, min: 1,
max: 60, max: 30,
divisions: 59, divisions: 29,
label: '${_customMinutes.toInt()}m', label: '${_customMinutes.toInt()}m',
onChanged: (v) => setState(() => _customMinutes = v), onChanged: (v) => setState(() => _customMinutes = v),
), ),
@@ -126,15 +126,10 @@ class _SessionModalState extends State<SessionModal> {
void _start(int minutes) async { void _start(int minutes) async {
final sm = context.read<SessionManager>(); final sm = context.read<SessionManager>();
final settings = context.read<SettingsService>();
if (settings.requireWordChallenge) { // Always require word challenge for reel sessions (User request)
final success = await DisciplineChallenge.show( final success = await DisciplineChallenge.show(context);
context,
count: settings.resolvedWordChallengeCount(),
);
if (!success) return; if (!success) return;
}
if (sm.startSession(minutes)) { if (sm.startSession(minutes)) {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
File diff suppressed because it is too large Load Diff
+64 -176
View File
@@ -1,225 +1,113 @@
/// JavaScript to block autoplaying videos on Instagram feed/explore while: /// JavaScript to block autoplaying videos on Instagram while still allowing
/// - Allowing videos to play normally when "Block Autoplay Videos" is OFF /// explicit user-initiated playback.
/// - Allowing user-initiated playback on click when blocking is ON
/// - NEVER blocking reels (they should always play normally per user request)
/// ///
/// This script: /// This script:
/// - Overrides HTMLVideoElement.prototype.play before Instagram initialises. /// - Overrides HTMLVideoElement.prototype.play before Instagram initialises.
/// - PAUSES any playing videos immediately when autoplay is blocked (only for feed/explore).
/// - Returns Promise.resolve() for blocked autoplay calls (never throws). /// - Returns Promise.resolve() for blocked autoplay calls (never throws).
/// - Uses a per-element flag set by user clicks to permanently allow that video to play. /// - Uses a short-lived per-element flag set by user clicks to allow play().
/// - Strips the autoplay attribute from dynamically added <video> elements. /// - Strips the autoplay attribute from dynamically added <video> elements.
/// - Respects session state - allows autoplay when session is active.
/// - NEVER blocks reels - they always play normally.
/// - Once a video is explicitly played by user, it plays fully without interruption.
const String kAutoplayBlockerJS = r''' const String kAutoplayBlockerJS = r'''
(function fgAutoplayBlocker() { (function fgAutoplayBlocker() {
if (window.__fgAutoplayPatched) return; if (window.__fgAutoplayPatched) return;
window.__fgAutoplayPatched = true; window.__fgAutoplayPatched = true;
// Default to blocking autoplay if not set // Toggleable at runtime from Flutter:
window.__fgBlockAutoplay = window.__fgBlockAutoplay !== false; // window.__fgBlockAutoplay = true/false
if (typeof window.__fgBlockAutoplay === 'undefined') {
// Session state - set by FocusGram when session is active window.__fgBlockAutoplay = true;
// window.__focusgramSessionActive = true/false
// Helper to check if this is a reel video (should NEVER be blocked)
function isReelVideo() {
try {
const url = window.location.href || '';
// Check if we're on a reel page
if (url.includes('/reels/') || url.includes('/reel/')) {
return true;
}
return false;
} catch (_) {
return false;
}
} }
// Helper to check if we should allow autoplay const ALLOW_KEY = '__fgAllowPlayUntil';
function shouldBlockAutoplay() { const ALLOW_WINDOW_MS = 1000;
// If we're on reels page, never block
if (isReelVideo()) return false;
// If autoplay setting is false, don't block at all
if (window.__fgBlockAutoplay === false) return false;
// If session is active, don't block autoplay (allow all videos)
if (window.__focusgramSessionActive === true) return false;
// Otherwise block autoplay for feed/explore videos
return true;
}
// Key to mark a video as explicitly started by user (permanent for that video instance)
const ALLOW_KEY = '__fgUserExplicitlyPlayed';
// Mark video as allowed permanently once user explicitly plays it
function markAllow(video) { function markAllow(video) {
try { try {
video[ALLOW_KEY] = true; video[ALLOW_KEY] = Date.now() + ALLOW_WINDOW_MS;
} catch (_) {} } catch (_) {}
} }
// Check if user has explicitly played this video
function shouldAllow(video) { function shouldAllow(video) {
try { try {
return video[ALLOW_KEY] === true; const until = video[ALLOW_KEY] || 0;
return Date.now() <= until;
} catch (_) { } catch (_) {
return false; return false;
} }
} }
// Pause video and strip autoplay attribute (for blocked autoplay videos) function stripAutoplay(root) {
function pauseAndFreezeVideo(video) {
try { try {
// Remove autoplay attribute completely if (window.__fgBlockAutoplay !== true) return;
video.removeAttribute('autoplay'); const all = root.querySelectorAll
try { video.autoplay = false; } catch (_) {} ? root.querySelectorAll('video')
// Pause the video : (root.tagName === 'VIDEO' ? [root] : []);
video.pause(); all.forEach(v => {
// Reset to beginning v.removeAttribute('autoplay');
video.currentTime = 0; try { v.autoplay = false; } catch (_) {}
});
} catch (_) {} } catch (_) {}
} }
// Store original play and pause // Initial pass
const _origPlay = HTMLVideoElement.prototype.play;
const _origPause = HTMLVideoElement.prototype.pause;
// Override play method
if (HTMLVideoElement.prototype.play) {
HTMLVideoElement.prototype.play = function() {
try { try {
// NEVER block reels - they always play normally document.querySelectorAll('video').forEach(v => stripAutoplay(v));
if (isReelVideo()) { } catch (_) {}
return _origPlay.apply(this, arguments);
}
// Check if we should block based on both settings and session // MutationObserver for dynamically added videos
if (!shouldBlockAutoplay()) {
// Autoplay is OFF or session is active - allow all playback
return _origPlay.apply(this, arguments);
}
// If user has explicitly played this video before, allow it to continue
if (shouldAllow(this)) {
return _origPlay.apply(this, arguments);
}
// Block autoplay: pause immediately and return resolved promise
pauseAndFreezeVideo(this);
return Promise.resolve();
} catch (_) {
// Fall back to original behaviour
try { try {
return _origPlay.apply(this, arguments); const mo = new MutationObserver(ms => {
} catch (_) { if (window.__fgBlockAutoplay !== true) return;
return Promise.resolve(); ms.forEach(m => {
} m.addedNodes.forEach(node => {
} if (!node || node.nodeType !== 1) return;
}; if (node.tagName === 'VIDEO') {
} stripAutoplay(node);
} else {
// Override pause method to work normally stripAutoplay(node);
if (HTMLVideoElement.prototype.pause) {
HTMLVideoElement.prototype.pause = function() {
try {
return _origPause.apply(this, arguments);
} catch (_) {
return Promise.resolve();
}
};
}
// Additional safeguard for dynamically created videos
try {
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('video').forEach(function(v) {
if (v.play) {
const originalPlay = v.play;
v.play = function() {
// NEVER block reels
if (isReelVideo()) {
return originalPlay.apply(this, arguments);
}
if (!shouldBlockAutoplay()) {
return originalPlay.apply(this, arguments);
}
if (shouldAllow(this)) {
return originalPlay.apply(this, arguments);
}
pauseAndFreezeVideo(this);
return Promise.resolve();
};
} }
}); });
}); });
});
mo.observe(document.documentElement, { childList: true, subtree: true });
} catch (_) {} } catch (_) {}
// Also handle videos that might be created after DOMContentLoaded // Allow play() shortly after a direct user click on a video.
try {
const originalCreateElement = document.createElement;
document.createElement = function(tagName) {
const element = originalCreateElement.apply(this, arguments);
if (tagName.toLowerCase() === 'video') {
// Intercept the play method on dynamically created videos
const originalPlay = element.play;
if (originalPlay) {
element.play = function() {
// NEVER block reels
if (isReelVideo()) {
return originalPlay.apply(this, arguments);
}
if (!shouldBlockAutoplay()) {
return originalPlay.apply(this, arguments);
}
if (shouldAllow(this)) {
return originalPlay.apply(this, arguments);
}
pauseAndFreezeVideo(this);
return Promise.resolve();
};
}
}
return element;
};
} catch (_) {}
// Mark video as allowed on user interaction (click/tap) - permanent for that video
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
try { try {
const video = e.target.closest ? e.target.closest('video') : e.target; const video = e.target && e.target.closest && e.target.closest('video');
if (video) { if (!video) return;
// Mark this specific video as user-initiated - permanent
markAllow(video); markAllow(video);
// Try to play the video if it was previously blocked
if (shouldBlockAutoplay() && !shouldAllow(video)) {
// Video will be allowed now, try to play
try { video.play(); } catch (_) {} try { video.play(); } catch (_) {}
}
}
} catch (_) {} } catch (_) {}
}, true); }, true);
document.addEventListener('touchstart', function(e) { // Prototype override
try { try {
const video = e.target.closest ? e.target.closest('video') : e.target; const origPlay = HTMLVideoElement.prototype.play;
if (video) { if (!origPlay) return;
markAllow(video); if (!window.__fgOrigVideoPlay) window.__fgOrigVideoPlay = origPlay;
if (shouldBlockAutoplay() && !shouldAllow(video)) {
try { video.play(); } catch (_) {}
}
}
} catch (_) {}
}, true);
// Also handle play events directly (for Instagram's internal play buttons) HTMLVideoElement.prototype.play = function() {
document.addEventListener('play', function(e) { try {
if (e.target && e.target.tagName === 'VIDEO') { if (window.__fgBlockAutoplay !== true) {
markAllow(e.target); return origPlay.apply(this, arguments);
} }
}, true); if (shouldAllow(this)) {
return origPlay.apply(this, arguments);
}
// Block autoplay: resolve without actually starting playback.
return Promise.resolve();
} catch (_) {
// If anything goes wrong, fall back to original behaviour to avoid
// breaking Instagram's player.
try {
return origPlay.apply(this, arguments);
} catch (_) {
return Promise.resolve();
}
}
};
} catch (_) {}
})(); })();
'''; ''';
+7 -182
View File
@@ -86,144 +86,6 @@ const String kHideStoriesBarJS = r'''
})(); })();
'''; ''';
/// Robust stories overlay - blocks clicking and applies blur when hide stories is enabled.
/// This is a more aggressive approach that places an overlay with blur on top of stories area.
const String kStoriesOverlayJS = r'''
(function() {
if (window.__fgStoriesOverlayRunning) return;
window.__fgStoriesOverlayRunning = true;
const BLOCKED_ATTR = 'data-fg-stories-blocked';
function buildOverlay(container) {
const div = document.createElement('div');
div.setAttribute(BLOCKED_ATTR, '1');
div.style.cssText = [
'position: absolute',
'inset: 0',
'z-index: 99998',
'display: flex',
'align-items: center',
'justify-content: center',
'background: rgba(0, 0, 0, 0.6)',
'backdrop-filter: blur(10px)',
'-webkit-backdrop-filter: blur(10px)',
'border-radius: 8px',
'pointer-events: all',
'cursor: not-allowed',
].join(';');
const label = document.createElement('span');
label.textContent = 'Stories blocked';
label.style.cssText = [
'color: rgba(255, 255, 255, 0.8)',
'font-size: 12px',
'font-weight: 600',
'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
'text-align: center',
'padding: 8px 16px',
'background: rgba(0, 0, 0, 0.5)',
'border-radius: 20px',
].join(';');
div.appendChild(label);
// Swallow all interaction
['click', 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'mouseenter'].forEach(function(evt) {
div.addEventListener(evt, function(e) {
e.preventDefault();
e.stopImmediatePropagation();
}, { capture: true });
});
return div;
}
function overlayStoriesContainer(container) {
if (!container) return;
if (container.querySelector('[' + BLOCKED_ATTR + ']')) return;
// Check if this looks like a stories container
const hasStories = container.querySelector('canvas, [style*="border-radius: 50%"], [aria-label*="story"], [role="list"]');
if (!hasStories) return;
container.style.position = 'relative';
container.style.overflow = 'hidden';
container.appendChild(buildOverlay(container));
}
function findAndOverlayStories() {
try {
// Method 1: Find by role="list" with story-related aria-label
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stori')) {
overlayStoriesContainer(el.parentElement);
}
} catch(_) {}
});
// Method 2: Find horizontal scroll containers at top of feed
document.querySelectorAll('header + div > div, main > div > div > div').forEach(function(el) {
try {
const style = window.getComputedStyle(el);
if ((style.overflowX === 'scroll' || style.overflowX === 'auto') &&
(style.display === 'flex' || style.display === '')) {
const children = el.children;
let hasAvatar = false;
for (let i = 0; i < Math.min(children.length, 10); i++) {
const child = children[i];
const childStyle = window.getComputedStyle(child);
if (childStyle.width === '60px' || childStyle.width === '66px' ||
child.querySelector('canvas, [style*="border-radius: 50%"]')) {
hasAvatar = true;
break;
}
}
if (hasAvatar) {
overlayStoriesContainer(el);
}
}
} catch(_) {}
});
// Method 3: Find story avatars directly
document.querySelectorAll('[href*="/stories/"], [aria-label*="Your Story"]').forEach(function(el) {
try {
let container = el.parentElement;
for (let i = 0; i < 5 && container; i++) {
const style = window.getComputedStyle(container);
if (style.position !== 'static' && container.children.length < 20) {
overlayStoriesContainer(container);
break;
}
container = container.parentElement;
}
} catch(_) {}
});
} catch(_) {}
}
// Initial run
findAndOverlayStories();
// Watch for dynamic changes
let _overlayTimer = null;
new MutationObserver(function() {
clearTimeout(_overlayTimer);
_overlayTimer = setTimeout(findAndOverlayStories, 500);
}).observe(document.documentElement, { childList: true, subtree: true });
// Also run on scroll
let _scrollTimer = null;
window.addEventListener('scroll', function() {
clearTimeout(_scrollTimer);
_scrollTimer = setTimeout(findAndOverlayStories, 300);
}, { passive: true });
})();
''';
const String kHideExploreTabCSS = """ const String kHideExploreTabCSS = """
a[href="/explore/"], a[href="/explore/"],
a[href="/explore"] { a[href="/explore"] {
@@ -451,54 +313,16 @@ const String kHideSuggestedPostsJS = r'''
(function() { (function() {
function hideSuggestedPosts() { function hideSuggestedPosts() {
try { try {
// Target text patterns that indicate suggested content document.querySelectorAll('span, h3, h4').forEach(function(el) {
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 { try {
const text = el.textContent.trim(); const text = el.textContent.trim();
const matched = suggestedPatterns.some(pattern =>
text === pattern || text.includes(pattern)
);
if (matched) {
let parent = el.parentElement;
// Traverse up to find the container section/article
for (let i = 0; i < 12 && parent; i++) {
const tag = parent.tagName.toLowerCase();
const classList = parent.className || '';
// Hide articles, sections, lists, and common suggestion containers
if ( if (
tag === 'article' || text === 'Suggested for you' ||
tag === 'section' || text === 'Suggested posts' ||
tag === 'li' || text === "You're all caught up"
classList.includes('xjx87jv0') || // Instagram suggestion container
classList.includes('x1a8lsjc') // Reel suggestion container
) { ) {
parent.style.setProperty('display', 'none', 'important'); let parent = el.parentElement;
break; for (let i = 0; i < 8 && parent; i++) {
}
parent = parent.parentElement;
}
}
} 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(); const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') { if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important'); parent.style.setProperty('display', 'none', 'important');
@@ -506,6 +330,7 @@ const String kHideSuggestedPostsJS = r'''
} }
parent = parent.parentElement; parent = parent.parentElement;
} }
}
} catch(_) {} } catch(_) {}
}); });
} catch(_) {} } catch(_) {}
+4 -103
View File
@@ -39,12 +39,6 @@ const String kBlurHomeFeedAndExploreCSS = '''
filter: blur(20px) !important; filter: blur(20px) !important;
transition: filter 0.15s ease !important; transition: filter 0.15s ease !important;
} }
/* Per-post unblur override (set by kTapToUnblurJS) */
[data-fg-unblurred="1"] img,
[data-fg-unblurred="1"] video {
filter: none !important;
-webkit-filter: none !important;
}
body[path="/"] article img:hover, body[path="/"] article img:hover,
body[path="/"] article video:hover, body[path="/"] article video:hover,
body[path^="/explore"] img:hover, body[path^="/explore"] img:hover,
@@ -86,96 +80,6 @@ const String kBlurReelsCSS = '''
} }
'''; ''';
/// Allows users to unblur blurred media by tapping it.
///
/// Behaviour:
/// - Only active when `window.__fgTapToUnblur === true`.
/// - Only applies on Home feed (`/`) and Explore (`/explore*`) where FocusGram blurs.
/// - First tap unblurs the post media and swallows the click (prevents opening).
/// - Subsequent taps behave normally (Instagram opens the post as usual).
const String kTapToUnblurJS = r'''
(function fgTapToUnblur() {
if (window.__fgTapToUnblurPatched) return;
window.__fgTapToUnblurPatched = true;
function isBlurContext() {
try {
const p = (document.body && document.body.getAttribute('path')) || window.location.pathname || '';
return p === '/' || p.indexOf('/explore') === 0;
} catch (_) {
return false;
}
}
function findMediaFromTarget(t) {
try {
if (!t) return null;
if (t.closest) {
const direct = t.closest('img,video');
if (direct) return direct;
}
// Walk up a few levels and look for a media element inside.
let n = t;
for (let i = 0; i < 6 && n; i++) {
if (n.querySelector) {
const m = n.querySelector('img,video');
if (m) return m;
}
n = n.parentElement;
}
} catch (_) {}
return null;
}
function getHost(media) {
try {
return media.closest('article') || media.closest('a') || media.parentElement;
} catch (_) {
return null;
}
}
function markUnblurred(host) {
try {
host.setAttribute('data-fg-unblurred', '1');
} catch (_) {}
}
function isUnblurred(host) {
try {
return host && host.getAttribute && host.getAttribute('data-fg-unblurred') === '1';
} catch (_) {
return false;
}
}
function unblurMedia(media) {
try {
media.style.setProperty('filter', 'none', 'important');
media.style.setProperty('-webkit-filter', 'none', 'important');
} catch (_) {}
}
document.addEventListener('click', function(e) {
try {
if (window.__fgTapToUnblur !== true) return;
if (!isBlurContext()) return;
const media = findMediaFromTarget(e.target);
if (!media) return;
const host = getHost(media);
if (!host) return;
if (isUnblurred(host)) return; // allow normal Instagram behaviour
// First tap: unblur and swallow click so it doesn't open the post.
markUnblurred(host);
unblurMedia(media);
if (e.cancelable) e.preventDefault();
e.stopPropagation();
} catch (_) {}
}, true);
})();
''';
// JavaScript helpers // JavaScript helpers
/// Removes the "Open in App" nag banner. /// Removes the "Open in App" nag banner.
@@ -277,15 +181,11 @@ const String kReelsMutationObserverJS = r'''
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]'; const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
function lockMode() { function lockMode() {
// Lock DM reels to prevent swipe-to-next, and optionally lock the home // Only lock scroll when: DM reel playing OR disableReelsEntirely enabled
// feed as a separate Minimal Mode control.
const isDmReel = window.location.pathname.includes('/direct/') && const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]'); !!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel'; if (isDmReel) return 'dm_reel';
if (window.__fgBlockHomeFeedScroll === true && if (window.__fgDisableReelsEntirely === true) return 'disabled';
(window.location.pathname === '/' || window.location.pathname === '')) {
return 'home_feed';
}
return null; return null;
} }
@@ -340,7 +240,8 @@ const String kReelsMutationObserverJS = r'''
try { try {
const mode = lockMode(); const mode = lockMode();
const hasReel = !!document.querySelector(REEL_SEL); const hasReel = !!document.querySelector(REEL_SEL);
if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') { // Apply lock for dm_reel or disabled modes when reel is present
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
if (__fgOrigHtmlOverflow === null) { if (__fgOrigHtmlOverflow === null) {
__fgOrigHtmlOverflow = document.documentElement.style.overflow || ''; __fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : ''; __fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
+1
View File
@@ -13,3 +13,4 @@ const String kDmKeyboardFixJS = r'''
} catch (_) {} } catch (_) {}
}); });
'''; ''';
-99
View File
@@ -1,99 +0,0 @@
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;
}
+1
View File
@@ -9,3 +9,4 @@ const String kHapticBridgeScript = '''
}, true); }, true);
})(); })();
'''; ''';
+1
View File
@@ -10,3 +10,4 @@ const String kScrollSmoothingJS = r'''
} catch (_) {} } catch (_) {}
})(); })();
'''; ''';
+1
View File
@@ -29,3 +29,4 @@ const String kSpaNavigationMonitorScript = '''
window.addEventListener('popstate', () => notifyUrlChange()); window.addEventListener('popstate', () => notifyUrlChange());
})(); })();
'''; ''';
-355
View File
@@ -1,355 +0,0 @@
/// Best-effort Instagram media downloader UI.
///
/// The script only exposes URLs already rendered in the WebView. It cannot
/// decrypt or fetch media that Instagram has not loaded, but it covers visible
/// feed posts, reels, profile avatars, and DM visual/video messages.
const String kVideoDownloadJS = r'''
(function() {
'use strict';
if (window.__fgMediaDownloadRunning) return;
window.__fgMediaDownloadRunning = true;
const BTN_ATTR = 'data-fg-download-btn';
const URL_ATTR = 'data-fg-download-url';
const TYPE_ATTR = 'data-fg-download-type';
const MAX_PER_PASS = 60;
function text(value) {
try { return (value || '').toString(); } catch (_) { return ''; }
}
function isHttp(value) {
const s = text(value);
return s.indexOf('https://') === 0 || s.indexOf('http://') === 0;
}
function cleanUrl(value) {
const s = text(value).trim();
if (!isHttp(s)) return null;
return s.replace(/&amp;/g, '&');
}
function bestFromSrcset(srcset) {
const raw = text(srcset);
if (!raw) return null;
let best = null;
let bestScore = -1;
raw.split(',').forEach(function(part) {
const bits = part.trim().split(/\s+/);
const url = cleanUrl(bits[0]);
if (!url) return;
const score = parseFloat(text(bits[1]).replace(/[^\d.]/g, '')) || 1;
if (score >= bestScore) {
bestScore = score;
best = url;
}
});
return best;
}
function backgroundUrl(el) {
try {
const bg = window.getComputedStyle(el).backgroundImage || '';
const match = bg.match(/url\(["']?(.*?)["']?\)/);
return match ? cleanUrl(match[1]) : null;
} catch (_) {
return null;
}
}
function urlFromJsonishAttribute(el) {
const attrs = ['data-store', 'data-props', 'data-visualcompletion'];
for (let i = 0; i < attrs.length; i++) {
const value = text(el.getAttribute && el.getAttribute(attrs[i]));
const match = value.match(/https?:\\?\/\\?\/[^"'\s\\]+/);
if (match) return cleanUrl(match[0].replace(/\\\//g, '/'));
}
return null;
}
function mediaUrl(el) {
if (!el) return null;
const tag = text(el.tagName).toLowerCase();
if (tag === 'video') {
return cleanUrl(el.currentSrc || el.src) ||
cleanUrl(el.getAttribute('src')) ||
cleanUrl(el.getAttribute('poster')) ||
firstSource(el);
}
if (tag === 'img') {
return cleanUrl(el.currentSrc || el.src) ||
bestFromSrcset(el.getAttribute('srcset')) ||
cleanUrl(el.getAttribute('src'));
}
return backgroundUrl(el) || urlFromJsonishAttribute(el);
}
function firstSource(video) {
try {
const sources = video.querySelectorAll('source');
for (let i = 0; i < sources.length; i++) {
const url = cleanUrl(sources[i].src || sources[i].getAttribute('src'));
if (url) return url;
}
} catch (_) {}
return null;
}
function typeFrom(el, url) {
const tag = text(el && el.tagName).toLowerCase();
const u = text(url).toLowerCase();
if (tag === 'video' || u.indexOf('.mp4') >= 0 || u.indexOf('.m3u8') >= 0) {
return 'video';
}
return 'photo';
}
function looksLikeAvatar(el) {
try {
const img = el && el.tagName && el.tagName.toLowerCase() === 'img' ? el : null;
if (!img) return false;
const alt = text(img.getAttribute('alt')).toLowerCase();
const r = img.getBoundingClientRect();
const rounded =
window.getComputedStyle(img).borderRadius.indexOf('%') >= 0 ||
parseFloat(window.getComputedStyle(img).borderRadius) >= Math.min(r.width, r.height) / 3;
return r.width <= 72 && r.height <= 72 && (rounded || alt.indexOf('profile') >= 0 || alt.indexOf('avatar') >= 0);
} catch (_) {
return false;
}
}
function mediaScore(item) {
try {
const r = item.el.getBoundingClientRect();
let score = Math.max(0, r.width) * Math.max(0, r.height);
if (item.type === 'video') score += 10000000;
if (looksLikeAvatar(item.el)) score -= 10000000;
if (text(item.url).toLowerCase().indexOf('s150x150') >= 0) score -= 5000000;
return score;
} catch (_) {
return 0;
}
}
function filename(type) {
const ext = type === 'video' ? 'mp4' : 'jpg';
return 'focusgram_' + type + '_' + Date.now() + '.' + ext;
}
function inView(el) {
try {
const r = el.getBoundingClientRect();
return r.width > 24 && r.height > 24 && r.bottom > 0 && r.top < window.innerHeight;
} catch (_) {
return false;
}
}
function icon() {
return '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
}
function sendDownload(url, type) {
try {
if (!url || !window.flutter_inappwebview || !window.flutter_inappwebview.callHandler) return;
window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON.stringify({
type: type,
url: url,
filename: filename(type),
}));
} catch (_) {}
}
function makeButton(url, type, mode) {
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute(BTN_ATTR, '1');
btn.setAttribute(URL_ATTR, url);
btn.setAttribute(TYPE_ATTR, type);
btn.setAttribute('aria-label', 'Download media');
btn.innerHTML = icon();
btn.style.cssText = [
'position:absolute',
'z-index: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();
})();
''';
@@ -1,430 +0,0 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class AdblockContentBlockerData {
final List<ContentBlocker> contentBlockers;
final Set<String> blockedHosts;
final String sourceTag;
const AdblockContentBlockerData({
required this.contentBlockers,
required this.blockedHosts,
required this.sourceTag,
});
Map<String, dynamic> toJson() => {
'sourceTag': sourceTag,
'hosts': blockedHosts.toList(),
// We cant safely serialize ContentBlocker objects; rebuild from hosts.
// contentBlockers will always be regenerated from hosts when restoring.
};
static AdblockContentBlockerData fromJson(Map<String, dynamic> json) {
final hosts =
(json['hosts'] as List?)?.whereType<String>().toSet() ?? <String>{};
return AdblockContentBlockerData(
contentBlockers: hosts
.map(
(h) => ContentBlocker(
trigger: ContentBlockerTrigger(
urlFilter: AdblockContentBlockerLoader._urlFilterForHost(h),
),
action: ContentBlockerAction(
type: ContentBlockerActionType.BLOCK,
),
),
)
.toList(growable: false),
blockedHosts: hosts,
sourceTag: (json['sourceTag'] as String?) ?? 'cached',
);
}
}
class AdblockContentBlockerLoader {
// Cache keys
static const _keyCache = 'adblock_cb_cache_v2';
static const _keyCacheUpdatedAt = 'adblock_cb_cache_updated_at_v1';
static const _keySourceCache = 'adblock_source_cache_v1';
static const _maxContentBlockerRules = 5000;
// Raw GitHub sources, intentionally split by repository sections so the app
// follows upstream changes without depending on third-party packaged mirrors.
static const _sources = <_SourceSpec>[
// uBlock Origin built-in Annoyances family:
// https://github.com/uBlockOrigin/uAssets/tree/master/filters
_SourceSpec(
tag: 'ublock_annoyances',
url:
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt',
),
_SourceSpec(
tag: 'ublock_annoyances_cookies',
url:
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-cookies.txt',
),
_SourceSpec(
tag: 'ublock_annoyances_others',
url:
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-others.txt',
),
// EasyList network-blocking sections:
// https://github.com/easylist/easylist/tree/master/easylist
_SourceSpec(
tag: 'easylist_adservers',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_adservers.txt',
),
_SourceSpec(
tag: 'easylist_general_block',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_block.txt',
),
_SourceSpec(
tag: 'easylist_specific_block',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_specific_block.txt',
),
_SourceSpec(
tag: 'easylist_thirdparty',
url:
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_thirdparty.txt',
),
// AdGuard BaseFilter network-blocking sections:
// https://github.com/AdguardTeam/AdguardFilters/tree/master/BaseFilter/sections
_SourceSpec(
tag: 'adguard_base_adservers',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers.txt',
),
_SourceSpec(
tag: 'adguard_base_adservers_firstparty',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers_firstparty.txt',
),
_SourceSpec(
tag: 'adguard_base_antiadblock',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/antiadblock.txt',
),
_SourceSpec(
tag: 'adguard_base_cryptominers',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/cryptominers.txt',
),
_SourceSpec(
tag: 'adguard_base_general_url',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/general_url.txt',
),
_SourceSpec(
tag: 'adguard_base_specific',
url:
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/specific.txt',
),
];
Future<AdblockContentBlockerData> loadOrUpdateIfNeeded({
required bool enabled,
required SharedPreferences prefs,
int timeoutMs = 8000,
}) async {
if (!enabled) {
return const AdblockContentBlockerData(
contentBlockers: [],
blockedHosts: {},
sourceTag: 'disabled',
);
}
final cachedData = _readCachedData(prefs);
final sourceCache = _readSourceCache(prefs);
final fetchResults = await _fetchAllSources(
cache: sourceCache,
timeoutMs: timeoutMs,
);
if (fetchResults.isEmpty && cachedData != null) {
return cachedData;
}
final sourceEntries = <String, _CachedSource>{...sourceCache};
for (final result in fetchResults) {
sourceEntries[result.tag] = result.source;
}
final hosts = sourceEntries.values
.expand((source) => source.hosts)
.where(_isValidHostname)
.toSet();
if (hosts.isEmpty && cachedData != null) {
return cachedData;
}
final data = _buildData(
hosts: hosts,
sourceTag: fetchResults.any((r) => r.changed)
? 'updated-github'
: 'validated-github-cache',
);
await prefs.setString(_keyCache, jsonEncode(data.toJson()));
await prefs.setString(
_keySourceCache,
jsonEncode({
for (final entry in sourceEntries.entries) entry.key: entry.value,
}),
);
await prefs.setInt(
_keyCacheUpdatedAt,
DateTime.now().millisecondsSinceEpoch,
);
return data;
}
AdblockContentBlockerData? _readCachedData(SharedPreferences prefs) {
final cached = prefs.getString(_keyCache);
if (cached == null) return null;
try {
final decoded = jsonDecode(cached) as Map<String, dynamic>;
return AdblockContentBlockerData.fromJson(decoded);
} catch (_) {
return null;
}
}
Map<String, _CachedSource> _readSourceCache(SharedPreferences prefs) {
final cached = prefs.getString(_keySourceCache);
if (cached == null) return {};
try {
final decoded = jsonDecode(cached) as Map<String, dynamic>;
return decoded.map((tag, value) {
return MapEntry(
tag,
_CachedSource.fromJson(value as Map<String, dynamic>),
);
});
} catch (_) {
return {};
}
}
AdblockContentBlockerData _buildData({
required Set<String> hosts,
required String sourceTag,
}) {
final sortedHosts = hosts.toList(growable: false)..sort();
final cappedHosts = sortedHosts.take(_maxContentBlockerRules).toSet();
return AdblockContentBlockerData(
contentBlockers: cappedHosts
.map(
(h) => ContentBlocker(
trigger: ContentBlockerTrigger(urlFilter: _urlFilterForHost(h)),
action: ContentBlockerAction(
type: ContentBlockerActionType.BLOCK,
),
),
)
.toList(growable: false),
blockedHosts: cappedHosts,
sourceTag: sourceTag,
);
}
Future<List<_FetchedSource>> _fetchAllSources({
required Map<String, _CachedSource> cache,
required int timeoutMs,
}) async {
final client = http.Client();
try {
final timeout = Duration(milliseconds: timeoutMs);
return Future.wait(
_sources.map(
(source) => _fetchSource(
client: client,
source: source,
cached: cache[source.tag],
timeout: timeout,
),
),
).then((results) => results.whereType<_FetchedSource>().toList());
} finally {
client.close();
}
}
Future<_FetchedSource?> _fetchSource({
required http.Client client,
required _SourceSpec source,
required _CachedSource? cached,
required Duration timeout,
}) async {
try {
final headers = <String, String>{
if (cached?.etag != null) 'If-None-Match': cached!.etag!,
if (cached?.lastModified != null)
'If-Modified-Since': cached!.lastModified!,
'User-Agent': 'FocusGram-AdblockListUpdater',
};
final res = await client
.get(Uri.parse(source.url), headers: headers)
.timeout(timeout);
if (res.statusCode == 304 && cached != null) {
return _FetchedSource(tag: source.tag, source: cached, changed: false);
}
if (res.statusCode != 200 || res.body.isEmpty) return null;
return _FetchedSource(
tag: source.tag,
source: _CachedSource(
url: source.url,
etag: res.headers['etag'],
lastModified: res.headers['last-modified'],
hosts: parseHostsFromFilterText(res.body),
),
changed: true,
);
} catch (_) {
return null;
}
}
/// Strict/strong: we only extract domain-ish entries from common uBlock/EasyList
/// syntax forms:
/// - ||example.com^
/// - ||example.com/
/// - ||example.com
///
/// We ignore all element-hiding/cosmetic rules and $ options.
@visibleForTesting
static Set<String> parseHostsFromFilterText(String raw) {
final hosts = <String>{};
for (final line in raw.split('\n')) {
final l = line.trim();
if (l.isEmpty) continue;
if (l.startsWith('!')) continue;
if (l.startsWith('@@')) continue;
// Skip comments / metadata
if (l.startsWith('[')) continue;
// Skip cosmetic element-hiding rules
if (l.contains('##') || l.contains('#@#') || l.contains(r'#$#')) {
continue;
}
// uBlock-style host anchors
if (l.startsWith('||')) {
final body = l.substring(2);
// Drop anything after a separator like '^', '/', '?', ' ' (conservative)
// e.g. "example.com^" -> "example.com"
// e.g. "example.com/" -> "example.com"
// e.g. "example.com^$third-party" -> "example.com"
final stopChars = ['^', '/', '?', '\\', '|', '\t', ' ', r'$'];
String host = body;
for (final sc in stopChars) {
final idx = host.indexOf(sc);
if (idx >= 0) host = host.substring(0, idx);
}
host = host.trim();
// Remove leading/trailing dots
host = host
.replaceAll(RegExp(r'^\.+'), '')
.replaceAll(RegExp(r'\.+$'), '');
if (host.isEmpty) continue;
if (host.contains('*') || host.contains(',')) continue;
final normalized = host.toLowerCase();
if (!_isValidHostname(normalized)) continue;
hosts.add(normalized);
}
}
return hosts;
}
static String _urlFilterForHost(String host) {
final escaped = RegExp.escape(host);
return r'^https?://([^/?#]+\.)?'
'$escaped'
r'([/?#:].*)?$';
}
static bool _isValidHostname(String host) {
if (!host.contains('.')) return false;
if (host.length > 255) return false;
if (host.startsWith('.') || host.endsWith('.')) return false;
if (host.contains('..')) return false;
return RegExp(r'^[a-z0-9][a-z0-9.-]*[a-z0-9]$').hasMatch(host);
}
}
class _SourceSpec {
final String tag;
final String url;
const _SourceSpec({required this.tag, required this.url});
}
class _FetchedSource {
final String tag;
final _CachedSource source;
final bool changed;
_FetchedSource({
required this.tag,
required this.source,
required this.changed,
});
}
class _CachedSource {
final String url;
final String? etag;
final String? lastModified;
final Set<String> hosts;
const _CachedSource({
required this.url,
required this.etag,
required this.lastModified,
required this.hosts,
});
factory _CachedSource.fromJson(Map<String, dynamic> json) {
return _CachedSource(
url: (json['url'] as String?) ?? '',
etag: json['etag'] as String?,
lastModified: json['lastModified'] as String?,
hosts: (json['hosts'] as List?)?.whereType<String>().toSet() ?? {},
);
}
Map<String, dynamic> toJson() => {
'url': url,
'etag': etag,
'lastModified': lastModified,
'hosts': hosts.toList(growable: false)..sort(),
};
}
+9 -7
View File
@@ -55,17 +55,16 @@ class InjectionController {
required bool sessionActive, required bool sessionActive,
required bool blurExplore, required bool blurExplore,
required bool blurReels, required bool blurReels,
required bool tapToUnblur,
required bool enableTextSelection, required bool enableTextSelection,
required bool hideSuggestedPosts, required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
required bool hideSponsoredPosts, required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
required bool hideLikeCounts, required bool hideLikeCounts,
required bool hideFollowerCounts, required bool hideFollowerCounts,
required bool hideStoriesBar,
required bool hideExploreTab, required bool hideExploreTab,
required bool hideReelsTab, required bool hideReelsTab,
required bool hideShopTab, required bool hideShopTab,
required bool disableReelsEntirely, required bool disableReelsEntirely,
required bool blockHomeFeedScroll,
}) { }) {
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS); final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS); if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
@@ -75,12 +74,18 @@ class InjectionController {
css.writeln(scripts.kHideReelsFeedContentCSS); css.writeln(scripts.kHideReelsFeedContentCSS);
} }
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
// Previously it was inside that block alongside display:none on the parent
// you cannot blur children of a display:none element, making it dead code.
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
// when sessionActive=false, reels are hidden anyway (blur harmless).
if (blurReels) css.writeln(scripts.kBlurReelsCSS); if (blurReels) css.writeln(scripts.kBlurReelsCSS);
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS); if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS); if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS); if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
if (hideStoriesBar) css.writeln(ui_hider.kHideStoriesBarCSS);
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS); if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS); if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS); if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
@@ -88,14 +93,11 @@ class InjectionController {
return ''' return '''
${buildSessionStateJS(sessionActive)} ${buildSessionStateJS(sessionActive)}
window.__fgDisableReelsEntirely = $disableReelsEntirely; window.__fgDisableReelsEntirely = $disableReelsEntirely;
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
window.__fgTapToUnblur = $tapToUnblur;
${scripts.kTrackPathJS} ${scripts.kTrackPathJS}
${_buildMutationObserver(css.toString())} ${_buildMutationObserver(css.toString())}
${scripts.kDismissAppBannerJS} ${scripts.kDismissAppBannerJS}
${!sessionActive ? scripts.kStrictReelsBlockJS : ''} ${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
${scripts.kReelsMutationObserverJS} ${scripts.kReelsMutationObserverJS}
${tapToUnblur ? scripts.kTapToUnblurJS : ''}
${scripts.kLinkSanitizationJS} ${scripts.kLinkSanitizationJS}
${scripts.kThemeDetectorJS} ${scripts.kThemeDetectorJS}
${scripts.kBadgeMonitorJS} ${scripts.kBadgeMonitorJS}
+32 -57
View File
@@ -6,7 +6,6 @@ import 'injection_controller.dart';
import '../scripts/grayscale.dart' as grayscale; import '../scripts/grayscale.dart' as grayscale;
import '../scripts/ui_hider.dart' as ui_hider; import '../scripts/ui_hider.dart' as ui_hider;
import '../scripts/content_disabling.dart' as content_disabling; import '../scripts/content_disabling.dart' as content_disabling;
import '../scripts/video_downloader.dart' as video_downloader;
// Core JS and CSS payloads injected into the Instagram WebView. // Core JS and CSS payloads injected into the Instagram WebView.
// //
@@ -387,39 +386,18 @@ const String kLinkSanitizationJS = r'''
// InjectionManager class // InjectionManager class
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 { class InjectionManager {
final JsEvaluator _jsEvaluator; final InAppWebViewController controller;
final SharedPreferences prefs; final SharedPreferences prefs;
final SessionManager sessionManager; final SessionManager sessionManager;
SettingsService? _settingsService; SettingsService? _settingsService;
InjectionManager({ InjectionManager({
required InAppWebViewController controller, required this.controller,
required this.prefs, required this.prefs,
required this.sessionManager, required this.sessionManager,
JsEvaluator? jsEvaluator, });
}) : _jsEvaluator = jsEvaluator ?? _WebViewJsEvaluator(controller);
InjectionManager.forTest({
required JsEvaluator jsEvaluator,
required this.prefs,
required this.sessionManager,
}) : _jsEvaluator = jsEvaluator;
void setSettingsService(SettingsService settingsService) { void setSettingsService(SettingsService settingsService) {
_settingsService = settingsService; _settingsService = settingsService;
@@ -433,59 +411,50 @@ class InjectionManager {
final sessionActive = sessionManager.isSessionActive; final sessionActive = sessionManager.isSessionActive;
// Get settings values // Get settings values
// Minimal mode controls all blocking - when enabled, it forces blur and disables final blurExplore = settings.blurExplore;
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
final tapToUnblur = settings.tapToUnblur;
final enableTextSelection = settings.enableTextSelection; final enableTextSelection = settings.enableTextSelection;
final hideSuggestedPosts = settings.hideSuggestedPosts;
// Per request: remove ALL Hide Suggested Posts behavior/UI/JS injection. final hideSponsoredPosts = settings.hideSponsoredPosts;
final hideSuggestedPosts = false;
final hideLikeCounts = settings.hideLikeCounts; final hideLikeCounts = settings.hideLikeCounts;
final hideFollowerCounts = settings.hideFollowerCounts; final hideFollowerCounts = settings.hideFollowerCounts;
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely final hideExploreTab = settings.hideExploreTab;
// These are now only controllable via minimal mode submenu final hideReelsTab = settings.hideReelsTab;
final disableExploreEntirely = settings.disableExploreEntirely;
final disableReelsEntirely = settings.disableReelsEntirely;
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
final hideExploreTab = disableExploreEntirely;
final hideReelsTab = disableReelsEntirely;
final hideShopTab = settings.hideShopTab; final hideShopTab = settings.hideShopTab;
final disableReelsEntirely = settings.disableReelsEntirely;
final isGrayscaleActive = settings.isGrayscaleActiveNow;
final injectionJS = InjectionController.buildInjectionJS( final injectionJS = InjectionController.buildInjectionJS(
sessionActive: sessionActive, sessionActive: sessionActive,
blurExplore: blurExplore, blurExplore: blurExplore,
blurReels: false, // Blur reels feature removed blurReels: false, // Blur reels feature removed
tapToUnblur: blurExplore && tapToUnblur,
enableTextSelection: enableTextSelection, enableTextSelection: enableTextSelection,
hideSuggestedPosts: hideSuggestedPosts, hideSuggestedPosts: hideSuggestedPosts,
hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead hideSponsoredPosts: hideSponsoredPosts,
hideLikeCounts: hideLikeCounts, hideLikeCounts: hideLikeCounts,
hideFollowerCounts: hideFollowerCounts, hideFollowerCounts: hideFollowerCounts,
hideStoriesBar: false, // Story blocking removed
hideExploreTab: hideExploreTab, hideExploreTab: hideExploreTab,
hideReelsTab: hideReelsTab, hideReelsTab: hideReelsTab,
hideShopTab: hideShopTab, hideShopTab: hideShopTab,
disableReelsEntirely: disableReelsEntirely, disableReelsEntirely: disableReelsEntirely,
blockHomeFeedScroll: blockHomeFeedScroll,
); );
try { try {
await _jsEvaluator.evaluateJavascript(source: injectionJS); await controller.evaluateJavascript(source: injectionJS);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
// Inject grayscale when active, remove when not active // Inject grayscale when active, remove when not active
if (settings.isGrayscaleActiveNow) { if (isGrayscaleActive) {
try { try {
await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS); await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
} else { } else {
try { try {
await _jsEvaluator.evaluateJavascript( await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
source: grayscale.kGrayscaleOffJS,
);
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
@@ -494,22 +463,28 @@ class InjectionManager {
// Inject hide like counts JS when enabled // Inject hide like counts JS when enabled
if (hideLikeCounts) { if (hideLikeCounts) {
try { try {
await _jsEvaluator.evaluateJavascript( await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
source: ui_hider.kHideLikeCountsJS, } catch (e) {
// Silently handle injection errors
}
}
// Inject hide suggested posts JS when enabled
if (hideSuggestedPosts) {
try {
await controller.evaluateJavascript(
source: ui_hider.kHideSuggestedPostsJS,
); );
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
} }
} }
// Stories hiding functionality removed per user request // Inject hide sponsored posts JS when enabled
// No stories overlay injection needed if (hideSponsoredPosts) {
// Inject video downloader UI when enabled
if (settings.videoDownloadEnabled) {
try { try {
await _jsEvaluator.evaluateJavascript( await controller.evaluateJavascript(
source: video_downloader.kVideoDownloadJS, source: ui_hider.kHideSponsoredPostsJS,
); );
} catch (e) { } catch (e) {
// Silently handle injection errors // Silently handle injection errors
@@ -519,7 +494,7 @@ class InjectionManager {
// Inject DM Reel blocker when disableReelsEntirely is enabled // Inject DM Reel blocker when disableReelsEntirely is enabled
if (disableReelsEntirely) { if (disableReelsEntirely) {
try { try {
await _jsEvaluator.evaluateJavascript( await controller.evaluateJavascript(
source: content_disabling.kDmReelBlockerJS, source: content_disabling.kDmReelBlockerJS,
); );
} catch (e) { } catch (e) {
+5 -72
View File
@@ -9,16 +9,16 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin = final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
Future<void> init({bool requestPermissions = false}) async { Future<void> init() async {
const AndroidInitializationSettings initializationSettingsAndroid = const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher'); AndroidInitializationSettings('@mipmap/ic_launcher');
// Request permissions for iOS // Request permissions for iOS
final DarwinInitializationSettings initializationSettingsIOS = final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings( DarwinInitializationSettings(
requestAlertPermission: requestPermissions, requestAlertPermission: true,
requestBadgePermission: requestPermissions, requestBadgePermission: true,
requestSoundPermission: requestPermissions, requestSoundPermission: true,
defaultPresentAlert: true, defaultPresentAlert: true,
defaultPresentBadge: true, defaultPresentBadge: true,
defaultPresentSound: true, defaultPresentSound: true,
@@ -37,12 +37,7 @@ class NotificationService {
}, },
); );
if (requestPermissions) { // Request permissions after initialization
await requestPermissionsNow();
}
}
Future<void> requestPermissionsNow() async {
await _requestIOSPermissions(); await _requestIOSPermissions();
await _requestAndroidPermissions(); await _requestAndroidPermissions();
} }
@@ -108,66 +103,4 @@ class NotificationService {
debugPrint('Notification error: $e'); debugPrint('Notification error: $e');
} }
} }
/// Shows a persistent (ongoing) notification that cannot be dismissed by the user
Future<void> showPersistentNotification({
required int id,
required String title,
required String body,
}) async {
const AndroidNotificationDetails androidDetails =
AndroidNotificationDetails(
'focusgram_persistent_channel',
'FocusGram Persistent',
channelDescription: 'Persistent notification while using FocusGram',
importance: Importance.max,
priority: Priority.high,
ongoing: true,
autoCancel: false,
showWhen: true,
playSound: false,
enableVibration: false,
category: AndroidNotificationCategory.service,
);
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
presentAlert: false,
presentBadge: false,
presentSound: false,
);
const NotificationDetails platformDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
try {
await _notificationsPlugin.show(
id: id,
title: title,
body: body,
notificationDetails: platformDetails,
);
} catch (e) {
debugPrint('Persistent notification error: $e');
}
}
/// Cancels a persistent notification
Future<void> cancelPersistentNotification({required int id}) async {
try {
await _notificationsPlugin.cancel(id: id);
} catch (e) {
debugPrint('Cancel persistent notification error: $e');
}
}
/// Cancels all notifications
Future<void> cancelAllNotifications() async {
try {
await _notificationsPlugin.cancelAll();
} catch (e) {
debugPrint('Cancel all notifications error: $e');
}
}
} }
-84
View File
@@ -1,84 +0,0 @@
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);
}
}
+6 -5
View File
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
/// ///
/// Storage format (in SharedPreferences, key `screen_time_data`): /// Storage format (in SharedPreferences, key `screen_time_data`):
/// { /// {
/// "2026-05-26": 3420, // seconds /// "2026-02-26": 3420, // seconds
/// "2026-05-25": 1800 /// "2026-02-25": 1800
/// } /// }
/// ///
/// All data stays on-device only. /// All data stays on-device only.
@@ -22,8 +22,6 @@ class ScreenTimeService extends ChangeNotifier {
bool _tracking = false; bool _tracking = false;
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate); Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
int get totalSeconds =>
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
Future<void> init() async { Future<void> init() async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
@@ -39,7 +37,9 @@ class ScreenTimeService extends ChangeNotifier {
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
_secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt())); _secondsByDate = decoded.map(
(k, v) => MapEntry(k, (v as num).toInt()),
);
} }
} catch (_) { } catch (_) {
_secondsByDate = {}; _secondsByDate = {};
@@ -104,3 +104,4 @@ class ScreenTimeService extends ChangeNotifier {
super.dispose(); super.dispose();
} }
} }
+15 -60
View File
@@ -57,7 +57,6 @@ class SessionManager extends ChangeNotifier {
static const _keyAppSessionEnd = 'app_sess_end_ts'; static const _keyAppSessionEnd = 'app_sess_end_ts';
static const _keyAppSessionExtUsed = 'app_sess_ext_used'; static const _keyAppSessionExtUsed = 'app_sess_ext_used';
static const _keyLastAppSessEnd = 'app_sess_last_end_ts'; static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
static const _keyDailyOpenCount = 'app_open_count'; static const _keyDailyOpenCount = 'app_open_count';
static const _keyScheduleEnabled = 'sched_enabled'; static const _keyScheduleEnabled = 'sched_enabled';
static const _keyScheduleStartHour = 'sched_start_h'; static const _keyScheduleStartHour = 'sched_start_h';
@@ -82,7 +81,6 @@ class SessionManager extends ChangeNotifier {
bool _appSessionExpiredFlag = bool _appSessionExpiredFlag =
false; // set when time runs out, waiting for user action false; // set when time runs out, waiting for user action
int _dailyOpenCount = 0; int _dailyOpenCount = 0;
int _lastAppSessionMinutes = 5;
// Scheduled Blocking runtime // Scheduled Blocking runtime
bool _scheduleEnabled = false; bool _scheduleEnabled = false;
@@ -92,10 +90,6 @@ class SessionManager extends ChangeNotifier {
int _schedEndMin = 0; int _schedEndMin = 0;
List<FocusSchedule> _schedules = []; List<FocusSchedule> _schedules = [];
bool _lastScheduleState = false; bool _lastScheduleState = false;
bool _scheduleNotificationShown =
false; // Track if schedule notification was shown
bool _sessionEndNotificationShown =
true; // Default to true to prevent notification on app startup (will be reset when new session starts)
bool _isInForeground = true; // Tracking app lifecycle state bool _isInForeground = true; // Tracking app lifecycle state
int _cachedRemainingSessionSeconds = 0; int _cachedRemainingSessionSeconds = 0;
@@ -179,7 +173,6 @@ class SessionManager extends ChangeNotifier {
/// How many times the user has opened the app today. /// How many times the user has opened the app today.
int get dailyOpenCount => _dailyOpenCount; int get dailyOpenCount => _dailyOpenCount;
int get lastAppSessionMinutes => _lastAppSessionMinutes;
// Scheduled Blocking Getters // Scheduled Blocking Getters
bool get scheduleEnabled => _scheduleEnabled; bool get scheduleEnabled => _scheduleEnabled;
@@ -299,8 +292,7 @@ class SessionManager extends ChangeNotifier {
_sessionExpiry = expiry; _sessionExpiry = expiry;
_isSessionActive = true; _isSessionActive = true;
} else { } else {
// Don't show notification for expired sessions from previous app session _cleanupExpiredReelSession();
_cleanupExpiredReelSession(showNotification: false);
} }
} }
final lastEndMs = _prefs!.getInt(_keyLastSessionEnd) ?? 0; final lastEndMs = _prefs!.getInt(_keyLastSessionEnd) ?? 0;
@@ -314,7 +306,6 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs); _appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
} }
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false; _appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0; final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
if (lastAppEndMs > 0) { if (lastAppEndMs > 0) {
@@ -368,26 +359,23 @@ class SessionManager extends ChangeNotifier {
// and update expiry ONLY when in foreground. // and update expiry ONLY when in foreground.
if (remainingSessionSeconds <= 0) { if (remainingSessionSeconds <= 0) {
// Only cleanup if session was actually active and has expired naturally _cleanupExpiredReelSession();
_cleanupExpiredReelSession(showNotification: true);
changed = true; changed = true;
} else { } else {
_dailyUsedSeconds++; _dailyUsedSeconds++;
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds); _prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted) { if (isDailyLimitExhausted) _cleanupExpiredReelSession();
_cleanupExpiredReelSession(showNotification: true);
}
changed = true; changed = true;
} }
} }
// App session countdown / expiry check // App session expiry check
if (_appSessionEnd != null && !_appSessionExpiredFlag) { if (_appSessionEnd != null && !_appSessionExpiredFlag) {
if (DateTime.now().isAfter(_appSessionEnd!)) { if (DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true; _appSessionExpiredFlag = true;
}
changed = true; changed = true;
} }
}
if (isCooldownActive) { if (isCooldownActive) {
changed = true; changed = true;
@@ -402,49 +390,24 @@ class SessionManager extends ChangeNotifier {
if (sched != _lastScheduleState) { if (sched != _lastScheduleState) {
_lastScheduleState = sched; _lastScheduleState = sched;
changed = true; changed = true;
// Show notification when schedule becomes active
if (sched && !_scheduleNotificationShown) {
_scheduleNotificationShown = true;
NotificationService().showNotification(
id: 1001,
title: 'FocusGram Schedule Active',
body: 'Instagram is blocked according to your schedule.',
);
} else if (!sched) {
_scheduleNotificationShown = false;
}
} }
if (changed) notifyListeners(); if (changed) notifyListeners();
} }
void _cleanupExpiredReelSession({bool showNotification = true}) { void _cleanupExpiredReelSession() {
// Only show notification if we haven't already shown one for this session
// and the user has enabled session end notifications
// The showNotification parameter should be false when cleaning up on app startup
// (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;
if (notifySessionEnd) {
NotificationService().showNotification(
id: 999,
title: 'Session Ended',
body: 'Your Reel session has expired. Time to focus!',
);
}
}
_isSessionActive = false; _isSessionActive = false;
_sessionExpiry = null; _sessionExpiry = null;
_lastSessionEnd = DateTime.now(); _lastSessionEnd = DateTime.now();
_prefs?.setInt(_keySessionExpiry, 0); _prefs?.setInt(_keySessionExpiry, 0);
_prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch); _prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch);
// Alert User
NotificationService().showNotification(
id: 999,
title: 'Session Ended',
body: 'Your Reel session has expired. Time to focus!',
);
} }
// Reel session API // Reel session API
@@ -455,8 +418,6 @@ class SessionManager extends ChangeNotifier {
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds); final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed)); _sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
_isSessionActive = true; _isSessionActive = true;
_sessionEndNotificationShown =
false; // Reset notification flag for new session
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch); _prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners(); notifyListeners();
return true; return true;
@@ -464,8 +425,7 @@ class SessionManager extends ChangeNotifier {
void endSession() { void endSession() {
if (!_isSessionActive) return; if (!_isSessionActive) return;
// Don't show notification when user manually ends the session _cleanupExpiredReelSession();
_cleanupExpiredReelSession(showNotification: false);
notifyListeners(); notifyListeners();
} }
@@ -475,10 +435,7 @@ class SessionManager extends ChangeNotifier {
_dailyLimitSeconds, _dailyLimitSeconds,
); );
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds); _prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted && _isSessionActive) { if (isDailyLimitExhausted && _isSessionActive) _cleanupExpiredReelSession();
// Daily limit exhausted - show notification
_cleanupExpiredReelSession(showNotification: true);
}
notifyListeners(); notifyListeners();
} }
@@ -490,10 +447,8 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = end; _appSessionEnd = end;
_appSessionExpiredFlag = false; _appSessionExpiredFlag = false;
_appExtensionUsed = false; _appExtensionUsed = false;
_lastAppSessionMinutes = minutes;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch); _prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false); _prefs?.setBool(_keyAppSessionExtUsed, false);
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
notifyListeners(); notifyListeners();
} }
+100 -534
View File
@@ -1,20 +1,13 @@
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
/// Stores and retrieves all user-configurable app settings. /// Stores and retrieves all user-configurable app settings.
class SettingsService extends ChangeNotifier { class SettingsService extends ChangeNotifier {
static const _keyBlurExplore = 'set_blur_explore'; static const _keyBlurExplore = 'set_blur_explore';
static const _keyBlurReels = 'set_blur_reels'; static const _keyBlurReels = 'set_blur_reels';
static const _keyTapToUnblur = 'set_tap_to_unblur'; static const _keyRequireLongPress = 'set_require_long_press';
static const _keyShowBreathGate = 'set_show_breath_gate'; static const _keyShowBreathGate = 'set_show_breath_gate';
static const _keyRequireWordChallenge = 'set_require_word_challenge'; static const _keyRequireWordChallenge = 'set_require_word_challenge';
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyBreathGateSeconds = 'breath_gate_seconds';
static const _keyWordChallengeCount = 'word_challenge_count';
static const _keyEnableTextSelection = 'set_enable_text_selection'; static const _keyEnableTextSelection = 'set_enable_text_selection';
static const _keyEnabledTabs = 'set_enabled_tabs'; static const _keyEnabledTabs = 'set_enabled_tabs';
static const _keyShowInstaSettings = 'set_show_insta_settings'; static const _keyShowInstaSettings = 'set_show_insta_settings';
@@ -23,43 +16,26 @@ class SettingsService extends ChangeNotifier {
// Focus / playback // Focus / playback
static const _keyBlockAutoplay = 'block_autoplay'; static const _keyBlockAutoplay = 'block_autoplay';
// Extras (Phase 2) // Grayscale mode
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 _keyGrayscaleEnabled = 'grayscale_enabled';
static const _keyGrayscaleSchedules = 'grayscale_schedules'; static const _keyGrayscaleScheduleEnabled = 'grayscale_schedule_enabled';
static const _keyGrayscaleScheduleTime = 'grayscale_schedule_time';
// Content filtering / UI hiding // Content filtering / UI hiding
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
static const _keyHideLikeCounts = 'hide_like_counts'; static const _keyHideLikeCounts = 'hide_like_counts';
static const _keyHideFollowerCounts = 'hide_follower_counts'; static const _keyHideFollowerCounts = 'hide_follower_counts';
static const _keyHideStoriesBar = 'hide_stories_bar';
static const _keyHideExploreTab = 'hide_explore_tab';
static const _keyHideReelsTab = 'hide_reels_tab';
static const _keyHideShopTab = 'hide_shop_tab'; static const _keyHideShopTab = 'hide_shop_tab';
// Minimal mode // Complete section disabling / Minimal mode
static const _keyDisableReelsEntirely = 'disable_reels_entirely';
static const _keyDisableExploreEntirely = 'disable_explore_entirely';
static const _keyMinimalModeEnabled = 'minimal_mode_enabled'; 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 _keyMinimalModePrevBlockHomeFeedScroll =
'minimal_mode_prev_block_home_feed_scroll';
// Reels History // Reels History
static const _keyReelsHistoryEnabled = 'reels_history_enabled'; static const _keyReelsHistoryEnabled = 'reels_history_enabled';
@@ -68,67 +44,37 @@ class SettingsService extends ChangeNotifier {
static const _keyNotifyDMs = 'set_notify_dms'; static const _keyNotifyDMs = 'set_notify_dms';
static const _keyNotifyActivity = 'set_notify_activity'; static const _keyNotifyActivity = 'set_notify_activity';
static const _keyNotifySessionEnd = 'set_notify_session_end'; 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; SharedPreferences? _prefs;
bool _blurExplore = true; bool _blurExplore = true;
bool _blurReels = false; bool _blurReels = false;
bool _tapToUnblur = true;
bool _requireLongPress = true; bool _requireLongPress = true;
bool _showBreathGate = true; bool _showBreathGate = true;
bool _requireWordChallenge = true; bool _requireWordChallenge = true;
int _breathGateSeconds = 10;
int _wordChallengeCount = 30;
bool _enableTextSelection = false; bool _enableTextSelection = false;
bool _showInstaSettings = true; bool _showInstaSettings = true;
bool _isDarkMode = true; // Default to dark as per existing app theme bool _isDarkMode = true; // Default to dark as per existing app theme
bool _blockAutoplay = true; bool _blockAutoplay = true;
bool _videoDownloadEnabled = false;
bool _hideSuggestedPosts = false;
// FocusGram v2 overlay toggles
bool _v2GhostModeEnabled = false;
bool _v2AdBlockerDomEnabled = false;
bool _v2ContentHiderEnabled = false;
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
bool _contentStories = false;
bool _contentPosts = false;
bool _contentReels = false;
bool _contentSuggested = false;
// Grayscale mode - now supports multiple schedules
bool _grayscaleEnabled = false; bool _grayscaleEnabled = false;
List<Map<String, dynamic>> _grayscaleSchedules = []; bool _grayscaleScheduleEnabled = false;
String _grayscaleScheduleTime = '21:00'; // 9:00 PM default
// Content filtering / UI hiding bool _hideSuggestedPosts = false;
bool _hideSponsoredPosts = false;
bool _hideLikeCounts = false; bool _hideLikeCounts = false;
bool _hideFollowerCounts = false; bool _hideFollowerCounts = false;
bool _hideStoriesBar = false;
bool _hideExploreTab = false;
bool _hideReelsTab = false;
bool _hideShopTab = false; bool _hideShopTab = false;
// These are now controlled internally by minimal mode
bool _disableReelsEntirely = false; bool _disableReelsEntirely = false;
bool _disableExploreEntirely = false; bool _disableExploreEntirely = false;
bool _blockHomeFeedScroll = false;
bool _minimalModeEnabled = false; bool _minimalModeEnabled = false;
// Tracking for smart restore
bool _prevDisableReels = false;
bool _prevDisableExplore = false;
bool _prevBlurExplore = false;
bool _prevBlockHomeFeedScroll = false;
bool _reelsHistoryEnabled = true; bool _reelsHistoryEnabled = true;
// Privacy defaults - notifications OFF by default // Privacy defaults - notifications OFF by default
@@ -136,15 +82,6 @@ class SettingsService extends ChangeNotifier {
bool _notifyDMs = false; bool _notifyDMs = false;
bool _notifyActivity = false; bool _notifyActivity = false;
bool _notifySessionEnd = false; 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 = [ List<String> _enabledTabs = [
'Home', 'Home',
@@ -157,100 +94,57 @@ class SettingsService extends ChangeNotifier {
bool get blurExplore => _blurExplore; bool get blurExplore => _blurExplore;
bool get blurReels => _blurReels; bool get blurReels => _blurReels;
bool get tapToUnblur => _tapToUnblur;
bool get requireLongPress => _requireLongPress; bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate; bool get showBreathGate => _showBreathGate;
bool get requireWordChallenge => _requireWordChallenge; bool get requireWordChallenge => _requireWordChallenge;
int get breathGateSeconds => _breathGateSeconds;
int get wordChallengeCount => _wordChallengeCount;
bool get enableTextSelection => _enableTextSelection; bool get enableTextSelection => _enableTextSelection;
bool get showInstaSettings => _showInstaSettings; bool get showInstaSettings => _showInstaSettings;
List<String> get enabledTabs => _enabledTabs; List<String> get enabledTabs => _enabledTabs;
bool get isFirstRun => _isFirstRun; bool get isFirstRun => _isFirstRun;
bool get isDarkMode => _isDarkMode; bool get isDarkMode => _isDarkMode;
bool get blockAutoplay => _blockAutoplay; bool get blockAutoplay => _blockAutoplay;
// Extras (Phase 2)
bool get videoDownloadEnabled => _videoDownloadEnabled;
bool get hideSuggestedPosts => _hideSuggestedPosts;
// FocusGram v2 overlay toggles
bool get v2GhostModeEnabled => _v2GhostModeEnabled;
bool get v2AdBlockerDomEnabled => _v2AdBlockerDomEnabled;
bool get v2ContentHiderEnabled => _v2ContentHiderEnabled;
bool get contentStories => _contentStories;
bool get contentPosts => _contentPosts;
bool get contentReels => _contentReels;
bool get contentSuggested => _contentSuggested;
bool get notifyDMs => _notifyDMs; bool get notifyDMs => _notifyDMs;
bool get notifyActivity => _notifyActivity; bool get notifyActivity => _notifyActivity;
bool get notifySessionEnd => _notifySessionEnd; bool get notifySessionEnd => _notifySessionEnd;
bool get notifyPersistent => _notifyPersistent;
bool get grayscaleEnabled => _grayscaleEnabled; bool get grayscaleEnabled => _grayscaleEnabled;
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules; bool get grayscaleScheduleEnabled => _grayscaleScheduleEnabled;
String get grayscaleScheduleTime => _grayscaleScheduleTime;
bool get hideSuggestedPosts => _hideSuggestedPosts;
bool get hideSponsoredPosts => _hideSponsoredPosts;
bool get hideLikeCounts => _hideLikeCounts; bool get hideLikeCounts => _hideLikeCounts;
bool get hideFollowerCounts => _hideFollowerCounts; bool get hideFollowerCounts => _hideFollowerCounts;
bool get hideStoriesBar => _hideStoriesBar;
bool get hideExploreTab => _hideExploreTab;
bool get hideReelsTab => _hideReelsTab;
bool get hideShopTab => _hideShopTab; bool get hideShopTab => _hideShopTab;
// Focus mode settings
bool get ghostMode => _ghostMode;
bool get noAds => _noAds;
bool get noStories => _noStories;
bool get noReels => _noReels;
bool get noAutoplay => _noAutoplay;
bool get noDMs => _noDMs;
// These are now controlled by minimal mode only
bool get disableReelsEntirely => _disableReelsEntirely; bool get disableReelsEntirely => _disableReelsEntirely;
bool get disableExploreEntirely => _disableExploreEntirely; bool get disableExploreEntirely => _disableExploreEntirely;
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
bool get minimalModeEnabled => _minimalModeEnabled; bool get minimalModeEnabled => _minimalModeEnabled;
bool get reelsHistoryEnabled => _reelsHistoryEnabled; bool get reelsHistoryEnabled => _reelsHistoryEnabled;
/// True if grayscale should currently be applied, considering the manual /// True if grayscale should currently be applied, considering the manual
/// toggle and the optional schedules. /// toggle and the optional schedule.
bool get isGrayscaleActiveNow { bool get isGrayscaleActiveNow {
if (_grayscaleEnabled) return true; if (_grayscaleEnabled) return true;
if (_grayscaleSchedules.isEmpty) return false; if (!_grayscaleScheduleEnabled) return false;
try {
final parts = _grayscaleScheduleTime.split(':');
if (parts.length != 2) return false;
final h = int.parse(parts[0]);
final m = int.parse(parts[1]);
final now = DateTime.now(); final now = DateTime.now();
final currentMinutes = now.hour * 60 + now.minute; final currentMinutes = now.hour * 60 + now.minute;
final startMinutes = h * 60 + m;
for (final schedule in _grayscaleSchedules) { // Active from the configured time until midnight.
if (schedule['enabled'] != true) continue; return currentMinutes >= startMinutes;
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 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
if (currentMinutes >= startMinutes || currentMinutes < endMinutes) {
return true;
}
} else {
// Same day: active if current time is between start and end
if (currentMinutes >= startMinutes && currentMinutes < endMinutes) {
return true;
}
}
} catch (_) { } catch (_) {
continue;
}
}
return false; return false;
} }
}
// Privacy getters // Privacy getters
bool get sanitizeLinks => _sanitizeLinks; bool get sanitizeLinks => _sanitizeLinks;
@@ -259,89 +153,39 @@ class SettingsService extends ChangeNotifier {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
_blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true; _blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true;
_blurReels = _prefs!.getBool(_keyBlurReels) ?? false; _blurReels = _prefs!.getBool(_keyBlurReels) ?? false;
_tapToUnblur = _prefs!.getBool(_keyTapToUnblur) ?? true;
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true; _requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true; _showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true; _requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
_breathGateSeconds = (_prefs!.getInt(_keyBreathGateSeconds) ?? 10)
.clamp(3, 60)
.toInt();
_wordChallengeCount = _normaliseWordChallengeCount(
_prefs!.getInt(_keyWordChallengeCount) ?? 30,
);
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false; _enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true; _showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true; _blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
// Extras (Phase 2) - defaults OFF for safety/non-invasive behavior _grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
_videoDownloadEnabled = _prefs!.getBool(_keyVideoDownloadEnabled) ?? false; _grayscaleScheduleEnabled =
_prefs!.getBool(_keyGrayscaleScheduleEnabled) ?? false;
_grayscaleScheduleTime =
_prefs!.getString(_keyGrayscaleScheduleTime) ?? '21:00';
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false; _hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? 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),
),
);
} catch (_) {
_grayscaleSchedules = [];
}
}
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false; _hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false; _hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
_hideStoriesBar = _prefs!.getBool(_keyHideStoriesBar) ?? false;
_hideExploreTab = _prefs!.getBool(_keyHideExploreTab) ?? false;
_hideReelsTab = _prefs!.getBool(_keyHideReelsTab) ?? false;
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false; _hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
// Load minimal mode _disableReelsEntirely = _prefs!.getBool(_keyDisableReelsEntirely) ?? false;
_disableExploreEntirely =
_prefs!.getBool(_keyDisableExploreEntirely) ?? false;
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false; _minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
// Load previous states for smart restore
_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;
_blockHomeFeedScroll =
_prefs!.getBool('internal_block_home_feed_scroll') ?? false;
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true; _reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
// Focus mode settings
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
_noReels = _prefs!.getBool(_keyNoReels) ?? false;
_noAutoplay = _prefs!.getBool(_keyNoAutoplay) ?? false;
_noDMs = _prefs!.getBool(_keyNoDMs) ?? false;
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true; _sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false; _notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false; _notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
_notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false; _notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false;
_notifyPersistent = _prefs!.getBool(_keyNotifyPersistent) ?? false;
_enabledTabs = _enabledTabs =
(_prefs!.getStringList(_keyEnabledTabs) ?? (_prefs!.getStringList(_keyEnabledTabs) ??
@@ -364,31 +208,15 @@ class SettingsService extends ChangeNotifier {
Future<void> setBlurExplore(bool v) async { Future<void> setBlurExplore(bool v) async {
_blurExplore = v; _blurExplore = v;
await _prefs?.setBool(_keyBlurExplore, v); await _prefs?.setBool(_keyBlurExplore, v);
if (_minimalModeEnabled) {
await _checkAndAutoDisableMinimalMode();
}
notifyListeners(); notifyListeners();
} }
Future<void> setBlurReels(bool v) async { Future<void> setBlurReels(bool v) async {
_blurReels = v; _blurReels = v;
// Sync blur reels with blur explore - enabling one enables the other
if (v && !_blurExplore) {
_blurExplore = true;
await _prefs?.setBool(_keyBlurExplore, true);
}
await _prefs?.setBool(_keyBlurReels, v); await _prefs?.setBool(_keyBlurReels, v);
notifyListeners(); notifyListeners();
} }
Future<void> setTapToUnblur(bool v) async {
_tapToUnblur = v;
await _prefs?.setBool(_keyTapToUnblur, v);
notifyListeners();
}
Future<void> setRequireLongPress(bool v) async { Future<void> setRequireLongPress(bool v) async {
_requireLongPress = v; _requireLongPress = v;
await _prefs?.setBool(_keyRequireLongPress, v); await _prefs?.setBool(_keyRequireLongPress, v);
@@ -407,32 +235,6 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); 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 { Future<void> setEnableTextSelection(bool v) async {
_enableTextSelection = v; _enableTextSelection = v;
await _prefs?.setBool(_keyEnableTextSelection, v); await _prefs?.setBool(_keyEnableTextSelection, v);
@@ -451,11 +253,21 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
// Extras (Phase 2) Future<void> setGrayscaleEnabled(bool v) async {
_grayscaleEnabled = v;
await _prefs?.setBool(_keyGrayscaleEnabled, v);
notifyListeners();
}
Future<void> setVideoDownloadEnabled(bool v) async { Future<void> setGrayscaleScheduleEnabled(bool v) async {
_videoDownloadEnabled = v; _grayscaleScheduleEnabled = v;
await _prefs?.setBool(_keyVideoDownloadEnabled, v); await _prefs?.setBool(_keyGrayscaleScheduleEnabled, v);
notifyListeners();
}
Future<void> setGrayscaleScheduleTime(String hhmm) async {
_grayscaleScheduleTime = hhmm;
await _prefs?.setString(_keyGrayscaleScheduleTime, hhmm);
notifyListeners(); notifyListeners();
} }
@@ -465,116 +277,15 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> setGrayscaleEnabled(bool v) async { Future<void> setHideSponsoredPosts(bool v) async {
_grayscaleEnabled = v; _hideSponsoredPosts = v;
await _prefs?.setBool(_keyGrayscaleEnabled, v); await _prefs?.setBool(_keyHideSponsoredPosts, v);
notifyListeners(); notifyListeners();
} }
Future<void> setGrayscaleSchedules( Future<void> setHideLikeCounts(bool v) async {
List<Map<String, dynamic>> schedules, _hideLikeCounts = v;
) async { await _prefs?.setBool(_keyHideLikeCounts, v);
_grayscaleSchedules = schedules;
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
notifyListeners();
}
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
_grayscaleSchedules.add(schedule);
await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners();
}
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),
);
notifyListeners();
}
}
Future<void> removeGrayscaleSchedule(int index) async {
if (index >= 0 && index < _grayscaleSchedules.length) {
_grayscaleSchedules.removeAt(index);
await _prefs?.setString(
_keyGrayscaleSchedules,
jsonEncode(_grayscaleSchedules),
);
notifyListeners();
}
}
Future<void> setHideShopTab(bool v) async {
_hideShopTab = v;
await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners();
}
// 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(); notifyListeners();
} }
@@ -584,139 +295,45 @@ class SettingsService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Setter for internal disable reels state (used by minimal mode submenu) Future<void> setHideStoriesBar(bool v) async {
/// Auto-disables minimal mode if all features are turned off _hideStoriesBar = v;
Future<void> setDisableReelsEntirelyInternal(bool v) async { await _prefs?.setBool(_keyHideStoriesBar, v);
notifyListeners();
}
Future<void> setHideExploreTab(bool v) async {
_hideExploreTab = v;
await _prefs?.setBool(_keyHideExploreTab, v);
notifyListeners();
}
Future<void> setHideReelsTab(bool v) async {
_hideReelsTab = v;
await _prefs?.setBool(_keyHideReelsTab, v);
notifyListeners();
}
Future<void> setHideShopTab(bool v) async {
_hideShopTab = v;
await _prefs?.setBool(_keyHideShopTab, v);
notifyListeners();
}
Future<void> setDisableReelsEntirely(bool v) async {
_disableReelsEntirely = v; _disableReelsEntirely = v;
await _prefs?.setBool('internal_disable_reels_entirely', v); await _prefs?.setBool(_keyDisableReelsEntirely, v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners(); notifyListeners();
} }
/// Setter for internal disable explore state (used by minimal mode submenu) Future<void> setDisableExploreEntirely(bool v) async {
/// Auto-disables minimal mode if all features are turned off
Future<void> setDisableExploreEntirelyInternal(bool v) async {
_disableExploreEntirely = v; _disableExploreEntirely = v;
await _prefs?.setBool('internal_disable_explore_entirely', v); await _prefs?.setBool(_keyDisableExploreEntirely, v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners(); notifyListeners();
} }
/// Setter for home feed scroll blocking state (used by minimal mode submenu).
Future<void> setBlockHomeFeedScrollInternal(bool v) async {
_blockHomeFeedScroll = v;
await _prefs?.setBool('internal_block_home_feed_scroll', v);
await _checkAndAutoDisableMinimalMode();
notifyListeners();
}
/// Helper: Auto-disable minimal mode if all its features are disabled
/// This ensures minimal mode auto-turns-off when user disables all sub-features
///
/// NOTE: We must check the RAW state variables here, NOT the public getters
/// (disableReelsEntirely/disableExploreEntirely), because those getters
/// unconditionally return true when _minimalModeEnabled is true, which would
/// make the "all disabled" condition impossible to reach.
Future<void> _checkAndAutoDisableMinimalMode() async {
if (!_minimalModeEnabled) return;
// Check the RAW saved state, not the getters
final rawReels =
_prefs?.getBool('internal_disable_reels_entirely') ??
_disableReelsEntirely;
final rawExplore =
_prefs?.getBool('internal_disable_explore_entirely') ??
_disableExploreEntirely;
final rawHomeFeedScroll =
_prefs?.getBool('internal_block_home_feed_scroll') ??
_blockHomeFeedScroll;
final allDisabled =
!rawReels && !rawExplore && !rawHomeFeedScroll && !_blurExplore;
if (allDisabled) {
_minimalModeEnabled = false;
await _prefs?.setBool(_keyMinimalModeEnabled, false);
}
}
/// Smart minimal mode toggle with state preservation
Future<void> setMinimalModeEnabled(bool v) async { Future<void> setMinimalModeEnabled(bool v) async {
if (v) { _minimalModeEnabled = v;
// Turning ON await _prefs?.setBool(_keyMinimalModeEnabled, v);
// 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(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
await _prefs?.setBool(
_keyMinimalModePrevBlockHomeFeedScroll,
_prevBlockHomeFeedScroll,
);
_minimalModeEnabled = true;
_disableReelsEntirely = true;
_disableExploreEntirely = 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 states that were saved BEFORE minimal mode was enabled.
// _prevDisableReels/Explore were saved at the moment minimal mode turned ON.
_minimalModeEnabled = false;
_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;
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_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(); notifyListeners();
} }
@@ -742,69 +359,18 @@ class SettingsService extends ChangeNotifier {
Future<void> setNotifyDMs(bool v) async { Future<void> setNotifyDMs(bool v) async {
_notifyDMs = v; _notifyDMs = v;
await _prefs?.setBool(_keyNotifyDMs, v); await _prefs?.setBool(_keyNotifyDMs, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners(); notifyListeners();
} }
Future<void> setNotifyActivity(bool v) async { Future<void> setNotifyActivity(bool v) async {
_notifyActivity = v; _notifyActivity = v;
await _prefs?.setBool(_keyNotifyActivity, v); await _prefs?.setBool(_keyNotifyActivity, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners(); notifyListeners();
} }
Future<void> setNotifySessionEnd(bool v) async { Future<void> setNotifySessionEnd(bool v) async {
_notifySessionEnd = v; _notifySessionEnd = v;
await _prefs?.setBool(_keyNotifySessionEnd, v); await _prefs?.setBool(_keyNotifySessionEnd, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
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(); notifyListeners();
} }
+1 -1
View File
@@ -517,7 +517,7 @@ class DisciplineChallenge {
]; ];
/// Shows the word challenge dialog. Returns true if successful. /// Shows the word challenge dialog. Returns true if successful.
static Future<bool> show(BuildContext context, {int count = 30}) async { static Future<bool> show(BuildContext context, {int count = 15}) async {
final list = List<String>.from(_words)..shuffle(); final list = List<String>.from(_words)..shuffle();
final challenge = list.take(count).join(' '); final challenge = list.take(count).join(' ');
final controller = TextEditingController(); final controller = TextEditingController();
@@ -1,141 +0,0 @@
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;
}
}
}
@@ -1,77 +0,0 @@
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);
}
}
-36
View File
@@ -1,36 +0,0 @@
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),
),
],
);
},
);
}
}
+45
View File
@@ -0,0 +1,45 @@
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
+24 -24
View File
@@ -37,10 +37,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: app_settings name: app_settings
sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a" sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "6.1.1"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -77,10 +77,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
checked_yaml: checked_yaml:
dependency: transitive dependency: transitive
description: description:
@@ -213,10 +213,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: fl_chart name: fl_chart
sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0 sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.71.0" version: "0.69.2"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -290,10 +290,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_launcher_icons name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.14.4" version: "0.13.1"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -372,10 +372,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: google_fonts name: google_fonts
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.0" version: "8.0.2"
gtk: gtk:
dependency: transitive dependency: transitive
description: description:
@@ -540,18 +540,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -596,10 +596,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.0.1" version: "8.3.1"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -668,18 +668,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: permission_handler name: permission_handler
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "11.4.0" version: "12.0.1"
permission_handler_android: permission_handler_android:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_android name: permission_handler_android
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "12.1.0" version: "13.0.1"
permission_handler_apple: permission_handler_apple:
dependency: transitive dependency: transitive
description: description:
@@ -764,10 +764,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.5" version: "2.5.4"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
@@ -865,10 +865,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.7"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
+13 -19
View File
@@ -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." 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' publish_to: 'none'
version: 2.0.0 version: 1.0.0+3
environment: environment:
sdk: ^3.10.7 sdk: ^3.10.7
@@ -11,11 +11,11 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
# WebView engine # WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely
flutter_inappwebview: ^6.1.5 flutter_inappwebview: ^6.1.5
# Local key-value persistence — latest stable # Local key-value persistence — latest stable
shared_preferences: ^2.5.5 shared_preferences: ^2.5.4
# Date/time formatting for daily resets — latest stable # Date/time formatting for daily resets — latest stable
intl: ^0.20.2 intl: ^0.20.2
@@ -28,26 +28,26 @@ dependencies:
# URL launcher for About page links — latest stable # URL launcher for About page links — latest stable
url_launcher: ^6.3.2 url_launcher: ^6.3.2
package_info_plus: ^9.0.0 package_info_plus: ^8.1.2
# Handling Instagram deep links — latest stable # Handling Instagram deep links — latest stable
app_links: ^6.4.1 app_links: ^6.3.2
# Open system settings — latest stable # Open system settings — latest stable
app_settings: ^7.0.0 app_settings: ^6.1.1
google_fonts: ^8.1.0 google_fonts: ^8.0.2
http: ^1.6.0 http: ^1.3.0
permission_handler: ^11.4.0 permission_handler: ^12.0.1
# Image/file picker for story uploads on Android # Image/file picker for story uploads on Android
image_picker: ^1.2.0 image_picker: ^1.1.2
flutter_windowmanager_plus: ^1.0.1 flutter_windowmanager_plus: ^1.0.1
# Charts for on-device screen time dashboard (MIT) # Charts for on-device screen time dashboard (MIT)
fl_chart: ^0.71.0 fl_chart: ^0.69.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.13.1
flutter: flutter:
uses-material-design: true uses-material-design: true
@@ -55,12 +55,6 @@ flutter:
assets: assets:
- assets/images/focusgram.png - assets/images/focusgram.png
- assets/images/focusgram.ico - assets/images/focusgram.ico
- assets/scripts/ghost_mode.js
- assets/scripts/ad_blocker_dom.js
- assets/scripts/content_hider.js
- assets/scripts/theme_detector.js
- assets/scripts/fetch_interceptor.js
- assets/scripts/autoplay_blocker.js
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
@@ -68,6 +62,6 @@ flutter_launcher_icons:
image_path: "assets/images/focusgram.png" image_path: "assets/images/focusgram.png"
adaptive_icon_background: "#000000" adaptive_icon_background: "#000000"
adaptive_icon_foreground: "assets/images/focusgram.png" adaptive_icon_foreground: "assets/images/focusgram.png"
min_sdk_android: 24 min_sdk_android: 21
@@ -1,65 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focusgram/screens/main_webview_page.dart';
void main() {
group('handleFocusGramMediaDownload', () {
test('rejects non-http(s) schemes', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{"type":"video","url":"file:///etc/passwd","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
test('accepts http(s) instagram-like hosts and calls launcher', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw:
'{"type":"video","url":"https://cdninstagram.com/v/1.mp4","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isTrue);
expect(launched, hasLength(1));
expect(launched.first.scheme, 'https');
expect(launched.first.host.toLowerCase(), contains('cdninstagram.com'));
});
test('rejects non-instagram hosts even if http(s)', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw:
'{"type":"video","url":"https://example.com/video.mp4","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
test('rejects malformed JSON safely', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{not json',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
test('rejects missing url field', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{"type":"video","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
});
}
-90
View File
@@ -1,90 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/injection_manager.dart';
import 'package:focusgram/services/adblock/adblock_content_blocker_loader.dart';
import 'package:focusgram/services/session_manager.dart';
import 'package:focusgram/services/settings_service.dart';
class _FakeJsEvaluator implements JsEvaluator {
final List<String> sources = [];
@override
Future<void> evaluateJavascript({required String source}) async {
sources.add(source);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test(
'v2AdBlockerDomEnabled(true) does NOT trigger sponsored-post JS injection (handled by V2 engine)',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setV2AdBlockerDomEnabled(true);
expect(settings.v2AdBlockerDomEnabled, isTrue);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
// Verify that sponsored posts JS injection is NOT triggered by InjectionManager
// (it's handled by the V2 DOM Ad Blocker engine instead)
final sponsoredPostsInjected = fakeEval.sources.any(
(s) => s.contains('hideSponsoredPosts') || s.contains('Sponsored'),
);
expect(
sponsoredPostsInjected,
isFalse,
reason:
'Sponsored posts blocking is now handled by V2 DOM Ad Blocker, not JS injection',
);
},
);
test(
'adblock parser extracts strict host rules and ignores allow/cosmetic rules',
() {
final hosts = AdblockContentBlockerLoader.parseHostsFromFilterText('''
! comment
[Adblock Plus 2.0]
||ads.example.com^
||tracker.example.net/path.js\$third-party
@@||allowed.example.com^
example.com##.sponsored
||wild*.example.com^
||bad,domain.example^
||sub.adguard.example.org^\$script,third-party
''');
expect(
hosts,
containsAll({
'ads.example.com',
'tracker.example.net',
'sub.adguard.example.org',
}),
);
expect(hosts, isNot(contains('allowed.example.com')));
expect(hosts, isNot(contains('wild*.example.com')));
expect(hosts, isNot(contains('bad,domain.example')));
},
);
}
-155
View File
@@ -1,155 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/injection_manager.dart';
import 'package:focusgram/services/session_manager.dart';
import 'package:focusgram/services/settings_service.dart';
class _FakeJsEvaluator implements JsEvaluator {
final List<String> sources = [];
@override
Future<void> evaluateJavascript({required String source}) async {
sources.add(source);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test(
'does NOT inject hideSuggestedPosts JS even when legacy setting is true',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setHideSuggestedPosts(true);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
expect(any, isFalse);
},
);
test(
'does NOT inject hideSuggestedPosts JS when settings.hideSuggestedPosts=false',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setHideSuggestedPosts(false);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
expect(any, isFalse);
},
);
test(
'injects video downloader JS only when settings.videoDownloadEnabled=true',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setVideoDownloadEnabled(true);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any(
(s) => s.contains('__fgMediaDownloadRunning'),
);
expect(any, isTrue);
},
);
test(
'does NOT inject video downloader JS when settings.videoDownloadEnabled=false',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setVideoDownloadEnabled(false);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any(
(s) => s.contains('__fgMediaDownloadRunning'),
);
expect(any, isFalse);
},
);
test('injects home feed scroll lock flag when enabled', () async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setBlockHomeFeedScrollInternal(true);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any(
(s) => s.contains('window.__fgBlockHomeFeedScroll = true;'),
);
expect(any, isTrue);
});
}
-56
View File
@@ -1,56 +0,0 @@
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';")),
);
},
);
});
}
@@ -1,69 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/screen_time_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('init loads persisted secondsByDate', () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
ScreenTimeService.prefKey,
'{"2026-01-01": 42, "2026-01-02": 7}',
);
final s = ScreenTimeService();
await s.init();
expect(s.secondsByDate['2026-01-01'], 42);
expect(s.secondsByDate['2026-01-02'], 7);
});
test('resetAll clears stored data and in-memory map', () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(ScreenTimeService.prefKey, '{"2026-01-01": 42}');
final s = ScreenTimeService();
await s.init();
expect(s.secondsByDate.isNotEmpty, isTrue);
await s.resetAll();
expect(s.secondsByDate, isEmpty);
final raw = prefs.getString(ScreenTimeService.prefKey);
expect(raw, isNull);
});
test(
'startTracking increments today seconds and stopTracking persists',
() async {
final s = ScreenTimeService();
await s.init();
final beforeTodayKey = DateTime.now();
final todayKey =
'${beforeTodayKey.year.toString().padLeft(4, '0')}-'
'${beforeTodayKey.month.toString().padLeft(2, '0')}-'
'${beforeTodayKey.day.toString().padLeft(2, '0')}';
s.startTracking();
// Wait ~2 seconds (test is unit-ish; still acceptable).
await Future<void>.delayed(const Duration(seconds: 2));
s.stopTracking();
expect(s.secondsByDate[todayKey], isNotNull);
expect(s.secondsByDate[todayKey]!, greaterThanOrEqualTo(2));
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(ScreenTimeService.prefKey);
expect(stored, isNotNull);
expect(stored, contains(todayKey));
},
);
}
-105
View File
@@ -1,105 +0,0 @@
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);
},
);
});
}
-84
View File
@@ -1,84 +0,0 @@
// 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.
-66
View File
@@ -1,66 +0,0 @@
# ── 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+
-98
View File
@@ -1,98 +0,0 @@
/**
* 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 });
})();
-100
View File
@@ -1,100 +0,0 @@
/**
* FocusGram DOM Ad Blocker
* Removes sponsored posts, "Suggested for you" injections, and ad elements.
* Uses structure-based selectors NOT class names (those change weekly).
* Injected at DOCUMENT_END.
*/
(function () {
'use strict';
// ─── Sponsored text signals (Instagram localizes these) ───────────────────
// We match the STRUCTURE not just English text.
// In IG mobile web, sponsored label appears as a <span> or <div>
// that is a direct sibling/child of the article header area.
const SPONSORED_TEXTS = new Set([
'sponsored', // en
'gesponsert', // de
'patrocinado', // es/pt
'sponsorisé', // fr
'sponsorizzato', // it
'sponsrad', // sv
'sponsoreret', // da
'gesponsord', // nl
'рекламa', // ru
'विज्ञापन', // hi
'广告', // zh
'ad', // en short
]);
const isSponsoredText = (text) =>
SPONSORED_TEXTS.has(text.trim().toLowerCase());
// ─── Remove a single article element ──────────────────────────────────────
const removeArticle = (el) => {
// Walk up to find the article or main feed item container
const target = el.closest('article') ?? el.closest('div[data-media-id]') ?? el;
target.remove();
};
// ─── Core ad scanner ──────────────────────────────────────────────────────
const scanAndRemove = () => {
// Strategy 1: <a href="/ads/..."> inside feed
document.querySelectorAll('a[href*="/ads/"]').forEach((a) => {
a.closest('article')?.remove();
});
// Strategy 2: Sponsored text in article spans
document.querySelectorAll('article').forEach((article) => {
const spans = article.querySelectorAll('span, div');
for (const span of spans) {
if (
span.children.length === 0 && // leaf node
isSponsoredText(span.textContent)
) {
article.remove();
return;
}
}
});
// Strategy 3: "Suggested for you" feed injections
document.querySelectorAll('article, section').forEach((el) => {
const firstText = el.querySelector('span, div, h4')?.textContent?.trim();
if (
firstText &&
(firstText.toLowerCase().startsWith('suggested') ||
firstText.toLowerCase().startsWith('you might') ||
firstText.toLowerCase() === 'posts you might like')
) {
el.remove();
}
});
// Strategy 4: Instagram marks some ad containers with aria-label
document
.querySelectorAll('[aria-label*="Sponsored"], [aria-label*="Ad"]')
.forEach((el) => {
el.closest('article')?.remove();
});
// Strategy 5: Tracking pixel iframes / hidden images
document.querySelectorAll('iframe[width="0"], iframe[height="0"]').forEach((el) => el.remove());
document
.querySelectorAll('img[width="1"][height="1"], img[width="0"][height="0"]')
.forEach((el) => el.remove());
};
// ─── Run on load + watch for new content ──────────────────────────────────
scanAndRemove();
const observer = new MutationObserver((mutations) => {
// Only scan if nodes were added (skip attribute/text changes)
const hasAdditions = mutations.some((m) => m.addedNodes.length > 0);
if (hasAdditions) scanAndRemove();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
})();
-83
View File
@@ -1,83 +0,0 @@
/**
* FocusGram Autoplay Blocker
* Injected at DOCUMENT_START before Instagram's JS loads.
* Prevents video autoplay by:
* 1. Blocking play() calls on video elements
* 2. Disabling autoplay attribute
* 3. Removing preload attributes
*/
(function () {
'use strict';
window.__fgBlockAutoplay = false;
// Override HTMLMediaElement.play() to check our flag
const _play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () {
if (window.__fgBlockAutoplay) {
// Return a resolved promise to avoid breaking Instagram's code
return Promise.resolve();
}
return _play.call(this);
};
// Override autoplay property setter
const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
const _originalAutoplaySetter = _videoDescriptor.set;
Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
set: function (value) {
if (window.__fgBlockAutoplay && value) {
// Silently ignore autoplay attempts when blocking is enabled
return;
}
if (_originalAutoplaySetter) {
_originalAutoplaySetter.call(this, value);
}
},
get: function () {
if (_videoDescriptor.get) {
return _videoDescriptor.get.call(this);
}
return this.getAttribute('autoplay') !== null;
},
enumerable: _videoDescriptor.enumerable,
configurable: true,
});
// On page load and SPA navigation, scan for video elements and remove autoplay
const removeAutoplayFromVideos = () => {
document.querySelectorAll('video, [role="video"]').forEach(el => {
if (window.__fgBlockAutoplay) {
el.autoplay = false;
el.removeAttribute('autoplay');
if (el.paused === false) {
el.pause();
}
}
});
};
// Run on load and when document changes
removeAutoplayFromVideos();
if (!window.__fgAutoplayObserver) {
let _timer = null;
window.__fgAutoplayObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(removeAutoplayFromVideos, 500);
});
window.__fgAutoplayObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// Allow Flutter to toggle
window.__fgSetBlockAutoplay = function (enabled) {
window.__fgBlockAutoplay = !!enabled;
if (enabled) {
removeAutoplayFromVideos();
}
};
})();
-304
View File
@@ -1,304 +0,0 @@
/**
* FocusGram Content Hider
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
* Flutter controls via window.__fgContent.*
* Injected at DOCUMENT_END.
*
* Key fixes applied:
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
* - Stories tray detection strengthened for fresh SPA navigations
* - Suggested posts detection uses multiple text-node matching strategies
*/
(function () {
'use strict';
if (window.__fgContent && window.__fgContent.__focusgramReady) {
return;
}
const STYLE_ID = 'fg-content-hider';
let hideStories = false;
let hidePosts = false;
let hideSuggested = false;
let hideReels = false;
// ─── CSS rules ─────────────────────────────────────────────────────────────
function buildCSS() {
const selectors = [];
if (hideStories) {
selectors.push(
'[role="list"]:has([aria-label*="tory"])',
'[role="listbox"]:has([aria-label*="tory"])',
'[role="menu"] > ul',
'section > div > div:first-child [style*="overflow"]',
'[role="list"] [style*="overflow"]',
);
}
if (hidePosts) {
selectors.push(
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
);
}
// hideReels CSS is intentionally NOT added here.
// We use DOM removal instead (see removeReels()) so that room is never left
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
return selectors.length
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
: '';
}
function applyCSS() {
if (document.body) {
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
}
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = buildCSS();
}
// ─── Story tray JS ─────────────────────────────────────────────────────────
function hideStoryTray() {
if (!hideStories) return;
// Strategy 1: <ul> children of a named list or menu
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
try {
const items = ul.querySelectorAll('li, button, a');
if (items.length < 2) return;
ul.style.setProperty('display', 'none', 'important');
} catch (_) {}
});
// Strategy 2: horizontally scrolling container with circle items
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
try {
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
if (cands.length < 2) return;
const s0 = window.getComputedStyle(cands[0]);
if (s0.width && parseFloat(s0.width) <= 90) {
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
}
} catch (_) {}
});
}
// ─── Suggested posts ───────────────────────────────────────────────────────
function removeSuggested() {
if (!hideSuggested) return;
var SIGNALS = [
'suggested for you',
'suggested posts',
'suggested reels',
'suggested',
'because you watched',
'because you follow',
'you might like',
'posts you might like',
'accounts you might like',
'recommendations',
];
function norm(s) {
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function hasSignal(s) {
var t = norm(s);
if (!t) return false;
return SIGNALS.some(function (signal) {
if (signal === 'suggested') return t === signal;
return t.indexOf(signal) >= 0;
});
}
function hideContainer(from) {
var parent = from;
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
var role = parent.getAttribute && parent.getAttribute('role');
var tag = parent.tagName;
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
if (
tag === 'ARTICLE' ||
tag === 'SECTION' ||
role === 'listitem' ||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
) {
parent.style.setProperty('display', 'none', 'important');
parent.setAttribute('data-fg-hidden-suggested', '1');
return true;
}
parent = parent.parentElement;
}
return false;
}
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
try {
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
var ownLabel = node.getAttribute('aria-label');
if (hasSignal(ownLabel)) { hideContainer(node); return; }
var text = norm(node.innerText || node.textContent || '');
if (
text.indexOf('suggested for you') >= 0 ||
text.indexOf('suggested posts') >= 0 ||
text.indexOf('suggested reels') >= 0 ||
text.indexOf('because you watched') >= 0 ||
text.indexOf('because you follow') >= 0
) {
hideContainer(node);
}
} catch (_) {}
});
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
try {
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
hideContainer(el);
}
} catch (_) {}
});
}
// ─── Reels DOM REMOVE (not display:none) ─────────────────────────────────
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
// gap cleanly and lets the feed flow naturally.
function removeReels() {
if (!hideReels) return;
var toRemove = [];
document.querySelectorAll('article').forEach(function (el) {
try {
// Fast path: check for a reel-signal attribute first
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
if (mt === '2') { toRemove.push(el); return; }
// Fallback: text-node scan for /reels/ markers
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var n;
while ((n = walker.nextNode())) {
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
toRemove.push(el); break;
}
}
} catch (_) {}
});
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
}
// ─── Public API ────────────────────────────────────────────────────────────
window.__fgContent = {
__focusgramReady: true,
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
setHideSuggested: function (val) {
hideSuggested = !!val;
applyCSS();
if (val) removeSuggested();
},
setHideReels: function (val) {
hideReels = !!val;
applyCSS();
if (val) removeReels();
},
applyAll: function (flags) {
hideStories = !!flags.stories;
hidePosts = !!flags.posts;
hideReels = !!flags.reels;
hideSuggested = !!flags.suggested;
applyCSS();
if (hideSuggested) removeSuggested();
if (hideStories) hideStoryTray();
if (hideReels) removeReels();
},
};
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
// pushState/replaceState don't fire any DOM event we can listen for.
// Hook the methods themselves so we know a navigation happened, then debounce
// re-apply. This also catches the case where the MutationObserver was on `body`
// and that node got replaced by Instagram's SPA re-render.
function scheduleReapply() {
clearTimeout(window.__fg_applyTimer);
window.__fg_applyTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 250);
}
var _origPush = history.pushState;
var _origReplace = history.replaceState;
history.pushState = function () {
_origPush.apply(this, arguments);
scheduleReapply();
};
history.replaceState = function () {
_origReplace.apply(this, arguments);
scheduleReapply();
};
// Reinforce on popstate too (user hits back/forward)
window.addEventListener('popstate', scheduleReapply, { passive: true });
// For pushState on the same URL (rare but possible) poll path briefly
window.addEventListener('pageshow', scheduleReapply, { passive: true });
window.addEventListener('focus', scheduleReapply, { passive: true });
// ─── MutationObserver ───────────────────────────────────────────────────────
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
// re-applies everything on each cycle. Does NOT guard on a per-element timer
// that would never re-fire after the body is replaced by SPA re-render.
if (!window.__fgContentObserver) {
window.__fgContentObserver = new MutationObserver(function () {
clearTimeout(window.__fg_moTimer);
window.__fg_moTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 300);
});
// `document.documentElement` survives SPA navigations (body gets replaced
// but <html> stays). Observing it catches both subtree mutations and, via
// the SPA heartbeat above, re-applies after pushState.
window.__fgContentObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// ─── Initial run ────────────────────────────────────────────────────────────
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
// Signal ready — Flutter will call applyAll() with stored prefs
if (window.ContentChannel) {
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
-281
View File
@@ -1,281 +0,0 @@
/**
* 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;
})();
-207
View File
@@ -1,207 +0,0 @@
/**
* FocusGram Ghost Mode
* Injected at DOCUMENT_START before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals.
*/
(function () {
'use strict';
// ─── Seen API patterns ────────────────────────────────────────────────────
const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//,
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
];
// ─── Activity patterns (like, comment) — intercepted for local history ────
const ACTIVITY_PATTERNS = [
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
/\/api\/v1\/web\/comments\/add\//,
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
];
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
const fakeOkResponse = () =>
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// ─── Fetch override ───────────────────────────────────────────────────────
const _fetch = window.fetch.bind(window);
const patchedFetch = async function (input, init) {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Block seen
if (isSeen(url)) {
if (window.GhostChannel) {
window.GhostChannel.postMessage(
JSON.stringify({ type: 'seen_blocked', url })
);
}
return fakeOkResponse();
}
// Intercept activity for local history
if (isActivity(url) && window.ActivityChannel) {
const body = init?.body;
const bodyText =
body instanceof URLSearchParams
? body.toString()
: typeof body === 'string'
? body
: '';
window.ActivityChannel.postMessage(
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
);
}
return _fetch(input, init);
};
// Disguise as native
Object.defineProperty(window, 'fetch', {
value: patchedFetch,
writable: true,
configurable: true,
enumerable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
window.fetch[Symbol.toStringTag] = 'fetch';
// ─── XMLHttpRequest override ──────────────────────────────────────────────
const _XHROpen = XMLHttpRequest.prototype.open;
const _XHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._fg_url = url ?? '';
this._fg_method = (method ?? '').toUpperCase();
return _XHROpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function (body) {
if (this._fg_url && isSeen(this._fg_url)) {
// Fire readyState 4 with fake success without actually sending
const self = this;
setTimeout(() => {
Object.defineProperty(self, 'readyState', { get: () => 4 });
Object.defineProperty(self, 'status', { get: () => 200 });
Object.defineProperty(self, 'responseText', {
get: () => '{"status":"ok"}',
});
Object.defineProperty(self, 'response', {
get: () => '{"status":"ok"}',
});
self.dispatchEvent(new Event('readystatechange'));
self.dispatchEvent(new Event('load'));
}, 10);
return;
}
return _XHRSend.call(this, body);
};
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
// Strict WS URL blocking (ghost mode requirement)
// sid/cid vary per user/chat; block by endpoint prefix, not exact query.
const isBlockedWssUrl = (u) => {
if (!u) return false;
const urlStr = String(u);
return (
urlStr.startsWith('wss://gateway.instagram.com/ws/streamcontroller') ||
urlStr.startsWith('wss://edge-chat.instagram.com/chat?sid=')
);
};
// Signal to other injected scripts that ghost-mode is active
window.__fgGhostModeActive = true;
const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) {
const urlStr = typeof url === 'string' ? url : url?.toString?.() ?? '';
// If the WebSocket URL is one of the blocked endpoints, return an inert WS-like object
if (isBlockedWssUrl(urlStr)) {
return {
send: () => {},
close: () => {},
readyState: 1,
addEventListener: () => {},
removeEventListener: () => {},
};
}
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
const _send = ws.send.bind(ws);
ws.send = function (data) {
if (typeof data === 'string') {
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
try {
const parsed = JSON.parse(data);
if (
parsed?.op === '4' ||
parsed?.op === 'seen' ||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
) {
return; // drop
}
} catch (_) {}
// Text-based seen signal check
if (data.includes('"seen"') && data.includes('"thread_id"')) {
return;
}
}
return _send(data);
};
return ws;
}
// Preserve WebSocket prototype chain so IG's ws checks pass
PatchedWebSocket.prototype = _WS.prototype;
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
PatchedWebSocket.OPEN = _WS.OPEN;
PatchedWebSocket.CLOSING = _WS.CLOSING;
PatchedWebSocket.CLOSED = _WS.CLOSED;
window.WebSocket = PatchedWebSocket;
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
// Only applied if user enables online-status hiding
// Wrapped in a named fn so Flutter can call it:
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
window.__fgEnableOnlineHide = function () {
Object.defineProperty(document, 'visibilityState', {
get: () => 'hidden',
configurable: true,
});
Object.defineProperty(document, 'hidden', {
get: () => true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
};
window.__fgDisableOnlineHide = function () {
// Restore by deleting the overrides (falls back to native getter)
delete document.visibilityState;
delete document.hidden;
document.dispatchEvent(new Event('visibilitychange'));
};
// Signal to Flutter that ghost mode JS is active
if (window.GhostChannel) {
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
-89
View File
@@ -1,89 +0,0 @@
/**
* 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 });
})();
-63
View File
@@ -1,63 +0,0 @@
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 (_) {}
},
);
}
-166
View File
@@ -1,166 +0,0 @@
/**
* 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' }));
}
})();
-233
View File
@@ -1,233 +0,0 @@
/**
* 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;
})();
-179
View File
@@ -1,179 +0,0 @@
/**
* 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' }));
}
})();
-283
View File
@@ -1,283 +0,0 @@
// 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');
})();
""";
-233
View File
@@ -1,233 +0,0 @@
// 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}
}''';
}
}
-201
View File
@@ -1,201 +0,0 @@
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,
),
],
);
}
}
-58
View File
@@ -1,58 +0,0 @@
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),
),
);
}
}
-230
View File
@@ -1,230 +0,0 @@
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;
}
}
}
-87
View File
@@ -1,87 +0,0 @@
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,
),
];
}
-80
View File
@@ -1,80 +0,0 @@
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;
}
}
-89
View File
@@ -1,89 +0,0 @@
/**
* 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 });
})();
-117
View File
@@ -1,117 +0,0 @@
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'},
);
}