11 Commits

Author SHA1 Message Date
Ujwal223 f1bd12f0bd V2 Release 2026-05-25 22:58:54 +05:45
Ujwal223 7d13ad64f1 Refactor GitHub Actions workflow and add scripts for versioning and release notes extraction 2026-05-25 22:53:53 +05:45
Ujwal Chapagain 5f86441675 Update GitHub Actions to use latest versions 2026-05-25 22:39:48 +05:45
Ujwal223 842dc70829 V2 Release 2026-05-25 22:12:38 +05:45
Ujwal223 2d33dcb889 Progress SAve- downloader,blur,ghost mode(Partially) works 2026-05-25 18:00:57 +05:45
Ujwal223 4f63e784ac JUst SAving Progress, i might fuck up 2026-05-23 11:56:23 +05:45
Ujwal Chapagain a504c51ac5 fix shields.io badge "invalid" 2026-03-05 11:02:22 +05:45
Ujwal Chapagain 5fafb9f142 Update README.md 2026-03-05 06:55:40 +05:45
Ujwal Chapagain d2a0294ab3 Removed Few things from ReadME as they were mentioned to be included for upcoming release. 2026-03-04 18:24:34 +05:45
Ujwal Chapagain 2e3823cdf7 Add Buy Me MoMo in Readme 2026-03-04 17:59:51 +05:45
Ujwal 7bb472d212 What's new
- Reordered Settings Page.
- Added "Click to Unblur" for posts.
- Added Persistent Notification
- Improved Grayscale Scheduling.
and more.
2026-03-04 10:48:14 +05:45
103 changed files with 15562 additions and 3219 deletions
+10
View File
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,8 @@
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))
+95
View File
@@ -0,0 +1,95 @@
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 }}
+8 -1
View File
@@ -12,6 +12,11 @@
.swiftpm/
migrate_working_dir/
PRD.md
.agents/
TODO.md
v2/FOCUSGRAM_V2_PLAN.md
v2/FocusGram_Feed_Filtering_Reference.docx
# IntelliJ related
*.iml
@@ -23,9 +28,9 @@ PRD.md
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
RELEASE_GUIDE.md
android/key.properties
android/app/*.jks
upload-keystore.jks
# Flutter/Dart/Pub related
**/doc/api/
@@ -47,3 +52,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
.flutter/
+23
View File
@@ -0,0 +1,23 @@
## FocusGram 2.0.0
### What's new
- NEW: Added Media Downloader for downloading images and videos
- NEW: Added Ghost Mode
- NEW: Added a toggle for scroll lock in minimal mode
- NEW: Added Option to Choose Duration of Mindfulness Gate
- NEW: Added ability to customize number of words in typing challenge
- UPDATED: Redesigned Focus Control Flyout
- UPDATED: Settings and Reordered items
- UPDATED: Added more time Choices for reels session
- UPDATED: Improved Permission Request invocation in onboarding page.
- UPDATED: Improved Notification Alerts
### Bug fixes
- Fixed: back button on homepage didnt exit the app.
- Fixed: Only First image of multiple imaged posts was blurred.
- FIxed: Couldn't scroll the home feed after enabling minimal mode
- Perfomance Optimizations
- A lof of other Minor fixes .
-220
View File
@@ -1,220 +0,0 @@
# FocusGram — Product Requirements Document (Canvas)
> Working title: **FocusGram**
---
## Product Type
Personal-use Flutter mobile application (Android). WebView wrapper around Instagram for private, distraction-free use.
## Primary Goal
Allow full use of Instagram (feed, stories, notes, DMs, profile) **without Reels or Explore distractions**, while preserving the ability to open a Reel **only when it is sent directly in a message**. Reels must not be discoverable anywhere else in the app.
---
## 1. Problem Statement
Instagram's Reels and Explore experiences create compulsive, endlessscroll behaviours. The user wants full functionality of Instagram *except* persistent exposure to Reels and similar autoplay distractions. Reels may be accessed intentionally and in a controlled way (session/time/cooldown), and Reels opened from DMs must not allow the user to scroll into other Reels.
---
## 2. Core Features (MVP + Integrated Phase 2)
These include all Phase 2 items integrated into the main product.
### 2.1 Embedded Instagram
* WebView loads `https://www.instagram.com`
* JavaScript enabled
* Custom user-agent to reduce login friction
* Cookie/session persistence stored locally
### 2.2 Global Reel & Explore Blocking (always-on)
* Remove/hide the Reels tab, Explore tab, and any UI element that reveals Reels elsewhere (profile grid toggles that surface Reels, Explore cards, thumbnails linking to `/reel/`).
* Block navigation to any URL containing `/reel/` or `/reels` unless in an active Reel session or when specifically opening a Reel message item.
* Inject a persistent CSS style (`hide-reels-style`) + MutationObserver to remove dynamic elements injected by Instagram's SPA.
### 2.3 DMReel Exception (oneoff, isolated playback)
* If a Reel URL is received via Direct Message (DM) and the user taps it in the message thread, the app will allow opening that single Reel in an isolated player overlay.
* The isolated player must:
* Load only the single Reel content (not the Reels feed).
* Disable gestures/controls that would navigate to other Reels (no left/right swipe to next Reel).
* Provide explicit controls: Play/Pause, Close, Share (if desired).
* Respect session/time/cooldown and count viewing duration toward limits.
### 2.4 Session & Daily Controls (customizable)
Provide settings and enforcement for controlled Reel consumption:
**Settings**
* Daily Total Reel Time (configurable, e.g., 0120 minutes)
* PerSession Reel Time Limit (configurable, e.g., 130 minutes)
* Session Cooldown Time (configurable, e.g., 5180 minutes) — the minimum wait between sessions
* Session Shortcuts (preset buttons: 1, 5, 10, 15 minutes)
**Behavior/Enforcement**
* A session may be started by user explicitly (via FAB or DM Reel tap when allowed).
* When a session starts, a countdown runs; when it reaches zero, the session ends and Reels are blocked again.
* All viewing time (including DMopened Reel play) counts toward the daily total.
* If daily total is exhausted, Reel sessions are blocked until midnight local device time, or until user increases limit in settings.
* Cooldown prevents immediately starting a new session until cooldown expires. The cooldown may be overridden only by changing settings (confirmed by an intentional action) — optional: require PIN to override.
### 2.5 Additional Controls & UX
* Quick status indicator in app chrome showing: `Reels: blocked` / `Reels: session active (mm:ss left)` / `Daily left: XX min`.
* Modal Reel Session UI: when enabling a session, present a small modal confirming session length, remaining daily minutes, and cooldown on completion.
* Option to blur Reels instead of hide (toggle in settings) — still blocks navigation but visually indicates presence.
* Option for longpress unlock: user must longpress the Reel Session button for 2 seconds to start a session (reduces impulsive enabling).
---
## 3. NonGoals
* No public distribution via Play Store (personal use only)
* No scraping or automated interactions with Instagram
* No use of private Instagram APIs
* Not attempting to permanently alter Instagram servers or content
---
## 4. Functional Requirements (detailed)
| ID | Requirement | Priority |
| -- | ----------------------------------------------------------------------------------------- | -------- |
| F1 | Load Instagram in WebView with persistent session | High |
| F2 | Inject/maintain CSS to hide Reels/Explore everywhere | High |
| F3 | Block navigation to `/reel` URLs globally unless ephemeral session or DM singleReel open | High |
| F4 | Allow singleReel open from DM in isolated player (no swipe to other reels) | High |
| F5 | Provide UI to start a Reel session limited by persession and daily settings | High |
| F6 | Enforce session cooldowns between sessions | High |
| F7 | Track and persist daily usage and session history locally | High |
| F8 | Provide override/change settings with explicit confirm (optional PIN) | Medium |
| F9 | Provide visual feedback and counters on main UI | High |
---
## 5. Technical Architecture
### Framework & Libraries
* Flutter (stable)
* `webview_flutter` for WebView
* `shared_preferences` for local persistence
* `intl` for date handling and resets
* Optional: `flutter_local_notifications` for session reminders/cooldown completion
### High-level Components
* **MainWebViewPage** — fullscreen WebView + top status bar + FAB for Reel Session
* **InjectionController** — handles JS/CSS injection, MutationObserver lifecycle, and reapply logic
* **NavigationGuard** — intercepts navigation requests and blocks `/reel` URLs when necessary
* **ReelPlayerOverlay** — isolated player used only for opening Reel from DM (no swiping)
* **SessionManager** — enforces persession timer, daily totals, cooldowns, and persistence
* **Settings** — UI for user to configure daily limit, session length, cooldown, blur/hide toggle
### JS/CSS Injection Patterns (examples)
* Insert a single `style` element with id `hide-reels-style` containing selectors for `href*="/reel"`, `href*="/reels"`, Reels tab anchors and Explore cards.
* MutationObserver that removes or hides any dynamically added nodes matching those selectors.
* Example-safe selectors: `a[href*="/reel"], a[href*="/reels"], nav a[href*="/reels"], [role="button"] [aria-label*="Reels"]`.
---
## 6. UX / Wireframes (textual)
**Main screen**
* WebView occupying most of the screen
* Top compact status bar: `Reels: Blocked • Daily left: 45m` (tappable to open Session modal)
* Floating Action Button (FAB) bottom-right: play icon — opens Reel Session modal
**Session modal**
* Presets: 1 / 5 / 10 / 15 minutes
* Input to set custom minutes
* Show `Daily left: X min` and `Cooldown: Y min remaining` if applicable
* Confirm button: `Start Session`
**DM Reel tap flow**
* User taps Reel link in DM
* If session active and daily left > 0 → open in ReelPlayerOverlay
* If session inactive → show small prompt: `Open this Reel? This will start a 5minute session (or choose length).` Confirm to open; counts toward session & daily totals.
**End of session**
* Overlay message: `Session ended. Reels are blocked.` with cooldown timer
* Option to extend session (only if daily minutes available and cooldown rules allow)
---
## 7. Data Model & Persistence
Stored locally via `shared_preferences` (or a small local DB if desired):
* `dailyDate` (YYYY-MM-DD) — date of last reset
* `dailyUsedMinutes` (int)
* `sessionActive` (bool) + `sessionExpiryTimestamp` (ms)
* `lastSessionEndTimestamp` (ms)
* `settings`: { dailyLimitMinutes, defaultSessionMinutes, cooldownMinutes, blurInsteadOfHide, requireLongPress }
* `sessionHistory[]` (timestamp, duration) — optional, capped locally
Reset logic: check `dailyDate` on app start / resume; if different from local device date, reset `dailyUsedMinutes` to 0 and update `dailyDate`.
---
## 8. Edge Cases & Rules
* If a Reel message contains a playlist or multiple reels link, block additional navigation — only allow the primary Reel to load.
* If Instagram tries to redirect from a DM Reel link into the Reels feed, intercept and force load of the single Reel content in `ReelPlayerOverlay`.
* If login prompts or security interstitials appear in WebView (2FA / suspicious login), surface them to the user; do not attempt to automate.
* If DOM selectors fail (Instagram update), fall back to broader `href*` checks and reapply; show a small banner to the user: `Reel blocker needs update` with troubleshooting.
---
## 9. Success Criteria
* Reels are not visible anywhere by default (tabs, explore, profile toggles)
* Tapping a Reel sent in DM opens only that Reel and does not allow navigating to others
* Session limits and daily totals are enforced reliably — user cannot bypass session/cooldown without changing settings and confirming
* UX is intuitive: starting/stopping sessions, seeing remaining time, and cooldowns are clear
---
## 10. Definition of Done
* App loads Instagram and preserves login across restarts
* Injected CSS/JS reliably hides Reels and blocks `/reel` navigation
* DMopened Reel flow works as isolated playback with no swipe navigation to other reels
* Session start/stop, daily enforcement, and cooldown behavior function and persist
* Settings screen implemented and persisting user preferences
---
## 11. Next Steps (recommended)
1. Create minimal Flutter skeleton with `webview_flutter` and SessionManager stub
2. Implement CSS/JS injection and test with local device Instagram login
3. Implement NavigationGuard and ReelPlayerOverlay
4. Add Settings and persistence
5. Test DM Reel flows thoroughly (multiple DM formats, external links)
6. Iterate selectors if Instagram DOM changes
---
## 12. Notes & Considerations
* This is for personal use only. Avoid publishing or distributing a wrapper app.
* Instagram may change behaviours; expect occasional maintenance.
* Consider adding a simple debug UI (visible only in dev builds) to reapply selectors and show blocked navigation attempts.
---
*End of PRD.*
+128 -38
View File
@@ -1,83 +1,173 @@
<div align="center">
<img src="assets/images/focusgram.png" alt="FocusGram" width="96" height="96" />
# FocusGram
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL%203.0-blue.svg)](LICENSE)
[![Flutter](https://img.shields.io/badge/Flutter-stable-blue?logo=flutter)](https://flutter.dev)
[![GitHub Downloads](https://img.shields.io/github/downloads/Ujwal223/FocusGram/total?label=total%20installs&color=blue)](https://github.com/Ujwal223/FocusGram/releases)
**Use social media on your terms.**
**Take back your time.** FocusGram is a distraction-free client for Instagram on Android that hides Reels and Explore, so you can stay connected without getting lost in the scroll.
[![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)
[![Downloads](https://img.shields.io/github/downloads/ujwal223/focusgram/total?label=downloads&color=blue&cacheSeconds=30)](https://github.com/ujwal223/focusgram/releases)
[🌟 Star on GitHub](https://github.com/Ujwal223/FocusGram) | [📥 Download Latest APK](https://github.com/Ujwal223/FocusGram/releases)
<a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram'>
<img src='https://stc.utdstc.com/img/mediakit/download-gio-small.png' alt='Download FocusGram 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>
---
## Why FocusGram?
Most people don't want to quit Instagram. They want to check their messages, post a story, and leave — without losing an hour to Reels they never meant to watch.
Most people don't want to delete Instagram entirely—they just want to stop wasting hours on Reels. FocusGram surgically removes the parts of Instagram designed for compulsive scrolling, while keeping your feed, stories, and DMs fully functional.
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.
### Key Benefits
- **Mental Health**: Stop the dopamine loop of endless autoplay videos.
- **Productivity**: Open Instagram to check a message or post a story, and get out in seconds.
- **Privacy**: No tracking, no analytics, and no third-party SDKs. Your data stays on your device.
> 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" />
---
## Master Your Usage
## What it does
FocusGram doesn't just block Reels—it gives you tools to build better habits:
**Focus tools**
- **Controlled Reel Sessions**: Need to watch a Reel? Start a timed session (1 to 15 minutes). When the time is up, they're blocked again.
- **Daily Limits**: Set a maximum amount of Reel time per day.
- **Habit-Building Cooldowns**: Enforce a mandatory break between sessions to prevent bingeing.
- 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
- Minimal Mode — strips everything down to Feed and DMs
**Content filtering**
- Hide the Explore tab, Reels tab, or Shop tab individually
- Disable Explore and blur posts entirely
- Disable Reels entirely
**Habit tools**
- Screen Time Dashboard — daily usage, 7-day chart, weekly average
- Grayscale Mode — reduces the visual pull of colour; can be scheduled by time of day
- Session intentions — optionally set a reason before opening the app
**The app itself**
- Feels (almost) like a native app, not a browser
- No blank loading screen — content loads in the background before you get there
- Instant updates via pull-to-refresh
- Dark mode follows your system
---
## Installation
### 1. From GitHub (Current)
1. Go to the [Releases](https://github.com/Ujwal223/FocusGram/releases) page.
2. Download the `focusgram-release.apk`.
3. Open the file on your phone and allow "Install from unknown sources" if prompted.
### Direct download
1. Go to the [Releases](https://github.com/ujwal223/focusgram/releases) page
2. Download `focusgram-release.apk`
3. Open the file and allow "Install from unknown sources" if prompted
### 2. From F-Droid (Soon)
We are currently in the process of submitting FocusGram to the F-Droid store for easier updates.
### Uptodown
1. Go to the [FocusGram on Uptodown](https://focusgram.en.uptodown.com/android) page
2. Click "Get the Latest Version"
3. Click "Download"
4. Open the file and allow "Install from unknown sources" if prompted
---
## Frequently Asked Questions
## Privacy
**Is my login safe?**
Yes. FocusGram uses a standard system WebView. Your credentials go directly to Instagram/Meta's servers, just like in a mobile browser. We do not (and cannot) see your password.
FocusGram has no access to your Instagram account credentials. It loads `instagram.com` inside a standard Android WebView — your login goes directly to Meta's servers, the same as any mobile browser.
- No analytics
- No crash reporting
- No third-party SDKs
- No data leaves your device
---
## Frequently asked questions
**Will this get my account banned?**
Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials.
**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.
**Why is it free?**
FocusGram is Open Source software created by [Ujwal Chapagain](https://github.com/Ujwal223). It is built for everyone who wants a healthier relationship with social media.
Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](https://github.com/Ujwal223) and released under AGPL-3.0.
---
## Development & Technical Details
## Building from source
<details>
<summary>View Technical Info</summary>
<summary>Technical details and build instructions</summary>
### Build from Source
### Requirements
- Flutter stable channel (3.38+)
- 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
```bash
flutter pub get
flutter build apk --release
```
### Permissions
- `INTERNET`: To load Instagram.
- `RECEIVE_BOOT_COMPLETED`: To keep your session timers and notifications accurate after a restart.
### Architecture
FocusGram uses a standard Android System WebView to load `instagram.com`. All features are implemented client-side via:
- JavaScript injection (autoplay blocking, metadata extraction, SPA navigation monitoring)
- CSS injection (element hiding, grayscale, scroll behaviour)
- URL interception via NavigationDelegate (Reels blocking, Explore blocking)
### Permissions
| Permission | Reason |
|---|---|
| `INTERNET` | Load instagram.com |
| `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
| | |
|---|---|
| Framework | Flutter (Dart) |
| WebView | flutter_inappwebview (Apache 2.0) |
| Storage | shared_preferences |
| License | AGPL-3.0 |
### Tech Stack
- **Framework**: Flutter (Dart)
- **Engine**: webview_flutter
- **License**: AGPL-3.0 (Affero General Public License)
</details>
---
## Legal disclaimer
FocusGram is an independent, free, and open-source productivity tool licensed under AGPL-3.0. It is not affiliated with, endorsed by, or associated with Meta Platforms, Inc. or Instagram in any way.
**How it works:** FocusGram embeds a standard Android System WebView that loads `instagram.com` — the same website accessible in any mobile browser. All user-facing features are implemented exclusively via client-side modifications and are never transmitted to or processed by Meta's servers.
**What we do not do:**
- Use Instagram's or Meta's private APIs
- Intercept, read, log, or store user credentials, session data, or any content
- Modify any server-side Meta or Instagram services
- Scrape, harvest, or collect any user data
- Claim ownership of any Meta or Instagram trademarks, logos, or intellectual property — any branding visible within the app is served directly from `instagram.com` and remains the property of Meta Platforms, Inc.
Using FocusGram is functionally equivalent to accessing Instagram through a mobile web browser with a content blocker extension. By using FocusGram, you acknowledge that you remain bound by Instagram's own Terms of Service.
For legal concerns, contact `notujwal@proton.me` before taking any other action.
---
## License
Copyright (C) 2025 Ujwal Chapagain
Copyright © 2025 Ujwal Chapagain
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
Licensed under the [GNU Affero General Public License v3.0](LICENSE). You are free to use, modify, and distribute this software under the same terms.
+4
View File
@@ -9,6 +9,10 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- v2/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
+2
View File
@@ -12,3 +12,5 @@ GeneratedPluginRegistrant.java
key.properties
**/*.keystore
**/*.jks
upload-keystore.jks
+190
View File
@@ -0,0 +1,190 @@
Copyright (c) 2005-2014, The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+14 -5
View File
@@ -10,7 +10,7 @@ plugins {
android {
namespace = "com.ujwal.focusgram"
compileSdk = flutter.compileSdkVersion
buildToolsVersion = "34.0.0"
buildToolsVersion = "35.0.0"
ndkVersion = flutter.ndkVersion
val keystorePropertiesFile = rootProject.file("key.properties")
@@ -42,10 +42,10 @@ android {
applicationId = "com.ujwal.focusgram"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
minSdk = 24
targetSdk = 35
versionCode = 4
versionName = "2.0.0"
}
buildTypes {
@@ -62,6 +62,15 @@ android {
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
configurations.all {
exclude(group = "com.google.android.gms")
exclude(group = "com.google.firebase")
exclude(group = "com.google.android.datatransport")
exclude(group = "com.google.android.play")
exclude(group = "com.google.android.play", module = "core")
exclude(group = "com.google.android.play", module = "core-common")
}
}
dependencies {
+13 -4
View File
@@ -1,3 +1,16 @@
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
-keep class com.pichillilorenzo.flutter_inappwebview.** { *; }
-keep class **.GeneratedPluginRegistrant { *; }
# Strip Google Play Core (Flutter engine bundles these unnecessarily for F-Droid)
-dontwarn com.google.android.play.core.**
-dontwarn com.google.android.play.core.splitinstall.**
-dontwarn com.google.android.play.core.tasks.**
-assumenosideeffects class com.google.android.play.core.** { *; }
-assumenosideeffects class com.google.android.play.core.splitinstall.** { *; }
-assumenosideeffects class com.google.android.play.core.tasks.** { *; }
# Flutter Wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
@@ -17,10 +30,6 @@
# Keystore and common
-keep class com.ujwal.focusgram.** { *; }
# Flutter Play Store Split (ignore optional references)
-dontwarn com.google.android.play.core.**
-dontwarn com.google.android.gms.common.**
# Avoid stripping JS bridge names
-keepattributes JavascriptInterface
-keepclassmembers class * {
+8
View File
@@ -1,11 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="FocusGram"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:hardwareAccelerated="true"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config">
@@ -50,6 +52,12 @@
</activity>
<!-- Flutter tool meta-data -->
<!-- Disable Impeller — causes blank/noisy WebView on some Samsung devices -->
<!-- Flutter issue #162439. Remove when Flutter fixes this. -->
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
File diff suppressed because one or more lines are too long
+61
View File
@@ -0,0 +1,61 @@
/**
* FocusGram DOM Ad Blocker (Fallback)
*
* DEPRECATED: Use fetch_interceptor.js for reliable ad blocking.
*
* This script provides DOM-based ad removal as a FALLBACK for ads that slip through
* GraphQL filtering. It's not reliable because Instagram has already rendered the content.
*
* Injected at DOCUMENT_END
* Removes sponsored/posts/tracking elements from the DOM.
*/
(function () {
'use strict';
const AD_SIGNALS = [
'Sponsored',
'paid partnership',
'Promoted',
];
const textMatchesSignal = (txt) => {
if (!txt) return false;
const t = txt.trim().toLowerCase();
return AD_SIGNALS.some((s) => t === s.toLowerCase());
};
const removeSponsoredArticles = () => {
try {
// aria-label routes (best-effort; localization may break)
document.querySelectorAll('a[aria-label]').forEach((a) => {
const aria = a.getAttribute('aria-label') || '';
if (textMatchesSignal(aria)) {
const article = a.closest('article');
if (article) article.remove();
}
});
// Text-based removal inside feed articles (best-effort)
document.querySelectorAll('article').forEach((article) => {
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const txt = node.nodeValue;
if (textMatchesSignal(txt)) {
article.remove();
break;
}
}
});
// Suggested content is intentionally left alone. Removing suggested
// units after Instagram has virtualized the feed can snap the viewport
// back to the top on some accounts.
} catch (_) {}
};
const observer = new MutationObserver(() => removeSponsoredArticles());
observer.observe(document.body, { childList: true, subtree: true });
removeSponsoredArticles();
})();
+129
View File
@@ -0,0 +1,129 @@
/**
* FocusGram Autoplay Blocker
* Injected at DOCUMENT_START — before Instagram's JS loads.
* Prevents video autoplay by:
* 1. Blocking play() calls on video elements
* 2. Disabling autoplay attribute
* 3. Removing preload attributes
*/
(function () {
'use strict';
// This script is only registered when the setting is enabled, so default ON.
window.__fgBlockAutoplay = typeof window.__fgBlockAutoplay === 'boolean'
? window.__fgBlockAutoplay : true;
const ALLOW_KEY = '__fgUserStartedPlayback';
let userGestureUntil = 0;
function isReelRoute() {
const path = window.location.pathname || '';
return path.indexOf('/reel/') >= 0 || path === '/reels' || path.indexOf('/reels/') >= 0;
}
function isUserGestureActive() {
return Date.now() < userGestureUntil;
}
function markUserGesture(target) {
userGestureUntil = Date.now() + 1200;
try {
let video = target && target.closest ? target.closest('video') : null;
if (!video && target && target.querySelector) video = target.querySelector('video');
if (video) video[ALLOW_KEY] = true;
} catch (_) {}
}
document.addEventListener('pointerdown', function (event) {
markUserGesture(event.target);
}, true);
document.addEventListener('touchstart', function (event) {
markUserGesture(event.target);
}, true);
document.addEventListener('click', function (event) {
markUserGesture(event.target);
}, true);
// Override HTMLMediaElement.play() to check our flag
const _play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () {
if (
window.__fgBlockAutoplay &&
!isReelRoute() &&
this[ALLOW_KEY] !== true &&
!isUserGestureActive()
) {
// Return a resolved promise to avoid breaking Instagram's code
try { this.pause(); } catch (_) {}
return Promise.resolve();
}
return _play.call(this);
};
// Override autoplay property setter
const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
const _originalAutoplaySetter = _videoDescriptor.set;
Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
set: function (value) {
if (window.__fgBlockAutoplay && value) {
// Silently ignore autoplay attempts when blocking is enabled
return;
}
if (_originalAutoplaySetter) {
_originalAutoplaySetter.call(this, value);
}
},
get: function () {
if (_videoDescriptor.get) {
return _videoDescriptor.get.call(this);
}
return this.getAttribute('autoplay') !== null;
},
enumerable: _videoDescriptor.enumerable,
configurable: true,
});
// On page load and SPA navigation, scan for video elements and remove autoplay
const removeAutoplayFromVideos = () => {
document.querySelectorAll('video, [role="video"]').forEach(el => {
if (window.__fgBlockAutoplay && !isReelRoute() && el[ALLOW_KEY] !== true) {
el.autoplay = false;
el.removeAttribute('autoplay');
el.removeAttribute('preload');
try { el.preload = 'none'; } catch (_) {}
if (el.paused === false) {
el.pause();
}
}
});
};
// Run on load and when document changes
removeAutoplayFromVideos();
if (!window.__fgAutoplayObserver) {
let _timer = null;
window.__fgAutoplayObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(removeAutoplayFromVideos, 500);
});
window.__fgAutoplayObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// Allow Flutter to toggle
window.__fgSetBlockAutoplay = function (enabled) {
window.__fgBlockAutoplay = !!enabled;
if (enabled) {
removeAutoplayFromVideos();
}
};
document.addEventListener('play', function (event) {
if (event.target && event.target.tagName === 'VIDEO' && isUserGestureActive()) {
event.target[ALLOW_KEY] = true;
}
}, true);
})();
+304
View File
@@ -0,0 +1,304 @@
/**
* FocusGram Content Hider
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
* Flutter controls via window.__fgContent.*
* Injected at DOCUMENT_END.
*
* Key fixes applied:
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
* - Stories tray detection strengthened for fresh SPA navigations
* - Suggested posts detection uses multiple text-node matching strategies
*/
(function () {
'use strict';
if (window.__fgContent && window.__fgContent.__focusgramReady) {
return;
}
const STYLE_ID = 'fg-content-hider';
let hideStories = false;
let hidePosts = false;
let hideSuggested = false;
let hideReels = false;
// ─── CSS rules ─────────────────────────────────────────────────────────────
function buildCSS() {
const selectors = [];
if (hideStories) {
selectors.push(
'[role="list"]:has([aria-label*="tory"])',
'[role="listbox"]:has([aria-label*="tory"])',
'[role="menu"] > ul',
'section > div > div:first-child [style*="overflow"]',
'[role="list"] [style*="overflow"]',
);
}
if (hidePosts) {
selectors.push(
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
);
}
// hideReels CSS is intentionally NOT added here.
// We use DOM removal instead (see removeReels()) so that room is never left
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
return selectors.length
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
: '';
}
function applyCSS() {
if (document.body) {
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
}
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = buildCSS();
}
// ─── Story tray JS ─────────────────────────────────────────────────────────
function hideStoryTray() {
if (!hideStories) return;
// Strategy 1: <ul> children of a named list or menu
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
try {
const items = ul.querySelectorAll('li, button, a');
if (items.length < 2) return;
ul.style.setProperty('display', 'none', 'important');
} catch (_) {}
});
// Strategy 2: horizontally scrolling container with circle items
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
try {
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
if (cands.length < 2) return;
const s0 = window.getComputedStyle(cands[0]);
if (s0.width && parseFloat(s0.width) <= 90) {
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
}
} catch (_) {}
});
}
// ─── Suggested posts ───────────────────────────────────────────────────────
function removeSuggested() {
if (!hideSuggested) return;
var SIGNALS = [
'suggested for you',
'suggested posts',
'suggested reels',
'suggested',
'because you watched',
'because you follow',
'you might like',
'posts you might like',
'accounts you might like',
'recommendations',
];
function norm(s) {
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function hasSignal(s) {
var t = norm(s);
if (!t) return false;
return SIGNALS.some(function (signal) {
if (signal === 'suggested') return t === signal;
return t.indexOf(signal) >= 0;
});
}
function hideContainer(from) {
var parent = from;
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
var role = parent.getAttribute && parent.getAttribute('role');
var tag = parent.tagName;
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
if (
tag === 'ARTICLE' ||
tag === 'SECTION' ||
role === 'listitem' ||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
) {
parent.style.setProperty('display', 'none', 'important');
parent.setAttribute('data-fg-hidden-suggested', '1');
return true;
}
parent = parent.parentElement;
}
return false;
}
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
try {
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
var ownLabel = node.getAttribute('aria-label');
if (hasSignal(ownLabel)) { hideContainer(node); return; }
var text = norm(node.innerText || node.textContent || '');
if (
text.indexOf('suggested for you') >= 0 ||
text.indexOf('suggested posts') >= 0 ||
text.indexOf('suggested reels') >= 0 ||
text.indexOf('because you watched') >= 0 ||
text.indexOf('because you follow') >= 0
) {
hideContainer(node);
}
} catch (_) {}
});
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
try {
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
hideContainer(el);
}
} catch (_) {}
});
}
// ─── Reels DOM REMOVE (not display:none) ─────────────────────────────────
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
// gap cleanly and lets the feed flow naturally.
function removeReels() {
if (!hideReels) return;
var toRemove = [];
document.querySelectorAll('article').forEach(function (el) {
try {
// Fast path: check for a reel-signal attribute first
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
if (mt === '2') { toRemove.push(el); return; }
// Fallback: text-node scan for /reels/ markers
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var n;
while ((n = walker.nextNode())) {
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
toRemove.push(el); break;
}
}
} catch (_) {}
});
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
}
// ─── Public API ────────────────────────────────────────────────────────────
window.__fgContent = {
__focusgramReady: true,
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
setHideSuggested: function (val) {
hideSuggested = !!val;
applyCSS();
if (val) removeSuggested();
},
setHideReels: function (val) {
hideReels = !!val;
applyCSS();
if (val) removeReels();
},
applyAll: function (flags) {
hideStories = !!flags.stories;
hidePosts = !!flags.posts;
hideReels = !!flags.reels;
hideSuggested = !!flags.suggested;
applyCSS();
if (hideSuggested) removeSuggested();
if (hideStories) hideStoryTray();
if (hideReels) removeReels();
},
};
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
// pushState/replaceState don't fire any DOM event we can listen for.
// Hook the methods themselves so we know a navigation happened, then debounce
// re-apply. This also catches the case where the MutationObserver was on `body`
// and that node got replaced by Instagram's SPA re-render.
function scheduleReapply() {
clearTimeout(window.__fg_applyTimer);
window.__fg_applyTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 250);
}
var _origPush = history.pushState;
var _origReplace = history.replaceState;
history.pushState = function () {
_origPush.apply(this, arguments);
scheduleReapply();
};
history.replaceState = function () {
_origReplace.apply(this, arguments);
scheduleReapply();
};
// Reinforce on popstate too (user hits back/forward)
window.addEventListener('popstate', scheduleReapply, { passive: true });
// For pushState on the same URL (rare but possible) poll path briefly
window.addEventListener('pageshow', scheduleReapply, { passive: true });
window.addEventListener('focus', scheduleReapply, { passive: true });
// ─── MutationObserver ───────────────────────────────────────────────────────
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
// re-applies everything on each cycle. Does NOT guard on a per-element timer
// that would never re-fire after the body is replaced by SPA re-render.
if (!window.__fgContentObserver) {
window.__fgContentObserver = new MutationObserver(function () {
clearTimeout(window.__fg_moTimer);
window.__fg_moTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 300);
});
// `document.documentElement` survives SPA navigations (body gets replaced
// but <html> stays). Observing it catches both subtree mutations and, via
// the SPA heartbeat above, re-applies after pushState.
window.__fgContentObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// ─── Initial run ────────────────────────────────────────────────────────────
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
// Signal ready — Flutter will call applyAll() with stored prefs
if (window.ContentChannel) {
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+315
View File
@@ -0,0 +1,315 @@
/**
* FocusGram Unified Feed Filter via Fetch Interception
* Injected at DOCUMENT_START — before Instagram's JS loads.
*
* This script intercepts GraphQL fetch calls and filters feed content based on:
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
* - Sponsored posts (ad_action_link, ad_header_style)
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
* - Videos/Reels (is_video, media_type, clips_metadata)
* - Autoplay blocking (video autoplay prevention)
*/
(function () {
'use strict';
// Configuration flags (set by Flutter via prefs)
window.__fgFilterConfig = {
blockAds: false,
blockSponsored: false,
blockSuggested: false,
blockVideos: false,
blockAutoplay: false,
blockGraphQLQueryWhenFeedPosts: false,
};
const textHasAdSignal = (value) => {
const s = String(value || '').toLowerCase();
return (
s === 'sponsored' ||
s.includes('"sponsored"') ||
s.includes('paid partnership') ||
s.includes('promoted') ||
s.includes('ad_id') ||
s.includes('ad_tracking') ||
s.includes('sponsor_tags')
);
};
// Helper: Check if a node is an ad
const isAdNode = (node) => {
if (!node || typeof node !== 'object') return false;
const typename = String(node.__typename || '');
const adText = JSON.stringify({
organic_tracking_token: node.organic_tracking_token,
sponsor_tags: node.sponsor_tags,
social_context: node.social_context,
title: node.title,
header: node.header,
label: node.label,
overlay_text: node.overlay_text,
});
return !!(
node.is_ad ||
node.is_paid_partnership ||
node.sponsor_tags ||
node.ad_tracking_token ||
node.ad_action_link ||
node.ad_id ||
node.ad_impression_token ||
node.ad_metadata ||
node.commerciality_status === 'commercial' ||
(node.product_type && node.product_type === 'ad') ||
(node.ad_header_style && node.ad_header_style !== 'none') ||
typename === 'GraphAdStory' ||
typename.includes('Ad') ||
textHasAdSignal(adText)
);
};
// Helper: Check if a node is sponsored
const isSponsoredNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_paid_partnership ||
node.sponsor_tags ||
(node.ad_action_link && node.ad_action_link.href) ||
(node.ad_header_style && node.ad_header_style !== 'none') ||
textHasAdSignal(JSON.stringify(node.social_context || node.header || node.label || ''))
);
};
// Helper: Check if a node is suggested content
const isSuggestedNode = (node) => {
if (!node || typeof node !== 'object') return false;
const typename = String(node.__typename || '');
const reason = JSON.stringify({
reason: node.suggested_reason,
social_context: node.social_context,
title: node.title,
header: node.header,
label: node.label,
}).toLowerCase();
return !!(
node.is_suggested ||
node.is_suggested_for_you ||
node.is_recommendation ||
node.suggested_users ||
node.suggested_media ||
node.suggested_content ||
node.recommendation_source ||
typename.includes('Suggested') ||
typename.includes('Recommendation') ||
reason.includes('suggested') ||
reason.includes('recommend')
);
};
// Helper: Check if a node is a video/reel
const isVideoNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_video ||
(node.media_type === 2) ||
node.clips_metadata ||
(node.__typename && (
node.__typename.includes('Clips') ||
node.__typename.includes('Video')
))
);
};
const isFeedMediaNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.pk ||
node.id ||
node.code ||
node.media_type ||
node.image_versions2 ||
node.video_versions ||
node.carousel_media ||
node.__typename?.includes('Media') ||
node.__typename?.includes('Timeline')
);
};
// Helper: Check for media in carousel
const hasVideoInCarousel = (node) => {
if (!node || typeof node !== 'object') return false;
if (node.media_type === 8) {
const edges = node.edge_sidecar_to_children?.edges || [];
return edges.some(edge => isVideoNode(edge.node));
}
return false;
};
// Main filter function for feed nodes
const shouldFilterNode = (node) => {
const config = window.__fgFilterConfig;
if (!node || typeof node !== 'object') return false;
if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
return true;
}
// Check ads
if (config.blockAds && isAdNode(node)) {
return true;
}
// Check sponsored (separate from ads)
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
return true;
}
// Check suggested content
if (config.blockSuggested && isSuggestedNode(node)) {
return true;
}
// Check videos/reels
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
return true;
}
return false;
};
// Recursively filter GraphQL response edges
const filterEdges = (edges, path = []) => {
if (!Array.isArray(edges)) return edges;
return edges.filter(edge => {
if (!edge || !edge.node) return true;
const node = edge.node;
// Keep the edge if it doesn't match any filter
if (!shouldFilterNode(node)) return true;
// Log filtered content for debugging
if (window.__fgDebugFilter) {
const type = node.__typename || 'Unknown';
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
}
return false;
});
};
// Recursively walk GraphQL response and filter edges
const walkAndFilter = (obj, visited = new Set()) => {
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
visited.add(obj);
// Handle arrays
if (Array.isArray(obj)) {
obj.forEach(item => walkAndFilter(item, visited));
return;
}
// Check for edges array (common GraphQL pattern)
if (obj.edges && Array.isArray(obj.edges)) {
obj.edges = filterEdges(obj.edges);
}
// Recurse into children
for (const key in obj) {
if (obj.hasOwnProperty(key) && key !== '__typename') {
const val = obj[key];
if (val && typeof val === 'object') {
walkAndFilter(val, visited);
}
}
}
};
// Override fetch
const _fetch = window.fetch.bind(window);
window.fetch = async function (input, init) {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Call original fetch
let response = await _fetch(input, init);
// Only intercept GraphQL feed queries
if (!url.includes('/graphql') && !url.includes('/api/v1/feed')) {
return response;
}
// Clone response to read body
const cloned = response.clone();
try {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return response;
}
const data = await cloned.json();
// Filter the response data
walkAndFilter(data);
// Return modified response
return new Response(JSON.stringify(data), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} catch (e) {
// On error, return original response
return response;
}
};
// Preserve native function appearance
Object.defineProperty(window, 'fetch', {
value: window.fetch,
writable: true,
configurable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
const _xhrOpen = XMLHttpRequest.prototype.open;
const _xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__fgUrl = typeof url === 'string' ? url : String(url || '');
return _xhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
if (
window.__fgFilterConfig.blockVideos &&
this.__fgUrl &&
(this.__fgUrl.includes('/api/v1/clips/') ||
this.__fgUrl.includes('/api/v1/discover/'))
) {
try { this.abort(); } catch (_) {}
return;
}
return _xhrSend.apply(this, arguments);
};
// Allow Flutter to update config flags
window.__fgSetFilterConfig = function (config) {
if (typeof config === 'object') {
Object.assign(window.__fgFilterConfig, config);
if (window.__fgDebugFilter) {
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
}
}
};
// Enable debug logging
window.__fgDebugFilter = false;
})();
+179
View File
@@ -0,0 +1,179 @@
/**
* FocusGram Ghost Mode
* Injected at DOCUMENT_START — before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals.
*/
(function () {
'use strict';
// ─── Seen API patterns ────────────────────────────────────────────────────
const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//,
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
];
// ─── Activity patterns (like, comment) — intercepted for local history ────
const ACTIVITY_PATTERNS = [
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
/\/api\/v1\/web\/comments\/add\//,
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
];
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
const fakeOkResponse = () =>
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// ─── Fetch override ───────────────────────────────────────────────────────
const _fetch = window.fetch.bind(window);
const patchedFetch = async function (input, init) {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Block seen
if (isSeen(url)) {
if (window.GhostChannel) {
window.GhostChannel.postMessage(
JSON.stringify({ type: 'seen_blocked', url })
);
}
return fakeOkResponse();
}
// Intercept activity for local history
if (isActivity(url) && window.ActivityChannel) {
const body = init?.body;
const bodyText =
body instanceof URLSearchParams
? body.toString()
: typeof body === 'string'
? body
: '';
window.ActivityChannel.postMessage(
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
);
}
return _fetch(input, init);
};
// Disguise as native
Object.defineProperty(window, 'fetch', {
value: patchedFetch,
writable: true,
configurable: true,
enumerable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
window.fetch[Symbol.toStringTag] = 'fetch';
// ─── XMLHttpRequest override ──────────────────────────────────────────────
const _XHROpen = XMLHttpRequest.prototype.open;
const _XHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._fg_url = url ?? '';
this._fg_method = (method ?? '').toUpperCase();
return _XHROpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function (body) {
if (this._fg_url && isSeen(this._fg_url)) {
// Fire readyState 4 with fake success without actually sending
const self = this;
setTimeout(() => {
Object.defineProperty(self, 'readyState', { get: () => 4 });
Object.defineProperty(self, 'status', { get: () => 200 });
Object.defineProperty(self, 'responseText', {
get: () => '{"status":"ok"}',
});
Object.defineProperty(self, 'response', {
get: () => '{"status":"ok"}',
});
self.dispatchEvent(new Event('readystatechange'));
self.dispatchEvent(new Event('load'));
}, 10);
return;
}
return _XHRSend.call(this, body);
};
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) {
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
const _send = ws.send.bind(ws);
ws.send = function (data) {
if (typeof data === 'string') {
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
try {
const parsed = JSON.parse(data);
if (
parsed?.op === '4' ||
parsed?.op === 'seen' ||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
) {
return; // drop
}
} catch (_) {}
// Text-based seen signal check
if (data.includes('"seen"') && data.includes('"thread_id"')) {
return;
}
}
return _send(data);
};
return ws;
}
// Preserve WebSocket prototype chain so IG's ws checks pass
PatchedWebSocket.prototype = _WS.prototype;
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
PatchedWebSocket.OPEN = _WS.OPEN;
PatchedWebSocket.CLOSING = _WS.CLOSING;
PatchedWebSocket.CLOSED = _WS.CLOSED;
window.WebSocket = PatchedWebSocket;
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
// Only applied if user enables online-status hiding
// Wrapped in a named fn so Flutter can call it:
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
window.__fgEnableOnlineHide = function () {
Object.defineProperty(document, 'visibilityState', {
get: () => 'hidden',
configurable: true,
});
Object.defineProperty(document, 'hidden', {
get: () => true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
};
window.__fgDisableOnlineHide = function () {
// Restore by deleting the overrides (falls back to native getter)
delete document.visibilityState;
delete document.hidden;
document.dispatchEvent(new Event('visibilitychange'));
};
// Signal to Flutter that ghost mode JS is active
if (window.GhostChannel) {
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+47
View File
@@ -0,0 +1,47 @@
/**
* FocusGram Theme Detector
* Reads light/dark theme from page and bridges to Flutter.
* Injected at DOCUMENT_END.
*/
(function () {
'use strict';
(function fgThemeSync() {
if (window.__fgThemeSyncRunning) return;
window.__fgThemeSyncRunning = true;
function getTheme() {
try {
const h = document.documentElement;
if (h.classList.contains('style-dark')) return 'dark';
if (h.classList.contains('style-light')) return 'light';
const bg = window.getComputedStyle(document.body).backgroundColor;
const rgb = bg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const luminance =
(0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
return luminance < 0.5 ? 'dark' : 'light';
}
} catch (_) {}
return 'dark';
}
let last = '';
function check() {
const current = getTheme();
if (current !== last) {
last = current;
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'FocusGramThemeChannel',
current
);
}
}
}
setInterval(check, 1500);
check();
})();
})();
View File
+3
View File
@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
@@ -1,5 +0,0 @@
Initial open-source release of FocusGram.
- Complete Reels and Explore hiding.
- Timed Reel sessions and daily limits.
- Isolated DM Reel player.
- Privacy-first: No Firebase or trackers.
@@ -1,9 +0,0 @@
FocusGram is a distraction-free client for Instagram that allows you to use the core features—feed, stories, DMs, and profile—without getting stuck in the endless scroll of Reels and Explore.
Key Features:
- Adds blur in explore feeds and homepage posts.
- Blocks navigation to /reel/ URLs to prevent accidental distraction.
- Implement controlled Reel sessions with customizable time limits and daily totals.
- Enforces cooldown periods between sessions to build better habits.
- Privacy-focused: No ads, no tracking, and no proprietary SDKs.
- 100% Free and Open Source.
@@ -1 +0,0 @@
Distraction-free Instagram with controlled Reel access.
@@ -1 +0,0 @@
FocusGram
+532
View File
@@ -0,0 +1,532 @@
import 'package:flutter/material.dart';
enum SkeletonType { feed, reels, explore, messages, profile, generic }
class SkeletonScreen extends StatefulWidget {
final SkeletonType skeletonType;
const SkeletonScreen({super.key, this.skeletonType = SkeletonType.generic});
@override
State<SkeletonScreen> createState() => _SkeletonScreenState();
}
class _SkeletonScreenState extends State<SkeletonScreen>
with SingleTickerProviderStateMixin {
late AnimationController _shimmerController;
late Animation<double> _shimmerAnimation;
@override
void initState() {
super.initState();
_shimmerController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
)..repeat();
_shimmerAnimation = Tween<double>(
begin: -1.0,
end: 2.0,
).animate(_shimmerController);
}
@override
void dispose() {
_shimmerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseColor = theme.colorScheme.surfaceContainerHighest.withValues(
alpha: theme.brightness == Brightness.dark ? 0.25 : 0.4,
);
final highlightColor = theme.colorScheme.onSurface.withValues(alpha: 0.08);
return Container(
color: theme.scaffoldBackgroundColor,
width: double.infinity,
height: double.infinity,
child: AnimatedBuilder(
animation: _shimmerAnimation,
builder: (context, child) {
return ShaderMask(
shaderCallback: (rect) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [baseColor, highlightColor, baseColor],
stops: const [0.1, 0.3, 0.6],
transform: _SlidingGradientTransform(
slidePercent: _shimmerAnimation.value,
),
).createShader(rect);
},
blendMode: BlendMode.srcATop,
child: _buildSkeletonContent(context),
);
},
),
);
}
Widget _buildSkeletonContent(BuildContext context) {
switch (widget.skeletonType) {
case SkeletonType.feed:
return _buildFeedSkeleton(context);
case SkeletonType.reels:
return _buildReelsSkeleton(context);
case SkeletonType.explore:
return _buildExploreSkeleton(context);
case SkeletonType.messages:
return _buildMessagesSkeleton(context);
case SkeletonType.profile:
return _buildProfileSkeleton(context);
case SkeletonType.generic:
return _buildGenericSkeleton(context);
}
}
Widget _buildFeedSkeleton(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: 6,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
),
),
const SizedBox(height: 2),
Container(
width: 32,
height: 6,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(3),
),
),
],
),
),
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 0),
itemCount: 3,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(width: 8),
Container(
width: 80,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
Container(
width: double.infinity,
height: width,
color: Colors.white,
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: List.generate(
3,
(i) => Padding(
padding: const EdgeInsets.only(right: 16),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(
width: width * 0.7,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
);
},
),
),
],
);
}
Widget _buildReelsSkeleton(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 200,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 24),
Container(
width: 150,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 12),
Container(
width: 100,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
);
}
Widget _buildExploreSkeleton(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(2),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 15,
itemBuilder: (context, index) {
return Container(color: Colors.white);
},
),
);
}
Widget _buildMessagesSkeleton(BuildContext context) {
return ListView.builder(
itemCount: 8,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 14,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: 150,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
);
},
);
}
Widget _buildProfileSkeleton(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 24),
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(48),
),
),
const SizedBox(height: 16),
Container(
width: 120,
height: 16,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
3,
(index) => Column(
children: [
Container(
width: 40,
height: 20,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
width: 50,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
const SizedBox(height: 24),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: 9,
itemBuilder: (context, index) {
return Container(color: Colors.white);
},
),
],
),
);
}
Widget _buildGenericSkeleton(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: List.generate(
6,
(index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: Column(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
),
),
const SizedBox(height: 8),
Container(
width: 40,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
),
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: 3,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: width * 0.4,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: width * 0.25,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
height: width * 1.1,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(height: 12),
Container(
width: width * 0.7,
height: 10,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
Container(
width: width * 0.5,
height: 8,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
),
),
],
),
);
},
),
),
],
);
}
}
class _SlidingGradientTransform extends GradientTransform {
final double slidePercent;
const _SlidingGradientTransform({required this.slidePercent});
@override
Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0);
}
}
SkeletonType getSkeletonTypeFromUrl(String url) {
final parsed = Uri.tryParse(url);
if (parsed == null) return SkeletonType.generic;
final path = parsed.path.toLowerCase();
if (path.startsWith('/reels') || path.startsWith('/reel/')) {
return SkeletonType.reels;
} else if (path.startsWith('/explore')) {
return SkeletonType.explore;
} else if (path.startsWith('/messages') || path.startsWith('/inbox')) {
return SkeletonType.messages;
} else if (path.startsWith('/') && !path.startsWith('/accounts')) {
if (path.split('/').length <= 2) {
return SkeletonType.feed;
}
return SkeletonType.profile;
}
return SkeletonType.generic;
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
class NativeBottomNav extends StatelessWidget {
final String currentUrl;
final bool reelsEnabled;
final bool exploreEnabled;
final bool minimalMode;
final Function(String path) onNavigate;
const NativeBottomNav({
super.key,
required this.currentUrl,
required this.reelsEnabled,
required this.exploreEnabled,
required this.minimalMode,
required this.onNavigate,
});
String get _path {
final parsed = Uri.tryParse(currentUrl);
if (parsed != null && parsed.path.isNotEmpty) return parsed.path;
return currentUrl; // may already be a path from SPA callbacks
}
bool get _onHome => _path == '/' || _path.isEmpty;
bool get _onExplore => _path.startsWith('/explore');
bool get _onReels => _path.startsWith('/reels') || _path.startsWith('/reel/');
bool get _onProfile =>
_path.startsWith('/accounts') ||
_path.contains('/profile') ||
_path.split('/').where((p) => p.isNotEmpty).length == 1;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final bgColor = theme.colorScheme.surface.withValues(
alpha: isDark ? 0.95 : 0.98,
);
final iconColorInactive = isDark ? Colors.white70 : Colors.black54;
final iconColorActive = theme.colorScheme.primary;
final tabs = <_NavItem>[
_NavItem(
icon: Icons.home_outlined,
activeIcon: Icons.home,
label: 'Home',
path: '/',
active: _onHome,
enabled: true,
),
if (!minimalMode)
_NavItem(
icon: Icons.search_outlined,
activeIcon: Icons.search,
label: 'Search',
path: '/explore/',
active: _onExplore,
enabled: exploreEnabled,
),
_NavItem(
icon: Icons.add_box_outlined,
activeIcon: Icons.add_box,
label: 'New',
path: '/create/select/',
active: false,
enabled: true,
),
if (!minimalMode)
_NavItem(
icon: Icons.play_circle_outline,
activeIcon: Icons.play_circle,
label: 'Reels',
path: '/reels/',
active: _onReels,
enabled: reelsEnabled,
),
_NavItem(
icon: Icons.person_outline,
activeIcon: Icons.person,
label: 'Profile',
path: '/accounts/edit/',
active: _onProfile,
enabled: true,
),
];
return SafeArea(
top: false,
child: Container(
decoration: BoxDecoration(
color: bgColor,
border: Border(
top: BorderSide(
color: isDark ? Colors.white10 : Colors.black12,
width: 0.5,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: tabs.map((item) {
final color = item.active ? iconColorActive : iconColorInactive;
final opacity = item.enabled ? 1.0 : 0.35;
return Expanded(
child: Opacity(
opacity: opacity,
child: InkWell(
onTap: item.enabled ? () => onNavigate(item.path) : null,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 6,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
item.active ? item.activeIcon : item.icon,
size: 24,
color: color,
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(fontSize: 10, color: color),
),
],
),
),
),
),
);
}).toList(),
),
),
);
}
}
class _NavItem {
final IconData icon;
final IconData activeIcon;
final String label;
final String path;
final bool active;
final bool enabled;
_NavItem({
required this.icon,
required this.activeIcon,
required this.label,
required this.path,
required this.active,
required this.enabled,
});
}
@@ -0,0 +1,69 @@
import 'dart:collection';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../scripts/autoplay_blocker.dart';
import '../../scripts/spa_navigation_monitor.dart';
import '../../scripts/native_feel.dart';
class InstagramPreloader {
static HeadlessInAppWebView? _headlessWebView;
static InAppWebViewController? controller;
static final InAppWebViewKeepAlive keepAlive = InAppWebViewKeepAlive();
static bool isReady = false;
static Future<void> start(String userAgent) async {
if (_headlessWebView != null) return; // don't start twice
_headlessWebView = HeadlessInAppWebView(
keepAlive: keepAlive,
initialUrlRequest: URLRequest(url: WebUri('https://www.instagram.com/')),
initialSettings: InAppWebViewSettings(
userAgent: userAgent,
mediaPlaybackRequiresUserGesture: true,
useHybridComposition: true,
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
databaseEnabled: true,
hardwareAcceleration: true,
transparentBackground: true,
safeBrowsingEnabled: false,
),
initialUserScripts: UnmodifiableListView([
UserScript(
source: 'window.__fgBlockAutoplay = true;',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kAutoplayBlockerJS,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kSpaNavigationMonitorScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
UserScript(
source: kNativeFeelingScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
]),
onWebViewCreated: (c) {
controller = c;
},
onLoadStop: (c, url) async {
isReady = true;
await c.evaluateJavascript(source: kNativeFeelingPostLoadScript);
},
);
await _headlessWebView!.run();
}
static void dispose() {
_headlessWebView?.dispose();
_headlessWebView = null;
controller = null;
isReady = false;
}
}
@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'reels_history_service.dart';
class ReelsHistoryScreen extends StatefulWidget {
const ReelsHistoryScreen({super.key});
@override
State<ReelsHistoryScreen> createState() => _ReelsHistoryScreenState();
}
class _ReelsHistoryScreenState extends State<ReelsHistoryScreen> {
final _service = ReelsHistoryService();
late Future<List<ReelsHistoryEntry>> _future;
@override
void initState() {
super.initState();
_future = _service.getEntries();
}
Future<void> _refresh() async {
setState(() => _future = _service.getEntries());
}
String _formatTimestamp(DateTime dt) =>
DateFormat('EEE, MMM d • h:mm a').format(dt.toLocal());
String _relativeTime(DateTime dt) {
final diff = DateTime.now().difference(dt.toLocal());
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return _formatTimestamp(dt);
}
Future<void> _confirmClearAll() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Clear Reels History?'),
content: const Text(
'This removes all history entries stored locally on this device.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.redAccent),
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Clear All'),
),
],
),
);
if (ok != true || !mounted) return;
await _service.clearAll();
await _refresh();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Reels History',
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),
),
actions: [
IconButton(
tooltip: 'Clear All',
onPressed: _confirmClearAll,
icon: const Icon(Icons.delete_outline),
),
],
),
body: RefreshIndicator(
onRefresh: _refresh,
child: FutureBuilder<List<ReelsHistoryEntry>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final entries = snapshot.data ?? const <ReelsHistoryEntry>[];
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(
Icons.lock_outline,
size: 12,
color: Colors.grey,
),
const SizedBox(width: 6),
Text(
'${entries.length} reels stored locally on device only',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
),
const SizedBox(height: 4),
if (entries.isEmpty)
const Padding(
padding: EdgeInsets.all(48),
child: Center(
child: Column(
children: [
Icon(
Icons.play_circle_outline,
size: 48,
color: Colors.grey,
),
SizedBox(height: 12),
Text(
'No Reels history yet.\nWatch a Reel and it will appear here.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
),
)
else
...entries.map((entry) {
return Dismissible(
key: ValueKey(entry.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.redAccent.withValues(alpha: 0.15),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(
Icons.delete_outline,
color: Colors.redAccent,
),
),
onDismissed: (_) async {
await _service.deleteEntry(entry.id);
// Don't call _refresh() on dismiss — removes the entry from
// the live list already via Dismissible, avoids double setState
},
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
leading: _ReelThumbnail(url: entry.thumbnailUrl),
title: Text(
entry.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
_relativeTime(entry.visitedAt),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
trailing: const Icon(
Icons.play_circle_outline,
color: Colors.blue,
size: 20,
),
onTap: () => Navigator.pop(context, entry.url),
),
);
}),
const SizedBox(height: 40),
],
);
},
),
),
);
}
}
/// Thumbnail widget that correctly sends Referer + User-Agent headers
/// required by Instagram's CDN. Without these the CDN returns 403.
class _ReelThumbnail extends StatelessWidget {
final String url;
const _ReelThumbnail({required this.url});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 60,
height: 60,
child: url.isEmpty
? _placeholder()
: Image.network(
url,
width: 60,
height: 60,
fit: BoxFit.cover,
headers: const {
// Instagram CDN requires a valid Referer header
'Referer': 'https://www.instagram.com/',
'User-Agent':
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/22G86',
},
errorBuilder: (_, _, _) => _placeholder(),
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return Container(
color: Colors.white10,
child: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
),
);
},
),
),
);
}
Widget _placeholder() => Container(
color: Colors.white10,
child: const Icon(
Icons.play_circle_outline,
color: Colors.white30,
size: 28,
),
);
}
@@ -0,0 +1,117 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class ReelsHistoryEntry {
final String id;
final String url;
final String title;
final String thumbnailUrl;
final DateTime visitedAt;
const ReelsHistoryEntry({
required this.id,
required this.url,
required this.title,
required this.thumbnailUrl,
required this.visitedAt,
});
Map<String, dynamic> toJson() => {
'id': id,
'url': url,
'title': title,
'thumbnailUrl': thumbnailUrl,
'visitedAt': visitedAt.toUtc().toIso8601String(),
};
static ReelsHistoryEntry fromJson(Map<String, dynamic> json) {
return ReelsHistoryEntry(
id: (json['id'] as String?) ?? '',
url: (json['url'] as String?) ?? '',
title: (json['title'] as String?) ?? 'Instagram Reel',
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
visitedAt:
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
DateTime.now().toUtc(),
);
}
}
class ReelsHistoryService {
static const String _prefsKey = 'reels_history';
static const int _maxEntries = 200;
SharedPreferences? _prefs;
Future<SharedPreferences> _getPrefs() async {
_prefs ??= await SharedPreferences.getInstance();
return _prefs!;
}
Future<List<ReelsHistoryEntry>> getEntries() async {
final prefs = await _getPrefs();
final raw = prefs.getString(_prefsKey);
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw) as List<dynamic>;
final entries = decoded
.whereType<Map>()
.map((e) => ReelsHistoryEntry.fromJson(e.cast<String, dynamic>()))
.where((e) => e.url.isNotEmpty)
.toList();
entries.sort((a, b) => b.visitedAt.compareTo(a.visitedAt));
return entries;
} catch (_) {
return [];
}
}
Future<void> addEntry({
required String url,
required String title,
required String thumbnailUrl,
}) async {
if (url.isEmpty) return;
final now = DateTime.now().toUtc();
final entries = await getEntries();
final recentDuplicate = entries.any((e) {
if (e.url != url) return false;
final diff = now.difference(e.visitedAt).inSeconds.abs();
return diff <= 60;
});
if (recentDuplicate) return;
final entry = ReelsHistoryEntry(
id: DateTime.now().microsecondsSinceEpoch.toString(),
url: url,
title: title.isEmpty ? 'Instagram Reel' : title,
thumbnailUrl: thumbnailUrl,
visitedAt: now,
);
final updated = [entry, ...entries];
if (updated.length > _maxEntries) {
updated.removeRange(_maxEntries, updated.length);
}
await _save(updated);
}
Future<void> deleteEntry(String id) async {
final entries = await getEntries();
entries.removeWhere((e) => e.id == id);
await _save(entries);
}
Future<void> clearAll() async {
final prefs = await _getPrefs();
await prefs.remove(_prefsKey);
}
Future<void> _save(List<ReelsHistoryEntry> entries) async {
final prefs = await _getPrefs();
final jsonList = entries.map((e) => e.toJson()).toList();
await prefs.setString(_prefsKey, jsonEncode(jsonList));
}
}
@@ -0,0 +1,307 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/screen_time_service.dart';
class ScreenTimeScreen extends StatelessWidget {
const ScreenTimeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Screen Time',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: Consumer<ScreenTimeService>(
builder: (context, service, _) {
final data = service.secondsByDate;
final todayKey = _todayKey();
final todaySeconds = data[todayKey] ?? 0;
final last7 = _lastNDays(7);
final barSpots = <BarChartGroupData>[];
int totalSeconds = 0;
for (var i = 0; i < last7.length; i++) {
final key = last7[i];
final sec = data[key] ?? 0;
totalSeconds += sec;
barSpots.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: sec / 60.0,
width: 10,
borderRadius: BorderRadius.circular(4),
color: Colors.blueAccent,
),
],
),
);
}
final daysWithData = data.values.isEmpty ? 0 : data.length;
final weeklyAvgMinutes = last7.isEmpty
? 0.0
: totalSeconds / 60.0 / last7.length;
final allTimeMinutes = totalSeconds / 60.0;
return ListView(
padding: const EdgeInsets.all(16),
children: [
_buildStatCard(
title: 'Today',
value: _formatDuration(todaySeconds),
),
const SizedBox(height: 16),
_buildChartCard(barSpots, last7),
const SizedBox(height: 16),
_buildInlineStats(
weeklyAvgMinutes: weeklyAvgMinutes,
allTimeMinutes: allTimeMinutes,
daysWithData: daysWithData,
),
const SizedBox(height: 24),
const Text(
'All data stored locally on your device only',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 24),
Center(
child: OutlinedButton.icon(
onPressed: () => _confirmReset(context, service),
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('Reset all data'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.redAccent,
side: const BorderSide(color: Colors.redAccent),
),
),
),
],
);
},
),
);
}
static String _todayKey() {
final now = DateTime.now();
return '${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
}
static List<String> _lastNDays(int n) {
final now = DateTime.now();
return List.generate(n, (i) {
final d = now.subtract(Duration(days: n - 1 - i));
return '${d.year.toString().padLeft(4, '0')}-'
'${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}';
});
}
Widget _buildStatCard({required String title, required String value}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.blue.withValues(alpha: 0.2)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(fontSize: 14, color: Colors.grey)),
Text(
value,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _buildChartCard(List<BarChartGroupData> bars, List<String> last7Keys) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white12),
),
height: 220,
child: BarChart(
BarChartData(
barGroups: bars,
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= last7Keys.length) {
return const SizedBox.shrink();
}
final label = last7Keys[index].substring(
last7Keys[index].length - 2,
);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
label,
style: const TextStyle(fontSize: 10, color: Colors.grey),
),
);
},
),
),
),
),
),
);
}
Widget _buildInlineStats({
required double weeklyAvgMinutes,
required double allTimeMinutes,
required int daysWithData,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.02),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_inlineStat(
label: '7-day avg',
value: '${weeklyAvgMinutes.toStringAsFixed(1)} min',
),
_inlineDivider(),
_inlineStat(
label: 'All-time total',
value: '${allTimeMinutes.toStringAsFixed(0)} min',
),
_inlineDivider(),
_inlineStat(label: 'Tracked days', value: '$daysWithData'),
],
),
);
}
Widget _inlineStat({required String label, required String value}) {
return Column(
children: [
Text(
value,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 11, color: Colors.grey)),
],
);
}
Widget _inlineDivider() {
return Container(
width: 1,
height: 40,
color: Colors.white.withValues(alpha: 0.08),
);
}
static String _formatDuration(int seconds) {
if (seconds < 60) {
return '0:${seconds.toString().padLeft(2, '0')}';
}
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
if (h > 0) {
return '${h}h ${m.toString().padLeft(2, '0')}m';
}
final s = seconds % 60;
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
}
Future<void> _confirmReset(
BuildContext context,
ScreenTimeService service,
) async {
final first = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Reset screen time?'),
content: const Text(
'This will clear all locally stored screen time data for the last 30 days.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text(
'Continue',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
if (first != true) return;
if (!context.mounted) return;
final second = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirm reset'),
content: const Text(
'Are you sure you want to permanently delete all screen time data?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text(
'Yes, delete',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
if (!context.mounted) return;
if (second == true) {
await service.resetAll();
}
}
}
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'update_checker_service.dart';
class UpdateBanner extends StatefulWidget {
final UpdateInfo updateInfo;
final VoidCallback onDismiss;
const UpdateBanner({
super.key,
required this.updateInfo,
required this.onDismiss,
});
@override
State<UpdateBanner> createState() => _UpdateBannerState();
}
class _UpdateBannerState extends State<UpdateBanner> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
const Text('🎉', style: TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Expanded(
child: Text(
'FocusGram ${widget.updateInfo.latestVersion} available',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: Icon(
_isExpanded ? Icons.expand_less : Icons.expand_more,
size: 18,
),
onPressed: () {
HapticFeedback.lightImpact();
setState(() => _isExpanded = !_isExpanded);
},
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () {
HapticFeedback.lightImpact();
widget.onDismiss();
},
),
],
),
),
if (_isExpanded) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"What's new",
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_formatReleaseNotes(widget.updateInfo.whatsNew),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () async {
final uri = Uri.parse(widget.updateInfo.releaseUrl);
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
},
child: const Text('Download on GitHub'),
),
),
],
),
),
],
],
),
);
}
String _formatReleaseNotes(String raw) {
var text = raw;
text = text.replaceAll(RegExp(r'#{1,6}\s'), '');
text = text.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'\1');
text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1');
text = text.replaceAll(
RegExp(r'\[([^\]]+)\]\([^)]+\)'),
r'\1',
); // links -> text
text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1');
return text.trim();
}
}
@@ -0,0 +1,106 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
class UpdateInfo {
final String latestVersion; // e.g. "1.0.0"
final String releaseUrl; // html_url
final String whatsNew; // trimmed body
final bool isUpdateAvailable;
const UpdateInfo({
required this.latestVersion,
required this.releaseUrl,
required this.whatsNew,
required this.isUpdateAvailable,
});
}
class UpdateCheckerService extends ChangeNotifier {
static const String _lastDismissedKey = 'last_dismissed_update_version';
static const String _githubUrl =
'https://api.github.com/repos/Ujwal223/FocusGram/releases/latest';
UpdateInfo? _updateInfo;
bool _isDismissed = false;
bool get hasUpdate => _updateInfo != null && !_isDismissed;
UpdateInfo? get updateInfo => hasUpdate ? _updateInfo : null;
Future<void> checkForUpdates() async {
try {
final response = await http
.get(Uri.parse(_githubUrl))
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return;
final data = json.decode(response.body);
final String gitVersionTag =
data['tag_name'] ?? ''; // e.g. "v0.9.8-beta.2"
final String htmlUrl = data['html_url'] ?? '';
final String body = (data['body'] as String?) ?? '';
if (gitVersionTag.isEmpty || htmlUrl.isEmpty) return;
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version; // e.g. "0.9.8-beta.2"
if (!_isNewerVersion(gitVersionTag, currentVersion)) return;
final prefs = await SharedPreferences.getInstance();
final dismissedVersion = prefs.getString(_lastDismissedKey);
if (dismissedVersion == gitVersionTag) {
_isDismissed = true;
return;
}
final cleanVersion = gitVersionTag.startsWith('v')
? gitVersionTag.substring(1)
: gitVersionTag;
var trimmed = body.trim();
if (trimmed.length > 1500) {
trimmed = trimmed.substring(0, 1500).trim();
}
_updateInfo = UpdateInfo(
latestVersion: cleanVersion,
releaseUrl: htmlUrl,
whatsNew: trimmed,
isUpdateAvailable: true,
);
_isDismissed = false;
notifyListeners();
} catch (e) {
debugPrint('Update check failed: $e');
}
}
Future<void> dismissUpdate() async {
if (_updateInfo == null) return;
_isDismissed = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastDismissedKey, _updateInfo!.latestVersion);
notifyListeners();
}
bool _isNewerVersion(String gitTag, String current) {
// Clean versions: strip 'v' and everything after '-' (beta/rc)
String cleanGit = gitTag.startsWith('v') ? gitTag.substring(1) : gitTag;
String cleanCurrent = current;
List<String> gitParts = cleanGit.split('-')[0].split('.');
List<String> currentParts = cleanCurrent.split('-')[0].split('.');
for (int i = 0; i < gitParts.length && i < currentParts.length; i++) {
int gitNum = int.tryParse(gitParts[i]) ?? 0;
int curNum = int.tryParse(currentParts[i]) ?? 0;
if (gitNum > curNum) return true;
if (gitNum < curNum) return false;
}
return false;
}
}
+17
View File
@@ -0,0 +1,17 @@
class FocusSettings {
final bool ghostMode; // hide read receipts
final bool noAds; // strip ads and sponsored posts
final bool noStories; // hide story tray
final bool noReels; // hide reels tab
final bool noAutoplay; // stop videos autoplaying
final bool noDMs; // block direct messages
const FocusSettings({
this.ghostMode = false,
this.noAds = true,
this.noStories = false,
this.noReels = false,
this.noAutoplay = false,
this.noDMs = false,
});
}
+32 -5
View File
@@ -1,16 +1,23 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:app_links/app_links.dart';
import 'services/session_manager.dart';
import 'services/settings_service.dart';
import 'services/screen_time_service.dart';
import 'services/focusgram_router.dart';
import 'services/injection_controller.dart';
import 'screens/onboarding_page.dart';
import 'screens/main_webview_page.dart';
import 'screens/breath_gate_screen.dart';
import 'screens/app_session_picker.dart';
import 'screens/cooldown_gate_screen.dart';
import 'services/notification_service.dart';
import 'features/update_checker/update_checker_service.dart';
import 'features/preloader/instagram_preloader.dart';
import 'widgets/remote_popup_handler.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -23,9 +30,13 @@ void main() async {
final sessionManager = SessionManager();
final settingsService = SettingsService();
final screenTimeService = ScreenTimeService();
final updateChecker = UpdateCheckerService();
await sessionManager.init();
await settingsService.init();
await screenTimeService.init();
await NotificationService().init();
runApp(
@@ -33,10 +44,15 @@ void main() async {
providers: [
ChangeNotifierProvider.value(value: sessionManager),
ChangeNotifierProvider.value(value: settingsService),
ChangeNotifierProvider.value(value: screenTimeService),
ChangeNotifierProvider.value(value: updateChecker),
],
child: const FocusGramApp(),
),
);
// Fire and forget — preloads Instagram while app UI initialises.
unawaited(InstagramPreloader.start(InjectionController.iOSUserAgent));
}
class FocusGramApp extends StatelessWidget {
@@ -72,7 +88,8 @@ class FocusGramApp extends StatelessWidget {
/// 1. Onboarding (if first run)
/// 2. Cooldown Gate (if app-open cooldown active)
/// 3. Breath Gate (if enabled in settings)
/// 4. App Session Picker (always)
/// 4. If an app session is already active, resume it
/// otherwise show App Session Picker
/// 5. Main WebView
class InitialRouteHandler extends StatefulWidget {
const InitialRouteHandler({super.key});
@@ -92,6 +109,10 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
super.initState();
_appLinks = AppLinks();
_initDeepLinks();
WidgetsBinding.instance.addPostFrameCallback((_) {
RemotePopupHandler.checkAndShow(context);
});
}
Future<void> _initDeepLinks() async {
@@ -129,15 +150,21 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
// Step 3: Breath gate
if (settings.showBreathGate && !_breathCompleted) {
return BreathGateScreen(
durationSeconds: settings.breathGateSeconds,
onFinish: () => setState(() => _breathCompleted = true),
);
}
// Step 4: App session picker
// Step 4: App session picker / resume existing session
if (!_appSessionStarted) {
return AppSessionPickerScreen(
onSessionStarted: () => setState(() => _appSessionStarted = true),
);
if (sm.isAppSessionActive) {
// User already has an active app session — don't ask intention again.
_appSessionStarted = true;
} else {
return AppSessionPickerScreen(
onSessionStarted: () => setState(() => _appSessionStarted = true),
);
}
}
// Step 5: Main app
-211
View File
@@ -1,211 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:http/http.dart' as http;
class AboutPage extends StatefulWidget {
const AboutPage({super.key});
@override
State<AboutPage> createState() => _AboutPageState();
}
class _AboutPageState extends State<AboutPage> {
final String _currentVersion = '0.9.8';
bool _isChecking = false;
Future<void> _checkUpdate() async {
setState(() => _isChecking = true);
try {
final response = await http
.get(
Uri.parse(
'https://api.github.com/repos/Ujwal223/FocusGram/releases/latest',
),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
final latestVersion = data['tag_name'].toString().replaceAll('v', '');
final downloadUrl = data['html_url'];
if (latestVersion != _currentVersion) {
_showUpdateDialog(latestVersion, downloadUrl);
} else {
_showSnackBar('You are up to date! 🎉');
}
} else {
_showSnackBar('Could not check for updates.');
}
} catch (_) {
_showSnackBar('Connectivity issue. Try again later.');
} finally {
if (mounted) setState(() => _isChecking = false);
}
}
void _showUpdateDialog(String version, String url) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1A1A1A),
title: const Text(
'Update Available!',
style: TextStyle(color: Colors.white),
),
content: Text(
'A new version ($version) is available on GitHub.',
style: const TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Later', style: TextStyle(color: Colors.white38)),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
_launchURL(url);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
child: const Text('Download'),
),
],
),
);
}
void _showSnackBar(String msg) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: const Text(
'About FocusGram',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: ClipOval(
child: Image.asset(
'assets/images/focusgram.png',
width: 60,
height: 60,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 24),
const Text(
'FocusGram',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Version $_currentVersion',
style: const TextStyle(color: Colors.white38, fontSize: 13),
),
const SizedBox(height: 40),
const Text(
'Developed with passion for digital discipline by',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const SizedBox(height: 4),
const Text(
'Ujwal Chapagain',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 40),
ElevatedButton.icon(
onPressed: _isChecking ? null : _checkUpdate,
icon: _isChecking
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.update),
label: Text(_isChecking ? 'Checking...' : 'Check for Update'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent.withValues(alpha: 0.2),
foregroundColor: Colors.white,
minimumSize: const Size(200, 45),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () =>
_launchURL('https://github.com/Ujwal223/FocusGram'),
icon: const Icon(Icons.code),
label: const Text('View on GitHub'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white10,
foregroundColor: Colors.white,
minimumSize: const Size(200, 45),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 20),
const Text(
'FocusGram is not affiliated with Instagram.',
style: TextStyle(
color: Color.fromARGB(48, 255, 255, 255),
fontSize: 10,
),
),
],
),
),
),
);
}
Future<void> _launchURL(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {}
}
}
+20 -4
View File
@@ -27,7 +27,25 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
55,
60,
];
int _selectedIndex = 2; // default: 15 min
int _selectedIndex = 0; // default: 5 min unless a previous choice exists
late final FixedExtentScrollController _scrollController;
@override
void initState() {
super.initState();
final lastMinutes = context.read<SessionManager>().lastAppSessionMinutes;
final lastIndex = _minuteOptions.indexOf(lastMinutes);
_selectedIndex = lastIndex >= 0 ? lastIndex : 0;
_scrollController = FixedExtentScrollController(
initialItem: _selectedIndex,
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -118,12 +136,10 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
perspective: 0.003,
squeeze: 1.1,
diameterRatio: 2.5,
controller: _scrollController,
onSelectedItemChanged: (i) {
setState(() => _selectedIndex = i);
},
controller: FixedExtentScrollController(
initialItem: _selectedIndex,
),
childDelegate: ListWheelChildListDelegate(
children: _minuteOptions.asMap().entries.map((entry) {
final isSelected = entry.key == _selectedIndex;
+9 -5
View File
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'dart:async';
/// A mindfulness screen shown before the app opens.
/// Forces the user to take a deep 10-second breath.
/// A mindfulness screen shown before Instagram opens.
class BreathGateScreen extends StatefulWidget {
final VoidCallback onFinish;
final int durationSeconds;
const BreathGateScreen({super.key, required this.onFinish});
const BreathGateScreen({
super.key,
required this.onFinish,
this.durationSeconds = 10,
});
@override
State<BreathGateScreen> createState() => _BreathGateScreenState();
@@ -16,15 +20,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
int _secondsRemaining = 10;
late int _secondsRemaining;
Timer? _timer;
bool _canContinue = false;
@override
void initState() {
super.initState();
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
// 10-second breathing animation: 5s in, 5s out
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
+154
View File
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/settings_service.dart';
class ExtrasSettingsPage extends StatelessWidget {
const ExtrasSettingsPage({super.key});
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
return Scaffold(
appBar: AppBar(
title: const Text(
'Extras',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(
children: [
const _SectionHeader(title: 'MEDIA'),
_SwitchTile(
title: 'Download Media (Feed + Reels)',
subtitle: 'Adds a download icon on posts and reels',
value: settings.videoDownloadEnabled,
onChanged: (v) async {
await settings.setVideoDownloadEnabled(v);
HapticFeedback.selectionClick();
},
),
const _SectionHeader(title: 'FOCUS'),
_SwitchTile(
title: 'GHOST MODE',
subtitle: 'Hide seen indicator / read receipts',
value: settings.ghostMode,
onChanged: (v) async {
await settings.setGhostMode(v);
HapticFeedback.selectionClick();
},
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber.withValues(alpha: 0.12)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 8, top: 2),
child: Icon(
Icons.info_outline,
size: 14,
color: Colors.amber,
),
),
const Expanded(
child: Text(
'NOTE: The seen indicator is not sent to the sender while you are active in the chat, but as soon as you close and reopen the chat, the seen indicator is sent.',
style: TextStyle(fontSize: 11, color: Colors.amber),
),
),
],
),
),
),
/* TRIED BUT IT DIDNT WORK 98% oF THE TIME)
const _SectionHeader(title: 'FOCUSGRAM V2'),
_SwitchTile(
title: 'Ad Blocker',
subtitle: 'Removes ads and sponsored posts',
value: settings.v2AdBlockerDomEnabled,
onChanged: (v) async {
await settings.setV2AdBlockerDomEnabled(v);
HapticFeedback.selectionClick();
},
),
_SwitchTile(
title: 'Block Suggested Posts',
subtitle: 'Removes Suggested for you and recommendation units',
value: settings.contentSuggested,
onChanged: (v) async {
await settings.setContentSuggestedEnabled(v);
HapticFeedback.selectionClick();
},
),
*/
const SizedBox(height: 40),
],
),
);
}
}
class _SwitchTile extends StatelessWidget {
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchTile({
required this.title,
this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return SwitchListTile(
title: Text(title, style: const TextStyle(fontSize: 15)),
subtitle: subtitle != null
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
: null,
value: value,
onChanged: onChanged,
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
title,
style: const TextStyle(
color: Colors.grey,
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
);
}
}
+7 -2
View File
@@ -18,7 +18,11 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
Future<void> Function() action,
) async {
if (sm.isScheduledBlockActive) {
final ok = await DisciplineChallenge.show(context, count: 35);
final settings = context.read<SettingsService>();
final ok = await DisciplineChallenge.show(
context,
count: settings.resolvedWordChallengeCount(),
);
if (!context.mounted || !ok) return;
}
await action();
@@ -321,7 +325,8 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
ElevatedButton(
onPressed: () async {
final sm = context.read<SessionManager>();
int wordCount = 15;
final settings = context.read<SettingsService>();
int wordCount = settings.resolvedWordChallengeCount();
// If we are at 0 quota, increase difficulty to 35 words
if (widget.title.contains('Daily Reel Limit') &&
sm.dailyRemainingSeconds <= 0) {
File diff suppressed because it is too large Load Diff
+293 -91
View File
@@ -18,58 +18,61 @@ class _OnboardingPageState extends State<OnboardingPage> {
final PageController _pageController = PageController();
int _currentPage = 0;
final List<OnboardingData> _pages = [
OnboardingData(
title: 'Welcome to FocusGram',
description:
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
icon: Icons.auto_awesome,
color: Colors.blue,
),
OnboardingData(
title: 'Ghost Mode',
description:
'Browse with total privacy. We block typing indicators and read receipts automatically.',
icon: Icons.visibility_off,
color: Colors.purple,
),
OnboardingData(
title: 'Session Management',
description:
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
icon: Icons.timer,
color: Colors.orange,
),
OnboardingData(
title: 'Open Links in FocusGram',
description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" -> "Add link" and select all.',
icon: Icons.link,
color: Colors.cyan,
isAppSettingsPage: true,
),
OnboardingData(
title: 'Upload Content',
description:
'We need access to your gallery if you want to upload stories or posts directly from FocusGram.',
icon: Icons.photo_library,
color: Colors.orange,
isPermissionPage: true,
permission: Permission.photos,
),
OnboardingData(
title: 'Stay Notified',
description:
'We need notification permissions to alert you when your session is over or a new message arrives.',
icon: Icons.notifications_active,
color: Colors.green,
isPermissionPage: true,
permission: Permission.notification,
),
];
// Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications
static const int _kTotalPages = 5;
static const int _kBlurPage = 3;
static const int _kLinkPage = 2;
static const int _kNotifPage = 4;
@override
Widget build(BuildContext context) {
final settings = context.watch<SettingsService>();
final List<Widget> slides = [
// ── Page 0: Welcome ─────────────────────────────────────────────────
_StaticSlide(
icon: Icons.auto_awesome_rounded,
color: const Color(0xFF4F8DFF),
title: 'Welcome to FocusGram',
description:
'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.',
),
// ── Page 1: Focus controls ───────────────────────────────────────────
_StaticSlide(
icon: Icons.timer_outlined,
color: const Color(0xFFFFB74D),
title: 'Time With Intent',
description:
'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.',
),
// ── Page 2: Open links ───────────────────────────────────────────────
_StaticSlide(
icon: Icons.link_rounded,
color: const Color(0xFF35C2D6),
title: 'Open Links in FocusGram',
description:
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
isAppSettingsPage: true,
),
// ── Page 3: Blur Settings ────────────────────────────────────────────
_BlurSettingsSlide(settings: settings),
// ── Page 4: Notifications ────────────────────────────────────────────
_StaticSlide(
icon: Icons.notifications_active_outlined,
color: const Color(0xFF5DD18A),
title: 'Useful Alerts Only',
description:
'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.',
isPermissionPage: true,
permission: Permission.notification,
),
];
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
@@ -77,9 +80,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
PageView.builder(
controller: _pageController,
onPageChanged: (index) => setState(() => _currentPage = index),
itemCount: _pages.length,
itemBuilder: (context, index) =>
_OnboardingSlide(data: _pages[index]),
itemCount: _kTotalPages,
itemBuilder: (context, index) => slides[index],
),
Positioned(
bottom: 50,
@@ -87,11 +89,13 @@ class _OnboardingPageState extends State<OnboardingPage> {
right: 0,
child: Column(
children: [
// Dot indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
_pages.length,
(index) => Container(
_kTotalPages,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: _currentPage == index ? 12 : 8,
height: 8,
@@ -104,7 +108,8 @@ class _OnboardingPageState extends State<OnboardingPage> {
),
),
),
const SizedBox(height: 32),
const SizedBox(height: 28),
// CTA button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: SizedBox(
@@ -112,24 +117,39 @@ class _OnboardingPageState extends State<OnboardingPage> {
height: 56,
child: Builder(
builder: (context) {
final data = _pages[_currentPage];
final isLast = _currentPage == _kTotalPages - 1;
final isLink = _currentPage == _kLinkPage;
final isNotif = _currentPage == _kNotifPage;
final isBlur = _currentPage == _kBlurPage;
String label;
if (isNotif) {
label = 'Allow & Start';
} else if (isLink) {
label = 'Configure';
} else if (isBlur) {
label = 'Save & Continue';
} else if (isLast) {
label = 'Get Started';
} else {
label = 'Next';
}
return ElevatedButton(
onPressed: () async {
if (data.isAppSettingsPage) {
if (isLink) {
await AppSettings.openAppSettings(
type: AppSettingsType.settings,
);
} else if (data.isPermissionPage) {
if (data.permission != null) {
await data.permission!.request();
}
if (data.title == 'Stay Notified') {
await NotificationService().init();
}
} else if (isNotif) {
await Permission.notification.request();
await NotificationService()
.requestPermissionsNow();
}
if (_currentPage == _pages.length - 1) {
_finish();
if (!context.mounted) return;
if (isLast) {
_finish(context);
} else {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
@@ -145,11 +165,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
),
),
child: Text(
_currentPage == _pages.length - 1
? 'Get Started'
: (data.isAppSettingsPage
? 'Configure'
: 'Next'),
label,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
@@ -160,6 +176,25 @@ class _OnboardingPageState extends State<OnboardingPage> {
),
),
),
// Skip button (available on all pages except last)
if (_currentPage < _kTotalPages - 1)
TextButton(
onPressed: () {
if (_currentPage == _kNotifPage) {
_finish(context);
} else {
_pageController.animateToPage(
_kTotalPages - 1,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: const Text(
'Skip setup',
style: TextStyle(color: Colors.white38, fontSize: 14),
),
),
],
),
),
@@ -168,65 +203,232 @@ class _OnboardingPageState extends State<OnboardingPage> {
);
}
void _finish() {
void _finish(BuildContext context) {
context.read<SettingsService>().setFirstRunCompleted();
widget.onFinish();
}
}
class OnboardingData {
final String title;
final String description;
// ── Static info slide ──────────────────────────────────────────────────────────
class _StaticSlide extends StatelessWidget {
final IconData icon;
final Color color;
final String title;
final String description;
final bool isPermissionPage;
final bool isAppSettingsPage;
final Permission? permission;
OnboardingData({
required this.title,
required this.description,
const _StaticSlide({
required this.icon,
required this.color,
required this.title,
required this.description,
this.isPermissionPage = false,
this.isAppSettingsPage = false,
this.permission,
});
}
class _OnboardingSlide extends StatelessWidget {
final OnboardingData data;
const _OnboardingSlide({required this.data});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(40),
padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(data.icon, size: 120, color: data.color),
const SizedBox(height: 48),
Container(
width: 112,
height: 112,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: color.withValues(alpha: 0.28)),
),
child: Icon(icon, size: 54, color: color),
),
const SizedBox(height: 36),
Text(
data.title,
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
data.description,
description,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white70,
fontSize: 18,
fontSize: 16,
height: 1.5,
),
),
if (isPermissionPage || isAppSettingsPage) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Text(
isPermissionPage
? 'Permission is optional and can be changed later.'
: 'This opens Android settings; return here when done.',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54, fontSize: 12),
),
),
],
],
),
);
}
}
// ── Blur settings slide ────────────────────────────────────────────────────────
class _BlurSettingsSlide extends StatelessWidget {
final SettingsService settings;
const _BlurSettingsSlide({required this.settings});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(32, 40, 32, 160),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Center(
child: Icon(
Icons.blur_on_rounded,
size: 90,
color: Colors.purpleAccent,
),
),
const SizedBox(height: 36),
const Center(
child: Text(
'Distraction Shield',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 12),
const Center(
child: Text(
'Blur feeds you don\'t want to be tempted by. You can change these anytime in Settings.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white60,
fontSize: 16,
height: 1.5,
),
),
),
const SizedBox(height: 40),
// Blur Home Feed toggle
_BlurToggleTile(
icon: Icons.home_rounded,
label: 'Blur Home Feed',
subtitle: 'Posts in your feed will be blurred until tapped',
value: settings.blurReels,
onChanged: (v) => settings.setBlurReels(v),
),
const SizedBox(height: 16),
// Blur Explore toggle
_BlurToggleTile(
icon: Icons.explore_rounded,
label: 'Blur Explore Feed',
subtitle: 'Explore thumbnails stay blurred until you tap',
value: settings.blurExplore,
onChanged: (v) => settings.setBlurExplore(v),
),
],
),
);
}
}
class _BlurToggleTile extends StatelessWidget {
final IconData icon;
final String label;
final String subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _BlurToggleTile({
required this.icon,
required this.label,
required this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: value
? Colors.purpleAccent.withValues(alpha: 0.12)
: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: value
? Colors.purpleAccent.withValues(alpha: 0.5)
: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
Icon(
icon,
color: value ? Colors.purpleAccent : Colors.white38,
size: 28,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: value ? Colors.white : Colors.white70,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(color: Colors.white38, fontSize: 12),
),
],
),
),
Switch(
value: value,
onChanged: onChanged,
activeThumbColor: Colors.purpleAccent,
),
],
),
);
+62 -48
View File
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../services/injection_controller.dart';
import '../services/session_manager.dart';
import 'package:provider/provider.dart';
@@ -15,58 +15,12 @@ class ReelPlayerOverlay extends StatefulWidget {
}
class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
late final WebViewController _controller;
DateTime? _startTime;
@override
void initState() {
super.initState();
_startTime = DateTime.now();
_initWebView();
}
void _initWebView() {
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setUserAgent(InjectionController.iOSUserAgent)
..setNavigationDelegate(
NavigationDelegate(
onPageFinished: (url) {
// Set isolated player flag to ensure scroll-lock applies even if a session is active globally
_controller.runJavaScript(
'window.__focusgramIsolatedPlayer = true;',
);
// Apply scroll-lock via MutationObserver: prevents swiping to next reel
_controller.runJavaScript(
InjectionController.reelsMutationObserverJS,
);
// Also hide Instagram's bottom nav inside this overlay
_controller.runJavaScript(
InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
ghostTyping: false,
ghostSeen: false,
ghostStories: false,
ghostDmPhotos: false,
enableTextSelection: true,
),
);
},
onNavigationRequest: (request) {
// Allow only the initial reel URL and instagram.com generally
final uri = Uri.tryParse(request.url);
if (uri == null) return NavigationDecision.prevent;
final host = uri.host;
if (!host.contains('instagram.com')) {
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
),
)
..loadRequest(Uri.parse(widget.url));
}
@override
@@ -114,7 +68,67 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
),
],
),
body: WebViewWidget(controller: _controller),
body: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
initialSettings: InAppWebViewSettings(
userAgent: InjectionController.iOSUserAgent,
mediaPlaybackRequiresUserGesture: true,
useHybridComposition: true,
cacheEnabled: true,
cacheMode: CacheMode.LOAD_CACHE_ELSE_NETWORK,
domStorageEnabled: true,
databaseEnabled: true,
hardwareAcceleration: true,
transparentBackground: true,
safeBrowsingEnabled: false,
supportZoom: false,
allowsInlineMediaPlayback: true,
verticalScrollBarEnabled: false,
horizontalScrollBarEnabled: false,
),
onWebViewCreated: (controller) {
// Controller is not stored; this overlay is self-contained.
},
onLoadStop: (controller, url) async {
// Set isolated player flag to ensure scroll-lock applies even if a session is active globally
await controller.evaluateJavascript(
source: 'window.__focusgramIsolatedPlayer = true;',
);
// Apply scroll-lock via MutationObserver: prevents swiping to next reel
await controller.evaluateJavascript(
source: InjectionController.reelsMutationObserverJS,
);
// Also apply FocusGram baseline CSS (hides bottom nav etc.)
await controller.evaluateJavascript(
source: InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
tapToUnblur: false,
enableTextSelection: true,
hideSuggestedPosts: false,
hideSponsoredPosts: false,
hideLikeCounts: false,
hideFollowerCounts: false,
// hideStoriesBar removed per user request
hideExploreTab: false,
hideReelsTab: false,
hideShopTab: false,
disableReelsEntirely: false,
blockHomeFeedScroll: false,
),
);
},
shouldOverrideUrlLoading: (controller, action) async {
// Keep this overlay locked to instagram.com pages only
final uri = action.request.url;
if (uri == null) return NavigationActionPolicy.CANCEL;
if (!uri.host.contains('instagram.com')) {
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
),
);
}
}
+26 -21
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/session_manager.dart';
import '../services/settings_service.dart';
import '../utils/discipline_challenge.dart';
class SessionModal extends StatefulWidget {
@@ -63,23 +64,22 @@ class _SessionModalState extends State<SessionModal> {
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [1, 5, 10, 15].map((m) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton(
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
? null
: () => _start(m),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white12,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text('${m}m'),
Wrap(
spacing: 8,
runSpacing: 8,
children: [1, 3, 5, 10, 15, 20, 30].map((m) {
return SizedBox(
width: 72,
child: ElevatedButton(
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
? null
: () => _start(m),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white12,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text('${m}m'),
),
);
}).toList(),
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
Slider(
value: _customMinutes,
min: 1,
max: 30,
divisions: 29,
max: 60,
divisions: 59,
label: '${_customMinutes.toInt()}m',
onChanged: (v) => setState(() => _customMinutes = v),
),
@@ -126,10 +126,15 @@ class _SessionModalState extends State<SessionModal> {
void _start(int minutes) async {
final sm = context.read<SessionManager>();
final settings = context.read<SettingsService>();
// Always require word challenge for reel sessions (User request)
final success = await DisciplineChallenge.show(context);
if (!success) return;
if (settings.requireWordChallenge) {
final success = await DisciplineChallenge.show(
context,
count: settings.resolvedWordChallengeCount(),
);
if (!success) return;
}
if (sm.startSession(minutes)) {
if (mounted) Navigator.pop(context);
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
/// JavaScript to block autoplaying videos on Instagram feed/explore while:
/// - Allowing videos to play normally when "Block Autoplay Videos" is OFF
/// - Allowing user-initiated playback on click when blocking is ON
/// - NEVER blocking reels (they should always play normally per user request)
///
/// This script:
/// - 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).
/// - Uses a per-element flag set by user clicks to permanently allow that video to play.
/// - 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'''
(function fgAutoplayBlocker() {
if (window.__fgAutoplayPatched) return;
window.__fgAutoplayPatched = true;
// Default to blocking autoplay if not set
window.__fgBlockAutoplay = window.__fgBlockAutoplay !== false;
// Session state - set by FocusGram when session is active
// 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
function shouldBlockAutoplay() {
// 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) {
try {
video[ALLOW_KEY] = true;
} catch (_) {}
}
// Check if user has explicitly played this video
function shouldAllow(video) {
try {
return video[ALLOW_KEY] === true;
} catch (_) {
return false;
}
}
// Pause video and strip autoplay attribute (for blocked autoplay videos)
function pauseAndFreezeVideo(video) {
try {
// Remove autoplay attribute completely
video.removeAttribute('autoplay');
try { video.autoplay = false; } catch (_) {}
// Pause the video
video.pause();
// Reset to beginning
video.currentTime = 0;
} catch (_) {}
}
// Store original play and pause
const _origPlay = HTMLVideoElement.prototype.play;
const _origPause = HTMLVideoElement.prototype.pause;
// Override play method
if (HTMLVideoElement.prototype.play) {
HTMLVideoElement.prototype.play = function() {
try {
// NEVER block reels - they always play normally
if (isReelVideo()) {
return _origPlay.apply(this, arguments);
}
// Check if we should block based on both settings and session
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 {
return _origPlay.apply(this, arguments);
} catch (_) {
return Promise.resolve();
}
}
};
}
// Override pause method to work normally
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();
};
}
});
});
} catch (_) {}
// Also handle videos that might be created after DOMContentLoaded
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) {
try {
const video = e.target.closest ? e.target.closest('video') : e.target;
if (video) {
// Mark this specific video as user-initiated - permanent
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 (_) {}
}
}
} catch (_) {}
}, true);
document.addEventListener('touchstart', function(e) {
try {
const video = e.target.closest ? e.target.closest('video') : e.target;
if (video) {
markAllow(video);
if (shouldBlockAutoplay() && !shouldAllow(video)) {
try { video.play(); } catch (_) {}
}
}
} catch (_) {}
}, true);
// Also handle play events directly (for Instagram's internal play buttons)
document.addEventListener('play', function(e) {
if (e.target && e.target.tagName === 'VIDEO') {
markAllow(e.target);
}
}, true);
})();
''';
+648
View File
@@ -0,0 +1,648 @@
// UI element hiding for Instagram web.
//
// SCROLL LOCK WARNING:
// MutationObserver callbacks with {childList:true, subtree:true} fire hundreds
// of times/sec on Instagram's infinite scroll feed. Expensive querySelectorAll
// calls inside these callbacks block the main thread = scroll jank.
// The JS hiders below use requestIdleCallback + a 300ms debounce so they run
// only during idle time and never on every single mutation.
// ─── CSS-based (reliable, zero perf cost) ────────────────────────────────────
const String kHideLikeCountsCSS =
"""
[role="button"][aria-label${r"$"}=" like"],
[role="button"][aria-label${r"$"}=" likes"],
[role="button"][aria-label${r"$"}=" view"],
[role="button"][aria-label${r"$"}=" views"],
a[href*="/liked_by/"] {
display: none !important;
}
""";
const String kHideFollowerCountsCSS = """
a[href*="/followers/"] span,
a[href*="/following/"] span {
opacity: 0 !important;
pointer-events: none !important;
}
""";
// Stories bar — broad selector covering multiple Instagram DOM layouts
const String kHideStoriesBarCSS = """
[aria-label*="Stories"],
[aria-label*="stories"],
[role="list"][aria-label*="tories"],
[role="listbox"][aria-label*="tories"],
div[style*="overflow"][style*="scroll"]:has(canvas),
section > div > div[style*="overflow-x"] {
display: none !important;
}
""";
// Also do a JS sweep for stories — CSS alone isn't reliable across Instagram versions
const String kHideStoriesBarJS = r'''
(function() {
function hideStories() {
try {
// Target the horizontal scrollable stories container
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stori')) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Fallback: find story bubbles (circular avatar containers at top of feed)
document.querySelectorAll('section > div > div').forEach(function(el) {
try {
const style = window.getComputedStyle(el);
if (style.overflowX === 'scroll' || style.overflowX === 'auto') {
const circles = el.querySelectorAll('canvas, [style*="border-radius: 50%"]');
if (circles.length > 2) {
el.style.setProperty('display', 'none', 'important');
}
}
} catch(_) {}
});
} catch(_) {}
}
hideStories();
if (!window.__fgStoriesObserver) {
let _storiesTimer = null;
window.__fgStoriesObserver = new MutationObserver(() => {
// Debounce — only run after mutations settle, not on every single one
clearTimeout(_storiesTimer);
_storiesTimer = setTimeout(hideStories, 300);
});
window.__fgStoriesObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
})();
''';
/// 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 = """
a[href="/explore/"],
a[href="/explore"] {
display: none !important;
}
""";
const String kHideReelsTabCSS = """
a[href="/reels/"],
a[href="/reels"] {
display: none !important;
}
""";
const String kHideShopTabCSS = """
a[href*="/shop"],
a[href*="/shopping"] {
display: none !important;
}
""";
// ─── Complete Section Disabling (CSS-based) ─────────────────────────────────
// Minimal mode - disables Reels and Explore entirely
const String kMinimalModeCssScript = r'''
(function() {
const css = `
/* Hide Reels tab */
a[href="/reels/"], a[href="/reels"] { display: none !important; }
/* Hide Explore tab */
a[href="/explore/"], a[href="/explore"] { display: none !important; }
/* Hide Create tab */
a[href="/create/"], a[href="/create"] { display: none !important; }
/* Hide Reels in feed */
article a[href*="/reel/"] { display: none !important; }
/* Hide Explore entry points */
svg[aria-label="Explore"], [aria-label="Explore"] { display: none !important; }
`;
const style = document.createElement('style');
style.id = 'fg-minimal-mode';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
''';
// Disable Reels entirely
const String kDisableReelsEntirelyCssScript = r'''
(function() {
const css = `
a[href="/reels/"], a[href="/reels"] { display: none !important; }
article a[href*="/reel/"] { display: none !important; }
`;
const style = document.createElement('style');
style.id = 'fg-disable-reels';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
''';
// Disable Explore entirely
const String kDisableExploreEntirelyCssScript = r'''
(function() {
const css = `
a[href="/explore/"], a[href="/explore"] { display: none !important; }
svg[aria-label="Explore"], [aria-label="Explore"] { display: none !important; }
`;
const style = document.createElement('style');
style.id = 'fg-disable-explore';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
''';
// ─── DM-embedded Reels Scroll Control ────────────────────────────────────────
// Disables vertical scroll on reels opened from DM unless comment box or share modal is open
const String kDmReelScrollLockScript = r'''
(function() {
// Track scroll lock state
window.__fgDmReelScrollLocked = true;
window.__fgDmReelCommentOpen = false;
window.__fgDmReelShareOpen = false;
function lockScroll() {
if (window.__fgDmReelScrollLocked) {
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
}
}
function unlockScroll() {
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
}
function updateScrollState() {
// Only unlock if comment or share modal is open
if (window.__fgDmReelCommentOpen || window.__fgDmReelShareOpen) {
unlockScroll();
} else if (window.__fgDmReelScrollLocked) {
lockScroll();
}
}
// Listen for comment box opening/closing
function setupCommentObserver() {
const commentBox = document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
if (commentBox) {
window.__fgDmReelCommentOpen = true;
updateScrollState();
}
}
// Listen for share modal
function setupShareObserver() {
const shareModal = document.querySelector('div[role="dialog"][aria-label*="Share"], section[aria-label*="Share"]');
if (shareModal) {
window.__fgDmReelShareOpen = true;
updateScrollState();
}
}
// Set up MutationObserver to detect comment/share modals
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) {
const ariaLabel = node.getAttribute('aria-label') || '';
const role = node.getAttribute('role') || '';
// Check for comment box
if (ariaLabel.toLowerCase().includes('comment') ||
(role === 'dialog' && ariaLabel === '')) {
// Check if it's a comment dialog
setTimeout(function() {
window.__fgDmReelCommentOpen = !!document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
updateScrollState();
}, 100);
}
// Check for share modal
if (ariaLabel.toLowerCase().includes('share')) {
window.__fgDmReelShareOpen = true;
updateScrollState();
}
}
});
mutation.removedNodes.forEach(function(node) {
if (node.nodeType === 1) {
const ariaLabel = node.getAttribute('aria-label') || '';
if (ariaLabel.toLowerCase().includes('comment')) {
setTimeout(function() {
window.__fgDmReelCommentOpen = !!document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
updateScrollState();
}, 100);
}
if (ariaLabel.toLowerCase().includes('share')) {
window.__fgDmReelShareOpen = false;
updateScrollState();
}
}
});
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
// Initial lock
lockScroll();
// Expose functions for external control
window.__fgSetDmReelScrollLock = function(locked) {
window.__fgDmReelScrollLocked = locked;
updateScrollState();
};
})();
''';
// ─── JS-based (text-content detection, debounced) ─────────────────────────────
// Sponsored posts — scans for "Sponsored" text, debounced so it doesn't
// cause scroll jank on Instagram's constantly-mutating feed DOM.
const String kHideSponsoredPostsJS = r'''
(function() {
function hideSponsoredPosts() {
try {
document.querySelectorAll('article, li[role="listitem"]').forEach(function(el) {
try {
if (el.__fgSponsoredChecked) return; // skip already-processed elements
const spans = el.querySelectorAll('span');
for (let i = 0; i < spans.length; i++) {
const text = spans[i].textContent.trim();
if (text === 'Sponsored' || text === 'Paid partnership') {
el.style.setProperty('display', 'none', 'important');
return;
}
}
el.__fgSponsoredChecked = true; // mark as checked (non-sponsored)
} catch(_) {}
});
} catch(_) {}
}
hideSponsoredPosts();
if (!window.__fgSponsoredObserver) {
let _timer = null;
window.__fgSponsoredObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(hideSponsoredPosts, 300);
});
window.__fgSponsoredObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
})();
''';
// Suggested posts — debounced same way.
const String kHideSuggestedPostsJS = r'''
(function() {
function hideSuggestedPosts() {
try {
// Target text patterns that indicate suggested content
const suggestedPatterns = [
'Suggested for you',
'Suggested posts',
"You're all caught up",
'Suggested',
'Recommendations',
'Discover more',
'Suggested Accounts',
];
// Find and hide all elements with suggested content text
document.querySelectorAll('span, h3, h4, h2, a').forEach(function(el) {
try {
const text = el.textContent.trim();
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 (
tag === 'article' ||
tag === 'section' ||
tag === 'li' ||
classList.includes('xjx87jv0') || // Instagram suggestion container
classList.includes('x1a8lsjc') // Reel suggestion container
) {
parent.style.setProperty('display', 'none', 'important');
break;
}
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();
if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important');
break;
}
parent = parent.parentElement;
}
} catch(_) {}
});
} catch(_) {}
}
hideSuggestedPosts();
if (!window.__fgSuggestedObserver) {
let _timer = null;
window.__fgSuggestedObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(hideSuggestedPosts, 300);
});
window.__fgSuggestedObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
})();
''';
// ─── DM Reel Blocker ─────────────────────────────────────────────────────────
/// Overlays a "Reels are disabled" card on reel preview cards inside DMs.
///
/// DM reel previews use pushState (SPA) not <a href> navigation, so the CSS
/// display:none in kDisableReelsEntirelyCssScript doesn't remove the preview
/// card from the thread. This script finds them structurally and covers them
/// with a blocking overlay that also swallows all touch/click events.
///
/// Inject when disableReelsEntirely OR minimalMode is on.
const String kDmReelBlockerJS = r'''
(function() {
if (window.__fgDmReelBlockerRunning) return;
window.__fgDmReelBlockerRunning = true;
const BLOCKED_ATTR = 'data-fg-blocked';
function buildOverlay() {
const div = document.createElement('div');
div.setAttribute(BLOCKED_ATTR, '1');
div.style.cssText = [
'position:absolute',
'inset:0',
'z-index:99999',
'display:flex',
'flex-direction:column',
'align-items:center',
'justify-content:center',
'background:rgba(0,0,0,0.85)',
'border-radius:inherit',
'pointer-events:all',
'gap:8px',
'cursor:default',
].join(';');
const icon = document.createElement('span');
icon.textContent = '🚫';
icon.style.cssText = 'font-size:28px;line-height:1';
const label = document.createElement('span');
label.textContent = 'Reels are disabled';
label.style.cssText = [
'color:#fff',
'font-size:13px',
'font-weight:600',
'font-family:-apple-system,sans-serif',
'text-align:center',
'padding:0 12px',
].join(';');
const sub = document.createElement('span');
sub.textContent = 'Disable "Block Reels" in FocusGram settings';
sub.style.cssText = [
'color:rgba(255,255,255,0.5)',
'font-size:11px',
'font-family:-apple-system,sans-serif',
'text-align:center',
'padding:0 16px',
].join(';');
div.appendChild(icon);
div.appendChild(label);
div.appendChild(sub);
// Swallow all interaction so the reel beneath cannot be triggered
['click','touchstart','touchend','touchmove','pointerdown'].forEach(function(evt) {
div.addEventListener(evt, function(e) {
e.preventDefault();
e.stopImmediatePropagation();
}, { capture: true });
});
return div;
}
function overlayContainer(container) {
if (!container) return;
if (container.querySelector('[' + BLOCKED_ATTR + ']')) return; // already overlaid
container.style.position = 'relative';
container.style.overflow = 'hidden';
container.appendChild(buildOverlay());
}
function blockDmReels() {
try {
// Strategy 1: <a href*="/reel/"> links inside the DM thread
document.querySelectorAll('a[href*="/reel/"]').forEach(function(link) {
try {
link.style.setProperty('pointer-events', 'none', 'important');
overlayContainer(link.closest('div') || link.parentElement);
} catch(_) {}
});
// Strategy 2: <video> inside DMs (reel cards without <a> wrapper)
// Only targets videos inside the Direct thread or on /direct/ path
document.querySelectorAll('video').forEach(function(video) {
try {
const inDm = !!video.closest('[aria-label="Direct"], [aria-label*="Direct"]');
const isDmPath = window.location.pathname.includes('/direct/');
if (!inDm && !isDmPath) return;
const container = video.closest('div[class]') || video.parentElement;
if (!container) return;
video.style.setProperty('pointer-events', 'none', 'important');
overlayContainer(container);
} catch(_) {}
});
} catch(_) {}
}
blockDmReels();
let _t = null;
new MutationObserver(function() {
clearTimeout(_t);
_t = setTimeout(blockDmReels, 200);
}).observe(document.documentElement, { childList: true, subtree: true });
})();
''';
+571
View File
@@ -0,0 +1,571 @@
// Core JS and CSS payloads injected into the Instagram WebView.
//
// WARNING: Do not add any network interception logic ("ghost mode") here.
// All scripts in this file must be limited to UI behaviour, navigation helpers,
// and local-only features that do not modify data sent to Meta's servers.
// ── CSS payloads ──────────────────────────────────────────────────────────────
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
/// Important: we must NOT hide [role="tablist"] inside dialogs/modals,
/// because Instagram's comment input sheet also uses that role and the
/// CSS would paint a grey overlay on top of the typing area.
const String kGlobalUIFixesCSS = '''
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
-webkit-tap-highlight-color: transparent !important;
}
/* Only hide the PRIMARY nav tablist (bottom bar), not tablist inside dialogs */
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
[aria-label="Direct"] header {
display: none !important;
visibility: hidden !important;
height: 0 !important;
pointer-events: none !important;
}
''';
/// Blurs images/videos in the home feed AND on Explore.
/// Activated via the body[path] attribute written by the path tracker script.
const String kBlurHomeFeedAndExploreCSS = '''
body[path="/"] article img,
body[path="/"] article video,
body[path^="/explore"] img,
body[path^="/explore"] video,
body[path="/explore/"] img,
body[path="/explore/"] video {
filter: blur(20px) !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 video:hover,
body[path^="/explore"] img:hover,
body[path^="/explore"] video:hover {
filter: blur(20px) !important;
}
''';
/// Prevents text selection to keep the app feeling native.
const String kDisableSelectionCSS = '''
* { -webkit-user-select: none !important; user-select: none !important; }
''';
/// Hides reel posts in the home feed when no Reel Session is active.
/// The Reels nav tab is NOT hidden — Flutter intercepts that navigation.
const String kHideReelsFeedContentCSS = '''
a[href*="/reel/"],
div[data-media-type="2"] {
display: none !important;
visibility: hidden !important;
}
''';
/// Blurs reel thumbnails in the feed AND reel preview cards sent in DMs.
///
/// Feed reels are wrapped in a[href*="/reel/"] — straightforward.
/// DM reel previews are inline media cards NOT wrapped in a[href*="/reel/"],
/// so they need separate selectors targeting img/video inside [aria-label="Direct"].
/// Profile photos are excluded via :not([alt*="rofile"]) — covers both
/// "profile" and "Profile" without case-sensitivity workarounds.
const String kBlurReelsCSS = '''
a[href*="/reel/"] img {
filter: blur(12px) !important;
}
[aria-label="Direct"] img:not([alt*="rofile"]):not([alt=""]),
[aria-label="Direct"] video {
filter: blur(12px) !important;
}
''';
/// 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 ────────────────────────────────────────────────────────
/// Removes the "Open in App" nag banner.
const String kDismissAppBannerJS = '''
(function fgDismissBanner() {
['[id*="app-banner"]','[class*="app-banner"]',
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
})();
''';
/// Intercepts clicks on /reels/ links when no session is active and redirects
/// to a recognisable URL so Flutter's NavigationDelegate can catch and block it.
///
/// Without this, fast SPA clicks bypass the NavigationDelegate entirely.
const String kStrictReelsBlockJS = r'''
(function fgReelsBlock() {
if (window.__fgReelsBlockPatched) return;
window.__fgReelsBlockPatched = true;
document.addEventListener('click', e => {
if (window.__focusgramSessionActive) return;
const a = e.target && e.target.closest('a[href*="/reels/"]');
if (!a) return;
e.preventDefault();
e.stopPropagation();
window.location.href = '/reels/?fg=blocked';
}, true);
})();
''';
/// SPA route tracker: writes `body[path]` and notifies Flutter of path changes
/// via the `UrlChange` handler so reels can be blocked on SPA navigation.
const String kTrackPathJS = '''
(function fgTrackPath() {
if (window.__fgPathTrackerRunning) return;
window.__fgPathTrackerRunning = true;
let last = window.location.pathname;
function check() {
const p = window.location.pathname;
if (p !== last) {
last = p;
if (document.body) document.body.setAttribute('path', p);
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('UrlChange', p);
}
}
}
if (document.body) document.body.setAttribute('path', last);
setInterval(check, 500);
})();
''';
/// Detects Instagram's current theme (dark/light) and notifies Flutter.
const String kThemeDetectorJS = r'''
(function fgThemeSync() {
if (window.__fgThemeSyncRunning) return;
window.__fgThemeSyncRunning = true;
function getTheme() {
try {
// 1. Check Instagram's specific classes
const h = document.documentElement;
if (h.classList.contains('style-dark')) return 'dark';
if (h.classList.contains('style-light')) return 'light';
// 2. Check body background color
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'; // Fallback
}
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();
})();
''';
/// Prevents swipe-to-next-reel in the isolated DM reel player and when Reels
/// are blocked by FocusGram's session controls.
const String kReelsMutationObserverJS = r'''
(function fgReelLock() {
if (window.__fgReelLockRunning) return;
window.__fgReelLockRunning = true;
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
function lockMode() {
// Lock DM reels to prevent swipe-to-next, and optionally lock the home
// feed as a separate Minimal Mode control.
const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel';
if (window.__fgBlockHomeFeedScroll === true &&
(window.location.pathname === '/' || window.location.pathname === '')) {
return 'home_feed';
}
return null;
}
function isLocked() {
return lockMode() !== null;
}
function allowInteractionTarget(t) {
if (!t || !t.closest) return false;
if (t.closest('input,textarea,[contenteditable="true"]')) return true;
if (t.closest(MODAL_SEL)) return true;
return false;
}
let sy = 0;
document.addEventListener('touchstart', e => {
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
}, { capture: true, passive: true });
document.addEventListener('touchmove', e => {
if (!isLocked()) return;
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
if (Math.abs(dy) > 2) {
// Mark the first DM reel as loaded on first swipe attempt
if (window.location.pathname.includes('/direct/')) {
window.__fgDmReelAlreadyLoaded = true;
}
if (allowInteractionTarget(e.target)) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
}, { capture: true, passive: false });
function block(e) {
if (!isLocked()) return;
if (allowInteractionTarget(e.target)) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
document.addEventListener('wheel', block, { capture: true, passive: false });
document.addEventListener('keydown', e => {
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
block(e);
}, { capture: true, passive: false });
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
let __fgOrigHtmlOverflow = null;
let __fgOrigBodyOverflow = null;
function applyOverflowLock() {
try {
const mode = lockMode();
const hasReel = !!document.querySelector(REEL_SEL);
if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
if (__fgOrigHtmlOverflow === null) {
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
}
document.documentElement.style.overflow = 'hidden';
if (document.body) document.body.style.overflow = 'hidden';
} else if (__fgOrigHtmlOverflow !== null) {
document.documentElement.style.overflow = __fgOrigHtmlOverflow;
if (document.body) document.body.style.overflow = __fgOrigBodyOverflow || '';
__fgOrigHtmlOverflow = null;
__fgOrigBodyOverflow = null;
}
} catch (_) {}
}
function sync() {
const reels = document.querySelectorAll(REEL_SEL);
applyOverflowLock();
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
// Give the first reel 3.5 s to buffer before activating the DM lock
if (!window.__fgDmReelTimer) {
window.__fgDmReelTimer = setTimeout(() => {
if (document.querySelector(REEL_SEL)) {
window.__fgDmReelAlreadyLoaded = true;
}
window.__fgDmReelTimer = null;
}, 3500);
}
}
if (reels.length === 0) {
if (window.__fgDmReelTimer) {
clearTimeout(window.__fgDmReelTimer);
window.__fgDmReelTimer = null;
}
window.__fgDmReelAlreadyLoaded = false;
}
}
sync();
new MutationObserver(ms => {
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
}).observe(document.body, { childList: true, subtree: true });
// Keep __focusgramIsolatedPlayer in sync with SPA navigations
if (!window.__fgIsolatedPlayerSync) {
window.__fgIsolatedPlayerSync = true;
let _lastPath = window.location.pathname;
setInterval(() => {
const p = window.location.pathname;
if (p === _lastPath) return;
_lastPath = p;
window.__focusgramIsolatedPlayer =
p.includes('/reel/') && !p.startsWith('/reels');
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
applyOverflowLock();
}, 400);
}
})();
''';
/// Periodically checks Instagram's UI for unread counts (badges) on the Direct
/// and Notifications icons, as well as the page title. Sends an event to
/// Flutter whenever a new notification is detected.
const String kBadgeMonitorJS = r'''
(function fgBadgeMonitor() {
if (window.__fgBadgeMonitorRunning) return;
window.__fgBadgeMonitorRunning = true;
const startedAt = Date.now();
let initialised = false;
let lastDmCount = 0;
let lastNotifCount = 0;
let lastTitleUnread = 0;
function parseBadgeCount(el) {
if (!el) return 0;
try {
const raw = (el.innerText || el.textContent || '').trim();
const n = parseInt(raw, 10);
return isNaN(n) ? 1 : n;
} catch (_) {
return 1;
}
}
function check() {
try {
// 1. Check Title for (N) indicator
const titleMatch = document.title.match(/\((\d+)\)/);
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
// 2. Scan for DM unread badge
const dmBadge = document.querySelector([
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
'a[href*="/direct/inbox/"] [style*="255, 48, 64"]',
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
'div[role="button"][aria-label*="Direct"] [style*="255, 48, 64"]',
'a[href*="/direct/inbox/"] svg[aria-label*="Direct"] + div',
'a[href*="/direct/inbox/"] ._a9-v',
].join(','));
const currentDmCount = parseBadgeCount(dmBadge);
// 3. Scan for Notifications unread badge
const notifBadge = document.querySelector([
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
'a[href*="/notifications"] [style*="255, 48, 64"]',
'a[href*="/notifications"] [aria-label*="unread"]'
].join(','));
const currentNotifCount = parseBadgeCount(notifBadge);
// Establish baseline on first run and suppress false positives right after reload.
if (!initialised) {
lastDmCount = currentDmCount;
lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
initialised = true;
return;
}
if (Date.now() - startedAt < 6000) {
lastDmCount = currentDmCount;
lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
return;
}
if (currentDmCount > lastDmCount) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'DM');
}
} else if (currentNotifCount > lastNotifCount) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
}
} else if (currentTitleUnread > lastTitleUnread && currentTitleUnread > (currentDmCount + currentNotifCount)) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
}
}
lastDmCount = currentDmCount;
lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
} catch(_) {}
}
// Initial check after some delay to let page settle
setTimeout(check, 2000);
setInterval(check, 1000);
})();
''';
/// Forwards Web Notification events to the native Flutter channel.
const String kNotificationBridgeJS = '''
(function fgNotifBridge() {
if (!window.Notification || window.__fgNotifBridged) return;
window.__fgNotifBridged = true;
const startedAt = Date.now();
const _N = window.Notification;
window.Notification = function(title, opts) {
try {
// Avoid false positives on reload / initial bootstrap.
if (Date.now() - startedAt < 6000) {
return new _N(title, opts);
}
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'FocusGramNotificationChannel',
title + (opts && opts.body ? ': ' + opts.body : ''),
);
}
} catch(_) {}
return new _N(title, opts);
};
window.Notification.permission = 'granted';
window.Notification.requestPermission = () => Promise.resolve('granted');
})();
''';
/// Strips tracking query params (igsh, utm_*, fbclid…) from all links and the
/// native Web Share API. Sanitised share URLs are routed to Flutter's share
/// channel instead.
const String kLinkSanitizationJS = r'''
(function fgSanitize() {
if (window.__fgSanitizePatched) return;
window.__fgSanitizePatched = true;
const STRIP = [
'igsh','igshid','fbclid',
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
'ref','s','_branch_match_id','_branch_referrer',
];
function clean(raw) {
try {
const u = new URL(raw, location.origin);
STRIP.forEach(p => u.searchParams.delete(p));
return u.toString();
} catch(_) { return raw; }
}
if (navigator.share) {
const _s = navigator.share.bind(navigator);
navigator.share = function(d) {
const u = d && d.url ? clean(d.url) : null;
if (window.flutter_inappwebview && u) {
window.flutter_inappwebview.callHandler(
'FocusGramShareChannel',
JSON.stringify({ url: u, title: (d && d.title) || '' }),
);
return Promise.resolve();
}
return _s({ ...d, url: u || (d && d.url) });
};
}
document.addEventListener('click', e => {
const a = e.target && e.target.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
try {
const u = new URL(href, location.origin);
if (STRIP.some(p => u.searchParams.has(p))) {
STRIP.forEach(p => u.searchParams.delete(p));
a.href = u.toString();
}
} catch(_) {}
}, true);
})();
''';
+15
View File
@@ -0,0 +1,15 @@
/// JS to help Instagram's layout detect viewport changes when the Android
/// soft keyboard appears in a WebView container.
///
/// It listens for resize events and re-dispatches an `orientationchange`
/// event, which nudges Instagram's layout system out of the DM loading
/// spinner state.
const String kDmKeyboardFixJS = r'''
// Fix: tell Instagram's layout system the viewport has changed after keyboard events
// This resolves the loading state that appears on DM screens in WebView
window.addEventListener('resize', function() {
try {
window.dispatchEvent(new Event('orientationchange'));
} catch (_) {}
});
''';
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../focus_settings.dart';
// Ghost Mode
const String ghostModeJS = '''
const _WS = window.WebSocket;
window.WebSocket = function(url, protocols) {
if (url.includes('edge-chat.instagram.com') ||
url.includes('gateway.instagram.com')) {
return {
send: ()=>{}, close: ()=>{},
readyState: 1,
addEventListener: ()=>{},
removeEventListener: ()=>{},
};
}
return new _WS(url, protocols);
};
window.WebSocket.prototype = _WS.prototype;
''';
// No Story Tray
const String hideStoryTrayJS = '''
const style = document.createElement('style');
style.textContent = '[data-pagelet="story_tray"] { display: none !important; }';
document.head.appendChild(style);
''';
// No Autoplay
const String noAutoplayJS = '''
document.addEventListener('play', function(e) {
if (e.target.tagName === 'VIDEO') {
e.target.pause();
}
}, true);
''';
// No Reels / Explore
const String hideReelsJS = '''
const hideReels = () => {
// nav bar reels icon
document.querySelectorAll('a[href="/reels/"]').forEach(el => {
el.closest('div')?.style.setProperty('display', 'none', 'important');
});
// explore page
document.querySelectorAll('a[href="/explore/"]').forEach(el => {
el.closest('div')?.style.setProperty('display', 'none', 'important');
});
};
new MutationObserver(hideReels).observe(document.body, {
childList: true,
subtree: true
});
hideReels();
''';
// No DMs
const String hideDMsJS = '''
const style = document.createElement('style');
style.textContent = 'a[href="/direct/inbox/"] { display: none !important; }';
document.head.appendChild(style);
''';
List<UserScript> buildUserScripts(FocusSettings settings) {
final startScripts = <String>[];
final endScripts = <String>[];
// AT_DOCUMENT_START scripts
if (settings.ghostMode) startScripts.add(ghostModeJS);
if (settings.noAutoplay) startScripts.add(noAutoplayJS);
// AT_DOCUMENT_END scripts
if (settings.noStories) endScripts.add(hideStoryTrayJS);
if (settings.noReels) endScripts.add(hideReelsJS);
if (settings.noDMs) endScripts.add(hideDMsJS);
final scripts = <UserScript>[];
if (startScripts.isNotEmpty) {
scripts.add(
UserScript(
source: startScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: false,
),
);
}
if (endScripts.isNotEmpty) {
scripts.add(
UserScript(
source: endScripts.join('\n'),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
forMainFrameOnly: true,
),
);
}
return scripts;
}
+48
View File
@@ -0,0 +1,48 @@
/// Grayscale style injector.
/// Uses a <style> tag with !important so Instagram's CSS cannot override it.
const String kGrayscaleJS = r'''
(function fgGrayscale() {
try {
const ID = 'fg-grayscale';
function inject() {
let el = document.getElementById(ID);
if (!el) {
el = document.createElement('style');
el.id = ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = 'html { filter: grayscale(100%) !important; }';
}
inject();
if (!window.__fgGrayscaleObserver) {
window.__fgGrayscaleObserver = new MutationObserver(() => {
if (!document.getElementById('fg-grayscale')) inject();
});
window.__fgGrayscaleObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
} catch (_) {}
})();
''';
/// Removes grayscale AND disconnects the observer so it cannot re-add it.
/// Previously kGrayscaleOffJS only removed the style tag — the observer
/// immediately re-injected it, requiring an app restart to actually go off.
const String kGrayscaleOffJS = r'''
(function() {
try {
// 1. Disconnect the observer FIRST so it cannot react to the removal
if (window.__fgGrayscaleObserver) {
window.__fgGrayscaleObserver.disconnect();
window.__fgGrayscaleObserver = null;
}
// 2. Remove the style tag
const el = document.getElementById('fg-grayscale');
if (el) el.remove();
// 3. Clear any inline filter that may have been set by older code
document.documentElement.style.filter = '';
} catch (_) {}
})();
''';
+11
View File
@@ -0,0 +1,11 @@
const String kHapticBridgeScript = '''
(function() {
// Trigger native haptic feedback on double-tap (like gesture on posts)
// Uses flutter_inappwebview's callHandler instead of postMessage
document.addEventListener('dblclick', function(e) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('Haptic', 'light');
}
}, true);
})();
''';
+68
View File
@@ -0,0 +1,68 @@
// Document-start script — injected before Instagram's JS loads.
const String kNativeFeelingScript = '''
(function() {
const style = document.createElement('style');
style.id = 'fg-native-feel';
style.textContent = `
/* Hide all scrollbars */
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
}
*::-webkit-scrollbar {
display: none !important;
}
/* Remove blue tap highlight */
* {
-webkit-tap-highlight-color: transparent !important;
}
/* Disable text selection globally except inputs */
* {
-webkit-user-select: none !important;
user-select: none !important;
}
input, textarea, [contenteditable="true"] {
-webkit-user-select: text !important;
user-select: text !important;
}
/* Momentum scrolling */
* {
-webkit-overflow-scrolling: touch !important;
}
/* Remove focus outlines */
*:focus, *:focus-visible {
outline: none !important;
}
/* Fade images in */
img {
animation: igFadeIn 0.15s ease-in-out;
}
@keyframes igFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`;
if (document.head) {
document.head.appendChild(style);
} else {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild(style);
});
}
})();
''';
// Post-load script — call in onLoadStop only.
// IMPORTANT: Do NOT add overscroll-behavior rules here — they lock the feed scroll.
const String kNativeFeelingPostLoadScript = '''
(function() {
// Smooth anchor scrolling only — do NOT apply to all containers.
document.documentElement.style.scrollBehavior = 'auto';
})();
''';
+118
View File
@@ -0,0 +1,118 @@
// Reel metadata extraction for history feature.
// Extracts title and thumbnail URL from the page and sends to Flutter.
const String kReelMetadataExtractorScript = r'''
(function() {
// Track if we've already extracted for this URL to avoid duplicates
window.__fgReelExtracted = window.__fgReelExtracted || false;
window.__fgLastExtractedUrl = window.__fgLastExtractedUrl || '';
function extractAndSend() {
const currentUrl = window.location.href;
// Skip if already extracted for this URL
if (window.__fgReelExtracted && window.__fgLastExtractedUrl === currentUrl) {
return;
}
// Check if this is a reel page
if (!currentUrl.includes('/reel/')) {
return;
}
// Try multiple sources for metadata
let title = '';
let thumbnailUrl = '';
// 1. Try Open Graph tags
const ogTitle = document.querySelector('meta[property="og:title"]');
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogTitle) title = ogTitle.content;
if (ogImage) thumbnailUrl = ogImage.content;
// 2. Fallback to document title if no OG title
if (!title && document.title) {
title = document.title.replace(' on Instagram', '').trim();
if (!title) title = 'Instagram Reel';
}
// 3. Try JSON-LD structured data
if (!thumbnailUrl) {
const jsonLdScripts = document.querySelectorAll('script[type="application/ld+json"]');
jsonLdScripts.forEach(function(script) {
try {
const data = JSON.parse(script.textContent);
if (data.image) {
if (Array.isArray(data.image)) {
thumbnailUrl = data.image[0];
} else if (typeof data.image === 'string') {
thumbnailUrl = data.image;
} else if (data.image.url) {
thumbnailUrl = data.image.url;
}
}
} catch(e) {}
});
}
// 4. Try Twitter card as fallback
if (!thumbnailUrl) {
const twitterImage = document.querySelector('meta[name="twitter:image"]');
if (twitterImage) thumbnailUrl = twitterImage.content;
}
// Skip if no thumbnail found
if (!thumbnailUrl) {
return;
}
// Mark as extracted
window.__fgReelExtracted = true;
window.__fgLastExtractedUrl = currentUrl;
// Send to Flutter
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'ReelMetadata',
JSON.stringify({
url: currentUrl,
title: title || 'Instagram Reel',
thumbnailUrl: thumbnailUrl
})
);
}
}
// Run immediately in case metadata is already loaded
extractAndSend();
// Set up MutationObserver to detect page changes and metadata loading
if (!window.__fgReelObserver) {
let debounceTimer = null;
window.__fgReelObserver = new MutationObserver(function(mutations) {
// Debounce to avoid excessive calls
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
extractAndSend();
}, 500);
});
window.__fgReelObserver.observe(document.documentElement, {
childList: true,
subtree: true
});
}
// Also listen for URL changes (SPA navigation)
let lastUrl = location.href;
setInterval(function() {
if (location.href !== lastUrl) {
lastUrl = location.href;
window.__fgReelExtracted = false;
window.__fgLastExtractedUrl = '';
extractAndSend();
}
}, 1000);
})();
''';
+12
View File
@@ -0,0 +1,12 @@
/// JS to improve momentum scrolling behaviour inside the WebView, especially
/// for content-heavy feeds like Reels.
///
/// Applies touch-style overflow scrolling hints to the root element.
const String kScrollSmoothingJS = r'''
(function fgScrollSmoothing() {
try {
document.documentElement.style.setProperty('-webkit-overflow-scrolling', 'touch');
document.documentElement.style.setProperty('overflow-scrolling', 'touch');
} catch (_) {}
})();
''';
+31
View File
@@ -0,0 +1,31 @@
const String kSpaNavigationMonitorScript = '''
(function() {
// Monitor Instagram's SPA navigation and notify Flutter on every URL change.
// Instagram uses history.pushState — onLoadStop won't fire for these transitions.
// This is injected at document start so it wraps pushState before Instagram does.
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
function notifyUrlChange(url) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'UrlChange',
url || window.location.href
);
}
}
history.pushState = function() {
originalPushState.apply(this, arguments);
setTimeout(() => notifyUrlChange(arguments[2]), 100);
};
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
setTimeout(() => notifyUrlChange(arguments[2]), 100);
};
window.addEventListener('popstate', () => notifyUrlChange());
})();
''';
+263
View File
@@ -0,0 +1,263 @@
// UI element hiding for Instagram web.
//
// SCROLL LOCK WARNING:
// MutationObserver callbacks with {childList:true, subtree:true} fire hundreds
// of times/sec on Instagram's infinite scroll feed. Expensive querySelectorAll
// calls inside these callbacks block the main thread = scroll jank.
// All JS hiders below use a 300ms debounce so they run only after mutations settle.
// ─── CSS-based ────────────────────────────────────────────────────────────────
// FIX: Like count CSS.
// Instagram's like BUTTON has aria-label="Like" (the verb) — NOT the count.
// [role="button"][aria-label$=" likes"] never matches anything.
// The COUNT lives in a[href*="/liked_by/"] (e.g. "1,234 likes" link).
// We hide that link. The JS hider below catches React-rendered span variants.
const String kHideLikeCountsCSS = '''
a[href*="/liked_by/"],
section a[href*="/liked_by/"] {
display: none !important;
}
''';
const String kHideFollowerCountsCSS = '''
a[href*="/followers/"] span,
a[href*="/following/"] span {
opacity: 0 !important;
pointer-events: none !important;
}
''';
// Stories bar CSS — multiple selectors for different Instagram DOM versions.
// :has() is supported in WebKit (Instagram's engine). Targets the container,
// not individual story items which is what [aria-label*="Stories"] matches.
const String kHideStoriesBarCSS = '''
[aria-label*="Stories"],
[aria-label*="stories"],
[role="list"]:has([aria-label*="tory"]),
[role="listbox"]:has([aria-label*="tory"]),
div[style*="overflow"][style*="scroll"]:has(canvas),
section > div > div[style*="overflow-x"] {
display: none !important;
}
''';
const String kHideExploreTabCSS = '''
a[href="/explore/"],
a[href="/explore"] {
display: none !important;
}
''';
const String kHideReelsTabCSS = '''
a[href="/reels/"],
a[href="/reels"] {
display: none !important;
}
''';
const String kHideShopTabCSS = '''
a[href*="/shop"],
a[href*="/shopping"] {
display: none !important;
}
''';
// ─── JS-based ─────────────────────────────────────────────────────────────────
// Like counts — JS fallback for React-rendered count spans not caught by CSS.
// Scans for text matching "1,234 likes" / "12.3K views" patterns.
const String kHideLikeCountsJS = r'''
(function() {
function hideLikeCounts() {
try {
// Hide liked_by links and their immediate parent wrapper
document.querySelectorAll('a[href*="/liked_by/"]').forEach(function(el) {
try {
el.style.setProperty('display', 'none', 'important');
// Also hide the parent span/div that wraps the count text
if (el.parentElement) {
el.parentElement.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Scan spans for numeric like/view count text patterns
document.querySelectorAll('span').forEach(function(el) {
try {
const text = el.textContent.trim();
// Matches: "1,234 likes", "12.3K views", "1 like", "45 views", etc.
if (/^[\d,.]+[KkMm]?\s+(like|likes|view|views)$/.test(text)) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
} catch(_) {}
}
hideLikeCounts();
if (!window.__fgLikeCountObserver) {
let _t = null;
window.__fgLikeCountObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideLikeCounts, 300);
});
window.__fgLikeCountObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Stories bar JS — structural detection when CSS selectors don't match.
// Two strategies:
// 1. aria-label scan on role=list/listbox elements
// 2. BoundingClientRect check: story circles are square, narrow (<120px), appear in a row
const String kHideStoriesBarJS = r'''
(function() {
function hideStories() {
try {
// Strategy 1: aria-label on list containers
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stor')) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Strategy 2: BoundingClientRect — story circles are narrow square items in a row.
// Look for a <ul> or <div role=list> whose first child is roughly square and < 120px wide.
document.querySelectorAll('ul, [role="list"]').forEach(function(el) {
try {
const items = el.children;
if (items.length < 3) return;
const first = items[0].getBoundingClientRect();
// Story item: small, roughly square (width ≈ height), near top of viewport
if (
first.width > 0 &&
first.width < 120 &&
Math.abs(first.width - first.height) < 20 &&
first.top < 300
) {
el.style.setProperty('display', 'none', 'important');
// Also hide the section wrapping this if it has no article (pure stories row)
const section = el.closest('section, div[class]');
if (section && !section.querySelector('article')) {
section.style.setProperty('display', 'none', 'important');
}
}
} catch(_) {}
});
// Strategy 3: horizontal overflow container before any article in the feed
document.querySelectorAll('main > div > div > div').forEach(function(container) {
try {
if (container.querySelector('article')) return;
const inner = container.querySelector('div, ul');
if (!inner) return;
const s = window.getComputedStyle(inner);
if (s.overflowX === 'scroll' || s.overflowX === 'auto') {
container.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
} catch(_) {}
}
hideStories();
if (!window.__fgStoriesObserver) {
let _t = null;
window.__fgStoriesObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideStories, 300);
});
window.__fgStoriesObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Sponsored posts — scans article elements for "Sponsored" text child.
// CSS cannot traverse from child text up to parent — JS only.
const String kHideSponsoredPostsJS = r'''
(function() {
function hideSponsoredPosts() {
try {
document.querySelectorAll('article, li[role="listitem"]').forEach(function(el) {
try {
if (el.__fgSponsoredChecked) return;
const spans = el.querySelectorAll('span');
for (let i = 0; i < spans.length; i++) {
const text = spans[i].textContent.trim();
if (text === 'Sponsored' || text === 'Paid partnership') {
el.style.setProperty('display', 'none', 'important');
return;
}
}
el.__fgSponsoredChecked = true;
} catch(_) {}
});
} catch(_) {}
}
hideSponsoredPosts();
if (!window.__fgSponsoredObserver) {
let _t = null;
window.__fgSponsoredObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideSponsoredPosts, 300);
});
window.__fgSponsoredObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Suggested posts — scans for heading text, walks up to parent article/section.
const String kHideSuggestedPostsJS = r'''
(function() {
function hideSuggestedPosts() {
try {
document.querySelectorAll('span, h3, h4').forEach(function(el) {
try {
const text = el.textContent.trim();
if (
text === 'Suggested for you' ||
text === 'Suggested posts' ||
text === "You're all caught up"
) {
let parent = el.parentElement;
for (let i = 0; i < 8 && parent; i++) {
const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important');
break;
}
parent = parent.parentElement;
}
}
} catch(_) {}
});
} catch(_) {}
}
hideSuggestedPosts();
if (!window.__fgSuggestedObserver) {
let _t = null;
window.__fgSuggestedObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideSuggestedPosts, 300);
});
window.__fgSuggestedObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
+355
View File
@@ -0,0 +1,355 @@
/// Best-effort Instagram media downloader UI.
///
/// The script only exposes URLs already rendered in the WebView. It cannot
/// decrypt or fetch media that Instagram has not loaded, but it covers visible
/// feed posts, reels, profile avatars, and DM visual/video messages.
const String kVideoDownloadJS = r'''
(function() {
'use strict';
if (window.__fgMediaDownloadRunning) return;
window.__fgMediaDownloadRunning = true;
const BTN_ATTR = 'data-fg-download-btn';
const URL_ATTR = 'data-fg-download-url';
const TYPE_ATTR = 'data-fg-download-type';
const MAX_PER_PASS = 60;
function text(value) {
try { return (value || '').toString(); } catch (_) { return ''; }
}
function isHttp(value) {
const s = text(value);
return s.indexOf('https://') === 0 || s.indexOf('http://') === 0;
}
function cleanUrl(value) {
const s = text(value).trim();
if (!isHttp(s)) return null;
return s.replace(/&amp;/g, '&');
}
function bestFromSrcset(srcset) {
const raw = text(srcset);
if (!raw) return null;
let best = null;
let bestScore = -1;
raw.split(',').forEach(function(part) {
const bits = part.trim().split(/\s+/);
const url = cleanUrl(bits[0]);
if (!url) return;
const score = parseFloat(text(bits[1]).replace(/[^\d.]/g, '')) || 1;
if (score >= bestScore) {
bestScore = score;
best = url;
}
});
return best;
}
function backgroundUrl(el) {
try {
const bg = window.getComputedStyle(el).backgroundImage || '';
const match = bg.match(/url\(["']?(.*?)["']?\)/);
return match ? cleanUrl(match[1]) : null;
} catch (_) {
return null;
}
}
function urlFromJsonishAttribute(el) {
const attrs = ['data-store', 'data-props', 'data-visualcompletion'];
for (let i = 0; i < attrs.length; i++) {
const value = text(el.getAttribute && el.getAttribute(attrs[i]));
const match = value.match(/https?:\\?\/\\?\/[^"'\s\\]+/);
if (match) return cleanUrl(match[0].replace(/\\\//g, '/'));
}
return null;
}
function mediaUrl(el) {
if (!el) return null;
const tag = text(el.tagName).toLowerCase();
if (tag === 'video') {
return cleanUrl(el.currentSrc || el.src) ||
cleanUrl(el.getAttribute('src')) ||
cleanUrl(el.getAttribute('poster')) ||
firstSource(el);
}
if (tag === 'img') {
return cleanUrl(el.currentSrc || el.src) ||
bestFromSrcset(el.getAttribute('srcset')) ||
cleanUrl(el.getAttribute('src'));
}
return backgroundUrl(el) || urlFromJsonishAttribute(el);
}
function firstSource(video) {
try {
const sources = video.querySelectorAll('source');
for (let i = 0; i < sources.length; i++) {
const url = cleanUrl(sources[i].src || sources[i].getAttribute('src'));
if (url) return url;
}
} catch (_) {}
return null;
}
function typeFrom(el, url) {
const tag = text(el && el.tagName).toLowerCase();
const u = text(url).toLowerCase();
if (tag === 'video' || u.indexOf('.mp4') >= 0 || u.indexOf('.m3u8') >= 0) {
return 'video';
}
return 'photo';
}
function looksLikeAvatar(el) {
try {
const img = el && el.tagName && el.tagName.toLowerCase() === 'img' ? el : null;
if (!img) return false;
const alt = text(img.getAttribute('alt')).toLowerCase();
const r = img.getBoundingClientRect();
const rounded =
window.getComputedStyle(img).borderRadius.indexOf('%') >= 0 ||
parseFloat(window.getComputedStyle(img).borderRadius) >= Math.min(r.width, r.height) / 3;
return r.width <= 72 && r.height <= 72 && (rounded || alt.indexOf('profile') >= 0 || alt.indexOf('avatar') >= 0);
} catch (_) {
return false;
}
}
function mediaScore(item) {
try {
const r = item.el.getBoundingClientRect();
let score = Math.max(0, r.width) * Math.max(0, r.height);
if (item.type === 'video') score += 10000000;
if (looksLikeAvatar(item.el)) score -= 10000000;
if (text(item.url).toLowerCase().indexOf('s150x150') >= 0) score -= 5000000;
return score;
} catch (_) {
return 0;
}
}
function filename(type) {
const ext = type === 'video' ? 'mp4' : 'jpg';
return 'focusgram_' + type + '_' + Date.now() + '.' + ext;
}
function inView(el) {
try {
const r = el.getBoundingClientRect();
return r.width > 24 && r.height > 24 && r.bottom > 0 && r.top < window.innerHeight;
} catch (_) {
return false;
}
}
function icon() {
return '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
}
function sendDownload(url, type) {
try {
if (!url || !window.flutter_inappwebview || !window.flutter_inappwebview.callHandler) return;
window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON.stringify({
type: type,
url: url,
filename: filename(type),
}));
} catch (_) {}
}
function makeButton(url, type, mode) {
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute(BTN_ATTR, '1');
btn.setAttribute(URL_ATTR, url);
btn.setAttribute(TYPE_ATTR, type);
btn.setAttribute('aria-label', 'Download media');
btn.innerHTML = icon();
btn.style.cssText = [
'position:absolute',
'z-index:999',
'width:34px',
'height:34px',
'border-radius:10px',
'border:1px solid rgba(255,255,255,.18)',
'background:' + (mode === 'inline' ? 'transparent' : 'rgba(0,0,0,.58)'),
'color:rgba(255,255,255,.94)',
'display:flex',
'align-items:center',
'justify-content:center',
'padding:0',
'cursor:pointer',
'pointer-events:auto',
'backdrop-filter:blur(8px)',
'-webkit-backdrop-filter:blur(8px)',
].join(';');
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
sendDownload(btn.getAttribute(URL_ATTR), btn.getAttribute(TYPE_ATTR) || type);
}, true);
return btn;
}
function ensureRelative(container) {
try {
const pos = window.getComputedStyle(container).position;
if (!pos || pos === 'static') container.style.position = 'relative';
} catch (_) {}
}
function placeNearSave(article, url, type) {
const ref = article.querySelector([
'button[aria-label*="Save" i]',
'button[aria-label*="Bookmark" i]',
'svg[aria-label*="Save" i]',
'svg[aria-label*="Bookmark" i]',
'a[href*="/save"]',
].join(','));
if (!ref) return false;
const target = ref.closest('button,a,div') || ref;
const bar = target.parentElement || article;
if (bar.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
const btn = makeButton(url, type, 'inline');
btn.style.position = 'relative';
btn.style.inset = 'auto';
btn.style.marginLeft = '8px';
btn.style.color = 'currentColor';
btn.style.border = '0';
btn.style.backdropFilter = 'none';
btn.style.webkitBackdropFilter = 'none';
try {
target.insertAdjacentElement('afterend', btn);
return true;
} catch (_) {
return false;
}
}
function placeOverlay(container, url, type, where) {
if (!container || container.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
ensureRelative(container);
const btn = makeButton(url, type, 'overlay');
if (where === 'reel') {
btn.style.top = '12px';
btn.style.right = '12px';
} else if (where === 'profile') {
btn.style.top = '8px';
btn.style.right = '8px';
} else {
btn.style.right = '10px';
btn.style.bottom = '10px';
}
container.appendChild(btn);
return true;
}
function visibleMedia(root) {
return Array.prototype.slice.call(root.querySelectorAll('video,img,[style*="background-image"]'))
.filter(inView)
.map(function(el) {
const url = mediaUrl(el);
return url ? { el: el, url: url, type: typeFrom(el, url) } : null;
})
.filter(Boolean);
}
function handleFeed() {
let added = 0;
document.querySelectorAll('article').forEach(function(article) {
if (added >= MAX_PER_PASS || article.querySelector('[' + BTN_ATTR + '="1"]')) return;
const media = visibleMedia(article)
.filter(function(item) { return !looksLikeAvatar(item.el); })
.sort(function(a, b) { return mediaScore(b) - mediaScore(a); })[0];
if (!media) return;
if (placeNearSave(article, media.url, media.type) ||
placeOverlay(article, media.url, media.type, 'feed')) {
added++;
}
});
return added;
}
function handleReels() {
let added = 0;
visibleMedia(document).forEach(function(media) {
if (added >= MAX_PER_PASS) return;
const container =
media.el.closest('[class*="ReelsVideoPlayer"]') ||
media.el.closest('article') ||
media.el.closest('[role="presentation"]') ||
media.el.parentElement;
if (placeOverlay(container, media.url, media.type, 'reel')) added++;
});
return added;
}
function handleDirect() {
let added = 0;
visibleMedia(document).forEach(function(media) {
if (added >= MAX_PER_PASS) return;
const bubble =
media.el.closest('[role="button"]') ||
media.el.closest('div[style*="max-width"]') ||
media.el.closest('article') ||
media.el.parentElement;
if (placeOverlay(bubble, media.url, media.type, 'dm')) added++;
});
return added;
}
function handleProfile() {
let added = 0;
const path = window.location.pathname || '/';
if (path === '/' || path.indexOf('/explore') === 0 || path.indexOf('/direct') === 0) return 0;
document.querySelectorAll('header img,img[alt*="profile" i],img[alt*="avatar" i]').forEach(function(img) {
if (added >= 4 || !inView(img)) return;
const url = mediaUrl(img);
if (!url) return;
const r = img.getBoundingClientRect();
if (r.width < 56 && r.height < 56) return;
const container = img.closest('div') || img.parentElement;
if (placeOverlay(container, url, 'photo', 'profile')) added++;
});
return added;
}
function pass() {
try {
const path = window.location.pathname || '/';
if (path.indexOf('/direct') === 0) {
handleDirect();
} else if (path.indexOf('/reels') === 0 || path.indexOf('/reel/') >= 0) {
handleReels();
} else {
handleFeed();
handleProfile();
}
} catch (_) {}
}
let timer = null;
function schedule() {
clearTimeout(timer);
timer = setTimeout(pass, 220);
}
new MutationObserver(schedule).observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'srcset', 'style'],
});
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule, { passive: true });
window.addEventListener('focus', schedule, { passive: true });
pass();
})();
''';
@@ -0,0 +1,430 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class AdblockContentBlockerData {
final List<ContentBlocker> contentBlockers;
final Set<String> blockedHosts;
final String sourceTag;
const AdblockContentBlockerData({
required this.contentBlockers,
required this.blockedHosts,
required this.sourceTag,
});
Map<String, dynamic> toJson() => {
'sourceTag': sourceTag,
'hosts': blockedHosts.toList(),
// We 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(),
};
}
+45 -741
View File
@@ -1,245 +1,21 @@
// ============================================================================
// FocusGram — InjectionController
// ============================================================================
//
// Builds all JavaScript and CSS payloads injected into the Instagram WebView.
//
// ── Ghost Mode Design ────────────────────────────────────────────────────────
//
// Instead of blocking exact URLs (brittle — Instagram renames paths constantly),
// we block by SEMANTIC KEYWORD GROUPS. A request is silenced if its URL contains
// ANY keyword from the relevant group.
//
// Ghost Mode Semantic Groups (last verified: 2025-02)
// ────────────────────────────────────────────────────
// seenKeywords — story/DM seen receipts (any endpoint Instagram uses to
// tell others you read/watched something)
// typingKeywords — typing indicator REST calls + WS text frames
// liveKeywords — live viewer heartbeat / join_request (presence on streams)
// photoKeywords — disappearing / view-once DM photo seen receipts
//
// Adding new endpoints in the future: just append a keyword to the right group
// in _ghostGroups below — no other code needs to change.
//
// ── Confirmed endpoint map ───────────────────────────────────────────────────
// /api/v1/media/seen/ — story seen v1 (covered by "media/seen")
// /api/v2/media/seen/ — story seen v2 (covered by "media/seen")
// /stories/reel/seen — web story seen (covered by "reel/seen")
// /api/v1/stories/reel/mark_seen/ — story mark (covered by "mark_seen")
// /direct_v2/threads/…/seen/ — DM message read (covered by "/seen")
// /api/v1/direct_v2/set_reel_seen/ — DM story (covered by "reel_seen")
// /api/v1/direct_v2/mark_visual_item_seen/ — disappearing photos
// /api/v1/live/…/heartbeat_and_get_viewer_count/ — live presence
// /api/v1/live/…/join_request/ — live join
// WS text frames with "typing", "direct_v2/typing", "activity_status"
//
// ============================================================================
/// Central hub for all JavaScript and CSS injected into the Instagram WebView.
import '../scripts/core_injection.dart' as scripts;
import '../scripts/ui_hider.dart' as ui_hider;
class InjectionController {
// ── User Agent ──────────────────────────────────────────────────────────────
/// iOS UA ensures Instagram serves the full mobile UI (Reels, Stories, DMs).
/// Without spoofing, instagram.com returns a stripped desktop-lite shell.
static const String iOSUserAgent =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
'Mobile/22G86 [FBAN/FBIOS;FBAV/531.0.0.35.77;FBBV/792629356;'
'FBDV/iPhone17,2;FBMD/iPhone;FBSN/iOS;FBSV/18.6;FBSS/3;'
'FBID/phone;FBLC/en_US;FBOP/5;FBRV/0;IABMV/1]';
'Version/26.0 Mobile/15E148 Safari/604.1';
// ── Ghost Mode keyword groups ────────────────────────────────────────────────
static const String reelsMutationObserverJS =
scripts.kReelsMutationObserverJS;
static const String linkSanitizationJS = scripts.kLinkSanitizationJS;
static String get notificationBridgeJS => scripts.kNotificationBridgeJS;
/// Semantic groups used by [buildGhostModeJS].
///
/// Each group is a list of URL substrings. A network request is suppressed
/// if its URL contains ANY substring in the enabled groups.
///
/// To add future endpoints: append keywords here — nothing else changes.
static const Map<String, List<String>> _ghostGroups = {
// Any URL that records you having seen/read something
'seen': ['/seen', '/mark_seen', 'reel_seen', 'reel/seen', 'media/seen'],
// Typing indicator (REST + WebSocket text frames)
'typing': ['set_typing_status', '/typing', 'activity_status'],
// Live stream viewer join / heartbeat (you appear in viewer list)
'live': ['/live/'],
// Disappearing / view-once DM photos
'dmPhotos': ['visual_item_seen'],
};
// ── CSS ─────────────────────────────────────────────────────────────────────
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
/// Important: we must NOT hide [role="tablist"] inside dialogs/modals,
/// because Instagram's comment input sheet also uses that role and the
/// CSS would paint a grey overlay on top of the typing area.
static const String _globalUIFixesCSS = '''
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
-webkit-tap-highlight-color: transparent !important;
}
/* Only hide the PRIMARY nav tablist (bottom bar), not tablist inside dialogs */
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
[aria-label="Direct"] header {
display: none !important;
visibility: hidden !important;
height: 0 !important;
pointer-events: none !important;
}
''';
/// Blurs images/videos in the home feed AND on Explore.
/// Activated via the body[path] attribute written by [_trackPathJS].
static const String _blurHomeFeedAndExploreCSS = '''
body[path="/"] article img,
body[path="/"] article video,
body[path^="/explore"] img,
body[path^="/explore"] video,
body[path="/explore/"] img,
body[path="/explore/"] video {
filter: blur(20px) !important;
transition: filter 0.15s ease !important;
}
body[path="/"] article img:hover,
body[path="/"] article video:hover,
body[path^="/explore"] img:hover,
body[path^="/explore"] video:hover {
filter: blur(20px) !important;
}
''';
/// Prevents text selection to keep the app feeling native.
static const String _disableSelectionCSS = '''
* { -webkit-user-select: none !important; user-select: none !important; }
''';
/// Hides reel posts in the home feed when no Reel Session is active.
/// The Reels nav tab is NOT hidden — Flutter intercepts that navigation.
static const String _hideReelsFeedContentCSS = '''
a[href*="/reel/"],
div[data-media-type="2"] {
display: none !important;
visibility: hidden !important;
}
''';
// _blurExploreCSS removed — replaced by _blurHomeFeedAndExploreCSS above.
/// Blurs reel thumbnail images shown in the feed.
static const String _blurReelsCSS = '''
a[href*="/reel/"] img { filter: blur(12px) !important; }
''';
// ── JavaScript helpers ───────────────────────────────────────────────────────
/// Removes the "Open in App" nag banner.
static const String _dismissAppBannerJS = '''
(function fgDismissBanner() {
['[id*="app-banner"]','[class*="app-banner"]',
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
})();
''';
/// Replaces ONLY the Instagram wordmark SVG with "FocusGram" brand text.
/// Specifically targets the top-bar logo SVG (aria-label="Instagram") while
/// explicitly excluding SVG icons inside nav/tablist (home, notifications,
/// create, reels, profile icons).
static const String _brandingJS = r'''
(function fgBranding() {
// Only the wordmark: SVG with aria-label="Instagram" that is NOT inside
// a [role="tablist"] (bottom nav) or a [role="navigation"] (nav bar).
// Also targets the ._ac83 class which Instagram uses for its top wordmark.
const WORDMARK_SEL = [
'svg[aria-label="Instagram"]',
'._ac83 svg[aria-label="Instagram"]',
'h1[role="presentation"] svg',
];
const STYLE =
'font-family:"Grand Hotel",cursive;font-size:26px;color:#fff;' +
'vertical-align:middle;cursor:default;letter-spacing:.5px;display:inline-block;';
function isNavIcon(el) {
// Exclude any SVG that lives inside a tablist, nav, or link with
// non-home/non-root href (these are functional icons, not the wordmark).
if (el.closest('[role="tablist"]')) return true;
if (el.closest('[role="navigation"]')) return true;
// The wordmark is always at the TOP of the page in a header/banner
const header = el.closest('header, [role="banner"], [role="main"]');
if (!header && el.closest('[role="button"]')) return true;
// If the SVG has a meaningful role (img presenting an action icon), skip it
const role = el.getAttribute('role');
if (role && role !== 'img') return true;
// If the parent <a> goes somewhere other than "/" it is a nav link
const anchor = el.closest('a');
if (anchor) {
const href = anchor.getAttribute('href') || '';
if (href && href !== '/' && !href.startsWith('/?')) return true;
}
return false;
}
function apply() {
WORDMARK_SEL.forEach(sel => document.querySelectorAll(sel).forEach(logo => {
if (logo.dataset.fgBranded) return;
if (isNavIcon(logo)) return;
logo.dataset.fgBranded = 'true';
const span = Object.assign(document.createElement('span'),
{ textContent: 'FocusGram' });
span.style.cssText = STYLE;
logo.style.display = 'none';
logo.parentNode.insertBefore(span, logo.nextSibling);
}));
}
apply();
new MutationObserver(apply)
.observe(document.documentElement, { childList: true, subtree: true });
})();
''';
/// Intercepts clicks on /reels/ links when no session is active and redirects
/// to a recognisable URL so Flutter's NavigationDelegate can catch and block it.
///
/// Without this, fast SPA clicks bypass the NavigationDelegate entirely.
static const String _strictReelsBlockJS = r'''
(function fgReelsBlock() {
if (window.__fgReelsBlockPatched) return;
window.__fgReelsBlockPatched = true;
document.addEventListener('click', e => {
if (window.__focusgramSessionActive) return;
const a = e.target && e.target.closest('a[href*="/reels/"]');
if (!a) return;
e.preventDefault();
e.stopPropagation();
window.location.href = '/reels/?fg=blocked';
}, true);
})();
''';
/// SPA route tracker: writes `body[path]` and notifies Flutter of path changes
/// via `FocusGramPathChannel` so reels can be blocked on SPA navigation.
static const String _trackPathJS = '''
(function fgTrackPath() {
if (window.__fgPathTrackerRunning) return;
window.__fgPathTrackerRunning = true;
let last = window.location.pathname;
function check() {
const p = window.location.pathname;
if (p !== last) {
last = p;
if (document.body) document.body.setAttribute('path', p);
if (window.FocusGramPathChannel) window.FocusGramPathChannel.postMessage(p);
}
}
if (document.body) document.body.setAttribute('path', last);
setInterval(check, 500);
})();
''';
/// Injects a persistent `style` element and keeps it alive across SPA route
/// changes by watching for it being removed from `head`.
static String _buildMutationObserver(String cssContent) =>
'''
(function fgApplyStyles() {
@@ -264,9 +40,6 @@ class InjectionController {
return '`$escaped`';
}
// ── Navigation helpers ───────────────────────────────────────────────────────
/// Returns JS that navigates to [path] only when not already on it.
static String softNavigateJS(String path) =>
'''
(function() {
@@ -275,526 +48,57 @@ class InjectionController {
})();
''';
// ── Session state ────────────────────────────────────────────────────────────
/// Writes the current session-active flag into the WebView global scope.
/// All injected scripts (Ghost Mode, scroll lock) read this flag.
static String buildSessionStateJS(bool active) =>
'window.__focusgramSessionActive = $active;';
// ── Ghost Mode ───────────────────────────────────────────────────────────────
/// Returns all URL keywords that should be blocked for the given feature flags.
///
/// Exposed as a separate method so unit tests can verify keyword selection
/// independently of the full JS string.
static List<String> resolveBlockedKeywords({
required bool typingIndicator,
required bool seenStatus,
required bool stories,
required bool dmPhotos,
}) {
final out = <String>[];
if (seenStatus) out.addAll(_ghostGroups['seen']!);
if (typingIndicator) out.addAll(_ghostGroups['typing']!);
if (stories) out.addAll(_ghostGroups['live']!);
if (dmPhotos) out.addAll(_ghostGroups['dmPhotos']!);
return out;
}
/// Returns all WebSocket text-frame keywords to drop for the given flags.
static List<String> resolveWsBlockedKeywords({
required bool typingIndicator,
}) {
if (!typingIndicator) return const [];
return List.unmodifiable(_ghostGroups['typing']!);
}
/// Builds JavaScript that intercepts fetch, XHR, WebSocket, and sendBeacon
/// traffic to suppress ALL activity receipts (seen, typing, live, DM photos).
///
/// All blocked requests return `{"status":"ok"}` with HTTP 200 so Instagram
/// does not retry or display an error.
///
/// See [resolveBlockedKeywords] for the URL-keyword logic.
static String buildGhostModeJS({
required bool typingIndicator,
required bool seenStatus,
required bool stories,
required bool dmPhotos,
}) {
if (!typingIndicator && !seenStatus && !stories && !dmPhotos) return '';
final blocked = resolveBlockedKeywords(
typingIndicator: typingIndicator,
seenStatus: seenStatus,
stories: stories,
dmPhotos: dmPhotos,
);
final wsBlocked = resolveWsBlockedKeywords(
typingIndicator: typingIndicator,
);
final urlsJson = blocked.map((u) => '"$u"').join(', ');
final wsJson = wsBlocked.map((u) => '"$u"').join(', ');
return '''
(function fgGhostMode() {
if (window.__fgGhostModeDone) return;
window.__fgGhostModeDone = true;
// URL substrings — any request whose URL contains one of these is silenced.
const BLOCKED = [$urlsJson];
// WebSocket text-frame keywords to drop (MQTT typing/presence).
const WS_KEYS = [$wsJson];
function shouldBlock(url) {
return typeof url === 'string' && BLOCKED.some(k => url.includes(k));
}
function isDmVideoLocked(url) {
if (typeof url !== 'string') return false;
if (!url.includes('.mp4') && !url.includes('/v/t') && !url.includes('cdninstagram') && !url.includes('.dash')) return false;
return window.__fgDmReelAlreadyLoaded === true;
}
// ── fetch ──────────────────────────────────────────────────────────────
const _oFetch = window.__fgOrigFetch || window.fetch;
window.__fgOrigFetch = _oFetch;
window.__fgGhostFetch = function(resource, init) {
const url = typeof resource === 'string' ? resource : (resource && resource.url) || '';
// Ghost mode: block seen/typing receipts
if (shouldBlock(url))
return Promise.resolve(new Response('{"status":"ok"}',
{ status: 200, headers: { 'Content-Type': 'application/json' } }));
// DM isolation: block additional video segments after first reel loaded
if (isDmVideoLocked(url))
return Promise.resolve(new Response('', { status: 200 }));
return _oFetch.apply(this, arguments);
};
window.fetch = window.__fgGhostFetch;
// ── sendBeacon ─────────────────────────────────────────────────────────
if (navigator.sendBeacon && !window.__fgBeaconPatched) {
window.__fgBeaconPatched = true;
const _oBeacon = navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = function(url, data) {
if (shouldBlock(url)) return true;
return _oBeacon(url, data);
};
}
// ── XHR ────────────────────────────────────────────────────────────────
const _oOpen = window.__fgOrigXhrOpen || XMLHttpRequest.prototype.open;
const _oSend = window.__fgOrigXhrSend || XMLHttpRequest.prototype.send;
window.__fgOrigXhrOpen = _oOpen;
window.__fgOrigXhrSend = _oSend;
XMLHttpRequest.prototype.open = function(m, url) {
this._fgUrl = url;
this._fgBlock = shouldBlock(url);
return _oOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function() {
if (this._fgBlock) {
Object.defineProperty(this, 'readyState', { get: () => 4, configurable: true });
Object.defineProperty(this, 'status', { get: () => 200, configurable: true });
Object.defineProperty(this, 'responseText', { get: () => '{"status":"ok"}', configurable: true });
Object.defineProperty(this, 'response', { get: () => '{"status":"ok"}', configurable: true });
setTimeout(() => {
try { if (this.onreadystatechange) this.onreadystatechange(); } catch(_) {}
try { if (this.onload) this.onload(); } catch(_) {}
}, 0);
return;
}
// DM isolation: block additional video XHR fetches after first reel loaded
if (this._fgUrl && isDmVideoLocked(this._fgUrl)) {
setTimeout(() => { try { this.onload?.(); } catch(_) {} }, 0);
return;
}
return _oSend.apply(this, arguments);
};
// ── WebSocket — block text AND binary frames ───────────────────────────
if (!window.__fgWsGhostDone) {
window.__fgWsGhostDone = true;
const _OWS = window.WebSocket;
const ALL_SEEN = [$urlsJson];
function containsKeyword(data) {
if (typeof data === 'string') return ALL_SEEN.some(k => data.includes(k));
try {
let bytes;
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
else if (data instanceof Uint8Array) bytes = data;
else return false;
const text = String.fromCharCode.apply(null, bytes);
return ALL_SEEN.some(k => text.includes(k));
} catch(_) { return false; }
}
function FgWS(url, proto) {
const ws = proto != null ? new _OWS(url, proto) : new _OWS(url);
const _send = ws.send.bind(ws);
ws.send = function(data) {
if (containsKeyword(data)) return;
return _send(data);
};
return ws;
}
FgWS.prototype = _OWS.prototype;
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(k => FgWS[k] = _OWS[k]);
window.WebSocket = FgWS;
}
// Reapply every 3 s in case Instagram replaces window.fetch
if (!window.__fgGhostReapplyInterval) {
window.__fgGhostReapplyInterval = setInterval(() => {
if (window.fetch !== window.__fgGhostFetch && window.__fgOrigFetch)
window.fetch = window.__fgGhostFetch;
}, 3000);
}
})();
''';
}
// ── Theme Detector ───────────────────────────────────────────────────────────
/// Detects Instagram's current theme (dark/light) and notifies Flutter.
static const String _themeDetectorJS = r'''
(function fgThemeSync() {
if (window.__fgThemeSyncRunning) return;
window.__fgThemeSyncRunning = true;
function getTheme() {
try {
// 1. Check Instagram's specific classes
const h = document.documentElement;
if (h.classList.contains('style-dark')) return 'dark';
if (h.classList.contains('style-light')) return 'light';
// 2. Check body background color
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'; // Fallback
}
let last = '';
function check() {
const current = getTheme();
if (current !== last) {
last = current;
if (window.FocusGramThemeChannel) {
window.FocusGramThemeChannel.postMessage(current);
}
}
}
setInterval(check, 1500);
check();
})();
''';
// ── Reel scroll lock ─────────────────────────────────────────────────────────
/// Prevents swipe-to-next-reel in the isolated DM reel player.
///
/// Lock is active when:
/// `window.__focusgramIsolatedPlayer === true` (DM overlay)
/// OR `window.__focusgramSessionActive === false` (no session)
///
/// Allow-list (these are never blocked):
/// • buttons, anchors, [role=button], aria elements
/// • dialogs, menus, modals, sheets (comment box, emoji picker, share sheet)
/// • keyboard input inside comment / text fields
/// Prevents swipe-to-next-reel in the isolated DM reel player.
///
/// Uses a document-level capture-phase touchmove listener so it fires BEFORE
/// Instagram's scroll container can steal the gesture. The lock is active when
/// `window.__focusgramIsolatedPlayer === true` (single reel from DM),
/// OR `window.__focusgramSessionActive === false` (reels feed, no session).
///
/// The isolated player flag is also maintained here from the path tracker
/// so it works for SPA navigations that don't trigger onPageFinished.
static const String reelsMutationObserverJS = r'''
(function fgReelLock() {
if (window.__fgReelLockRunning) return;
window.__fgReelLockRunning = true;
const ALLOW_SEL = 'button,a,[role="button"],[aria-label],[aria-haspopup],input,textarea,span,h1,h2,h3';
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
function isLocked() {
const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]');
return window.__focusgramIsolatedPlayer === true ||
window.__focusgramSessionActive === false ||
isDmReel;
}
let sy = 0;
document.addEventListener('touchstart', e => {
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
}, { capture: true, passive: true });
document.addEventListener('touchmove', e => {
if (!isLocked()) return;
// Allow vertical swipe if in a session and not on a DM/isolated path
if (window.__focusgramSessionActive === true && !window.location.pathname.includes('/direct/')) return;
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
if (Math.abs(dy) > 2) {
if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return;
// Mark the first DM reel as loaded on first swipe attempt
if (window.location.pathname.includes('/direct/')) {
window.__fgDmReelAlreadyLoaded = true;
}
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
}, { capture: true, passive: false });
function block(e) {
if (!isLocked()) return;
if (e.target && e.target.closest && (e.target.closest(ALLOW_SEL) || e.target.closest(MODAL_SEL))) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
document.addEventListener('wheel', block, { capture: true, passive: false });
document.addEventListener('keydown', e => {
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
block(e);
}, { capture: true, passive: false });
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
function sync() {
const reels = document.querySelectorAll(REEL_SEL);
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
// Give the first reel 3.5 s to buffer before activating the DM lock
if (!window.__fgDmReelTimer) {
window.__fgDmReelTimer = setTimeout(() => {
if (document.querySelector(REEL_SEL)) {
window.__fgDmReelAlreadyLoaded = true;
}
window.__fgDmReelTimer = null;
}, 3500);
}
}
if (reels.length === 0) {
if (window.__fgDmReelTimer) {
clearTimeout(window.__fgDmReelTimer);
window.__fgDmReelTimer = null;
}
window.__fgDmReelAlreadyLoaded = false;
}
}
sync();
new MutationObserver(ms => {
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
}).observe(document.body, { childList: true, subtree: true });
// Keep __focusgramIsolatedPlayer in sync with SPA navigations
if (!window.__fgIsolatedPlayerSync) {
window.__fgIsolatedPlayerSync = true;
let _lastPath = window.location.pathname;
setInterval(() => {
const p = window.location.pathname;
if (p === _lastPath) return;
_lastPath = p;
window.__focusgramIsolatedPlayer =
p.includes('/reel/') && !p.startsWith('/reels/');
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
}, 400);
}
})();
''';
// ── Badge Monitor ────────────────────────────────────────────────────────────
/// Periodically checks Instagram's UI for unread counts (badges) on the Direct
/// and Notifications icons, as well as the page title. Sends an event to
/// Flutter whenever a new notification is detected.
static const String _badgeMonitorJS = r'''
(function fgBadgeMonitor() {
if (window.__fgBadgeMonitorRunning) return;
window.__fgBadgeMonitorRunning = true;
let lastDmCount = 0;
let lastNotifCount = 0;
let lastTitleUnread = 0;
function check() {
try {
// 1. Check Title for (N) indicator
const titleMatch = document.title.match(/\((\d+)\)/);
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
// 2. Scan for DM unread badge
const dmBadge = document.querySelector([
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
'a[href*="/direct/inbox/"] [style*="255, 48, 64"]',
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
'div[role="button"][aria-label*="Direct"] [style*="255, 48, 64"]',
'a[href*="/direct/inbox/"] svg[aria-label*="Direct"] + div', // New red dot sibling
'a[href*="/direct/inbox/"] ._a9-v', // Modern common red badge class
].join(','));
const currentDmCount = dmBadge ? (parseInt(dmBadge.innerText) || 1) : 0;
// 3. Scan for Notifications unread badge
const notifBadge = document.querySelector([
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
'a[href*="/notifications"] [style*="255, 48, 64"]',
'a[href*="/notifications"] [aria-label*="unread"]'
].join(','));
const currentNotifCount = notifBadge ? (parseInt(notifBadge.innerText) || 1) : 0;
if (currentDmCount > lastDmCount) {
window.FocusGramNotificationChannel?.postMessage('DM');
} else if (currentNotifCount > lastNotifCount) {
window.FocusGramNotificationChannel?.postMessage('Activity');
} else if (currentTitleUnread > lastTitleUnread && currentTitleUnread > (currentDmCount + currentNotifCount)) {
window.FocusGramNotificationChannel?.postMessage('Activity');
}
lastDmCount = currentDmCount;
lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
} catch(_) {}
}
// Initial check after some delay to let page settle
setTimeout(check, 2000);
setInterval(check, 3000);
})();
''';
// ── Notification bridge ──────────────────────────────────────────────────────
/// Forwards Web Notification events to the native Flutter channel.
static String get notificationBridgeJS => '''
(function fgNotifBridge() {
if (!window.Notification || window.__fgNotifBridged) return;
window.__fgNotifBridged = true;
const _N = window.Notification;
window.Notification = function(title, opts) {
try {
if (window.FocusGramNotificationChannel)
window.FocusGramNotificationChannel
.postMessage(title + (opts && opts.body ? ': ' + opts.body : ''));
} catch(_) {}
return new _N(title, opts);
};
window.Notification.permission = 'granted';
window.Notification.requestPermission = () => Promise.resolve('granted');
})();
''';
// ── Link sanitization ────────────────────────────────────────────────────────
/// Strips tracking query params (igsh, utm_*, fbclid…) from all links and the
/// native Web Share API. Sanitised share URLs are routed to Flutter's share
/// channel instead.
static const String linkSanitizationJS = r'''
(function fgSanitize() {
if (window.__fgSanitizePatched) return;
window.__fgSanitizePatched = true;
const STRIP = [
'igsh','igshid','fbclid',
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
'ref','s','_branch_match_id','_branch_referrer',
];
function clean(raw) {
try {
const u = new URL(raw, location.origin);
STRIP.forEach(p => u.searchParams.delete(p));
return u.toString();
} catch(_) { return raw; }
}
if (navigator.share) {
const _s = navigator.share.bind(navigator);
navigator.share = function(d) {
const u = d && d.url ? clean(d.url) : null;
if (window.FocusGramShareChannel && u) {
window.FocusGramShareChannel.postMessage(
JSON.stringify({ url: u, title: (d && d.title) || '' }));
return Promise.resolve();
}
return _s({ ...d, url: u || (d && d.url) });
};
}
document.addEventListener('click', e => {
const a = e.target && e.target.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
try {
const u = new URL(href, location.origin);
if (STRIP.some(p => u.searchParams.has(p))) {
STRIP.forEach(p => u.searchParams.delete(p));
a.href = u.toString();
}
} catch(_) {}
}, true);
})();
''';
// ── Main injection builder ───────────────────────────────────────────────────
/// Builds the complete JS payload for a page load or session-state change.
///
/// Injection order matters (later scripts can depend on earlier ones):
/// 1. Session flag — other scripts read `__focusgramSessionActive`
/// 2. Path tracker — writes `body[path]` for CSS page targeting
/// 3. CSS observer — keeps `<style>` alive across SPA navigations
/// 4. Banner dismiss — removes "Open in App" nag
/// 5. Branding — replaces Instagram logo with FocusGram
/// 6. Reels JS blocker — click-interceptor (only when no session)
/// 7. Ghost Mode — network interceptors (fetch / XHR / WS)
/// 8. Link sanitizer — tracking param stripping
static String buildInjectionJS({
required bool sessionActive,
required bool blurExplore,
required bool blurReels,
required bool ghostTyping,
required bool ghostSeen,
required bool ghostStories,
required bool ghostDmPhotos,
required bool tapToUnblur,
required bool enableTextSelection,
required bool hideSuggestedPosts,
required bool hideSponsoredPosts,
required bool hideLikeCounts,
required bool hideFollowerCounts,
required bool hideExploreTab,
required bool hideReelsTab,
required bool hideShopTab,
required bool disableReelsEntirely,
required bool blockHomeFeedScroll,
}) {
final css = StringBuffer()..writeln(_globalUIFixesCSS);
if (!enableTextSelection) css.writeln(_disableSelectionCSS);
if (!sessionActive) {
css.writeln(_hideReelsFeedContentCSS);
if (blurReels) css.writeln(_blurReelsCSS);
}
// blurExplore now also blurs home-feed posts ("Blur Posts and Explore")
if (blurExplore) css.writeln(_blurHomeFeedAndExploreCSS);
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
final ghost = buildGhostModeJS(
typingIndicator: ghostTyping,
seenStatus: ghostSeen,
stories: ghostStories,
dmPhotos: ghostDmPhotos,
);
if (!sessionActive) {
// Hide reel feed content when no session active
css.writeln(scripts.kHideReelsFeedContentCSS);
}
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
return '''
${buildSessionStateJS(sessionActive)}
$_trackPathJS
window.__fgDisableReelsEntirely = $disableReelsEntirely;
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
window.__fgTapToUnblur = $tapToUnblur;
${scripts.kTrackPathJS}
${_buildMutationObserver(css.toString())}
$_dismissAppBannerJS
$_brandingJS
${!sessionActive ? _strictReelsBlockJS : ''}
$reelsMutationObserverJS
$ghost
$linkSanitizationJS
$_themeDetectorJS
$_badgeMonitorJS
${scripts.kDismissAppBannerJS}
${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
${scripts.kReelsMutationObserverJS}
${tapToUnblur ? scripts.kTapToUnblurJS : ''}
${scripts.kLinkSanitizationJS}
${scripts.kThemeDetectorJS}
${scripts.kBadgeMonitorJS}
''';
}
}
+530
View File
@@ -0,0 +1,530 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'session_manager.dart';
import 'settings_service.dart';
import 'injection_controller.dart';
import '../scripts/grayscale.dart' as grayscale;
import '../scripts/ui_hider.dart' as ui_hider;
import '../scripts/content_disabling.dart' as content_disabling;
import '../scripts/video_downloader.dart' as video_downloader;
// Core JS and CSS payloads injected into the Instagram WebView.
//
// WARNING: Do not add any network interception logic ("ghost mode") here.
// All scripts in this file must be limited to UI behaviour, navigation helpers,
// and local-only features that do not modify data sent to Meta's servers.
// ── CSS payloads ──────────────────────────────────────────────────────────────
/// Base UI polish — hides scrollbars and Instagram's nav tab-bar.
const String kGlobalUIFixesCSS = '''
::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; }
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
-webkit-tap-highlight-color: transparent !important;
}
body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]),
[aria-label="Direct"] header {
display: none !important;
visibility: hidden !important;
height: 0 !important;
pointer-events: none !important;
}
''';
/// Blurs images/videos in the home feed AND on Explore.
const String kBlurHomeFeedAndExploreCSS = '''
body[path="/"] article img,
body[path="/"] article video,
body[path^="/explore"] img,
body[path^="/explore"] video,
body[path="/explore/"] img,
body[path="/explore/"] video {
filter: blur(20px) !important;
transition: filter 0.15s ease !important;
}
body[path="/"] article img:hover,
body[path="/"] article video:hover,
body[path^="/explore"] img:hover,
body[path^="/explore"] video:hover {
filter: blur(20px) !important;
}
''';
/// Prevents text selection to keep the app feeling native (only when disabled).
const String kDisableSelectionCSS = '''
* { -webkit-user-select: none !important; user-select: none !important; }
''';
/// Hides reel posts in the home feed when no Reel Session is active.
const String kHideReelsFeedContentCSS = '''
a[href*="/reel/"],
div[data-media-type="2"] {
display: none !important;
visibility: hidden !important;
}
''';
// ── JavaScript helpers ────────────────────────────────────────────────────────
const String kDismissAppBannerJS = '''
(function fgDismissBanner() {
['[id*="app-banner"]','[class*="app-banner"]',
'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]']
.forEach(s => document.querySelectorAll(s).forEach(el => el.remove()));
})();
''';
const String kStrictReelsBlockJS = r'''
(function fgReelsBlock() {
if (window.__fgReelsBlockPatched) return;
window.__fgReelsBlockPatched = true;
document.addEventListener('click', e => {
if (window.__focusgramSessionActive) return;
const a = e.target && e.target.closest('a[href*="/reels/"]');
if (!a) return;
e.preventDefault();
e.stopPropagation();
window.location.href = '/reels/?fg=blocked';
}, true);
})();
''';
const String kTrackPathJS = '''
(function fgTrackPath() {
if (window.__fgPathTrackerRunning) return;
window.__fgPathTrackerRunning = true;
let last = window.location.pathname;
function check() {
const p = window.location.pathname;
if (p !== last) {
last = p;
if (document.body) document.body.setAttribute('path', p);
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('UrlChange', p);
}
}
}
if (document.body) document.body.setAttribute('path', last);
setInterval(check, 500);
})();
''';
const String kThemeDetectorJS = r'''
(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();
})();
''';
const String kReelsMutationObserverJS = r'''
(function fgReelLock() {
if (window.__fgReelLockRunning) return;
window.__fgReelLockRunning = true;
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
function lockMode() {
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled
const isDmReel = window.location.pathname.includes('/direct/') &&
!!document.querySelector('[class*="ReelsVideoPlayer"]');
if (isDmReel) return 'dm_reel';
if (window.__fgDisableReelsEntirely === true) return 'disabled';
return null;
}
function isLocked() { return lockMode() !== null; }
function allowInteractionTarget(t) {
if (!t || !t.closest) return false;
if (t.closest('input,textarea,[contenteditable="true"]')) return true;
if (t.closest(MODAL_SEL)) return true;
return false;
}
let sy = 0;
document.addEventListener('touchstart', e => {
sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0;
}, { capture: true, passive: true });
document.addEventListener('touchmove', e => {
if (!isLocked()) return;
const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0;
if (Math.abs(dy) > 2) {
if (window.location.pathname.includes('/direct/')) {
window.__fgDmReelAlreadyLoaded = true;
}
if (allowInteractionTarget(e.target)) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
}, { capture: true, passive: false });
function block(e) {
if (!isLocked()) return;
if (allowInteractionTarget(e.target)) return;
if (e.cancelable) e.preventDefault();
e.stopPropagation();
}
document.addEventListener('wheel', block, { capture: true, passive: false });
document.addEventListener('keydown', e => {
if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return;
if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return;
block(e);
}, { capture: true, passive: false });
const REEL_SEL = '[class*="ReelsVideoPlayer"], video';
let __fgOrigHtmlOverflow = null;
let __fgOrigBodyOverflow = null;
function applyOverflowLock() {
try {
const mode = lockMode();
const hasReel = !!document.querySelector(REEL_SEL);
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
if (__fgOrigHtmlOverflow === null) {
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
}
document.documentElement.style.overflow = 'hidden';
if (document.body) document.body.style.overflow = 'hidden';
} else if (__fgOrigHtmlOverflow !== null) {
document.documentElement.style.overflow = __fgOrigHtmlOverflow;
if (document.body) document.body.style.overflow = __fgOrigBodyOverflow || '';
__fgOrigHtmlOverflow = null;
__fgOrigBodyOverflow = null;
}
} catch (_) {}
}
function sync() {
const reels = document.querySelectorAll(REEL_SEL);
applyOverflowLock();
if (window.location.pathname.includes('/direct/') && reels.length > 0) {
if (!window.__fgDmReelTimer) {
window.__fgDmReelTimer = setTimeout(() => {
if (document.querySelector(REEL_SEL)) window.__fgDmReelAlreadyLoaded = true;
window.__fgDmReelTimer = null;
}, 3500);
}
}
if (reels.length === 0) {
if (window.__fgDmReelTimer) { clearTimeout(window.__fgDmReelTimer); window.__fgDmReelTimer = null; }
window.__fgDmReelAlreadyLoaded = false;
}
}
sync();
new MutationObserver(ms => {
if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync();
}).observe(document.body, { childList: true, subtree: true });
if (!window.__fgIsolatedPlayerSync) {
window.__fgIsolatedPlayerSync = true;
let _lastPath = window.location.pathname;
setInterval(() => {
const p = window.location.pathname;
if (p === _lastPath) return;
_lastPath = p;
window.__focusgramIsolatedPlayer = p.includes('/reel/') && !p.startsWith('/reels');
if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false;
applyOverflowLock();
}, 400);
}
})();
''';
const String kBadgeMonitorJS = r'''
(function fgBadgeMonitor() {
if (window.__fgBadgeMonitorRunning) return;
window.__fgBadgeMonitorRunning = true;
const startedAt = Date.now();
let initialised = false;
let lastDmCount = 0, lastNotifCount = 0, lastTitleUnread = 0;
function parseBadgeCount(el) {
if (!el) return 0;
try {
const raw = (el.innerText || el.textContent || '').trim();
const n = parseInt(raw, 10);
return isNaN(n) ? 1 : n;
} catch (_) { return 1; }
}
function check() {
try {
const titleMatch = document.title.match(/\((\d+)\)/);
const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0;
const dmBadge = document.querySelector([
'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]',
'a[href*="/direct/inbox/"] [aria-label*="unread"]',
'a[href*="/direct/inbox/"] ._a9-v',
].join(','));
const currentDmCount = parseBadgeCount(dmBadge);
const notifBadge = document.querySelector([
'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]',
'a[href*="/notifications"] [aria-label*="unread"]',
].join(','));
const currentNotifCount = parseBadgeCount(notifBadge);
if (!initialised) {
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread; initialised = true; return;
}
if (Date.now() - startedAt < 6000) {
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread; return;
}
if (currentDmCount > lastDmCount && window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'DM');
} else if (currentNotifCount > lastNotifCount && window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity');
}
lastDmCount = currentDmCount; lastNotifCount = currentNotifCount;
lastTitleUnread = currentTitleUnread;
} catch(_) {}
}
setTimeout(check, 2000);
setInterval(check, 1000);
})();
''';
const String kNotificationBridgeJS = '''
(function fgNotifBridge() {
if (!window.Notification || window.__fgNotifBridged) return;
window.__fgNotifBridged = true;
const startedAt = Date.now();
const _N = window.Notification;
window.Notification = function(title, opts) {
try {
if (Date.now() - startedAt < 6000) return new _N(title, opts);
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'FocusGramNotificationChannel',
title + (opts && opts.body ? ': ' + opts.body : ''),
);
}
} catch(_) {}
return new _N(title, opts);
};
window.Notification.permission = 'granted';
window.Notification.requestPermission = () => Promise.resolve('granted');
})();
''';
const String kLinkSanitizationJS = r'''
(function fgSanitize() {
if (window.__fgSanitizePatched) return;
window.__fgSanitizePatched = true;
const STRIP = [
'igsh','igshid','fbclid',
'utm_source','utm_medium','utm_campaign','utm_term','utm_content',
'ref','s','_branch_match_id','_branch_referrer',
];
function clean(raw) {
try {
const u = new URL(raw, location.origin);
STRIP.forEach(p => u.searchParams.delete(p));
return u.toString();
} catch(_) { return raw; }
}
if (navigator.share) {
const _s = navigator.share.bind(navigator);
navigator.share = function(d) {
const u = d && d.url ? clean(d.url) : null;
if (window.flutter_inappwebview && u) {
window.flutter_inappwebview.callHandler(
'FocusGramShareChannel',
JSON.stringify({ url: u, title: (d && d.title) || '' }),
);
return Promise.resolve();
}
return _s({ ...d, url: u || (d && d.url) });
};
}
document.addEventListener('click', e => {
const a = e.target && e.target.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript')) return;
try {
const u = new URL(href, location.origin);
if (STRIP.some(p => u.searchParams.has(p))) {
STRIP.forEach(p => u.searchParams.delete(p));
a.href = u.toString();
}
} catch(_) {}
}, true);
})();
''';
// ── 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 {
final JsEvaluator _jsEvaluator;
final SharedPreferences prefs;
final SessionManager sessionManager;
SettingsService? _settingsService;
InjectionManager({
required InAppWebViewController controller,
required this.prefs,
required this.sessionManager,
JsEvaluator? jsEvaluator,
}) : _jsEvaluator = jsEvaluator ?? _WebViewJsEvaluator(controller);
InjectionManager.forTest({
required JsEvaluator jsEvaluator,
required this.prefs,
required this.sessionManager,
}) : _jsEvaluator = jsEvaluator;
void setSettingsService(SettingsService settingsService) {
_settingsService = settingsService;
}
/// Runs all post-load JavaScript injections based on current settings.
Future<void> runAllPostLoadInjections(String url) async {
if (_settingsService == null) return;
final settings = _settingsService!;
final sessionActive = sessionManager.isSessionActive;
// Get settings values
// Minimal mode controls all blocking - when enabled, it forces blur and disables
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
final tapToUnblur = settings.tapToUnblur;
final enableTextSelection = settings.enableTextSelection;
// Per request: remove ALL “Hide Suggested Posts” behavior/UI/JS injection.
final hideSuggestedPosts = false;
final hideLikeCounts = settings.hideLikeCounts;
final hideFollowerCounts = settings.hideFollowerCounts;
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
// These are now only controllable via minimal mode submenu
final disableExploreEntirely = settings.disableExploreEntirely;
final disableReelsEntirely = settings.disableReelsEntirely;
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
final hideExploreTab = disableExploreEntirely;
final hideReelsTab = disableReelsEntirely;
final hideShopTab = settings.hideShopTab;
final injectionJS = InjectionController.buildInjectionJS(
sessionActive: sessionActive,
blurExplore: blurExplore,
blurReels: false, // Blur reels feature removed
tapToUnblur: blurExplore && tapToUnblur,
enableTextSelection: enableTextSelection,
hideSuggestedPosts: hideSuggestedPosts,
hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
hideLikeCounts: hideLikeCounts,
hideFollowerCounts: hideFollowerCounts,
hideExploreTab: hideExploreTab,
hideReelsTab: hideReelsTab,
hideShopTab: hideShopTab,
disableReelsEntirely: disableReelsEntirely,
blockHomeFeedScroll: blockHomeFeedScroll,
);
try {
await _jsEvaluator.evaluateJavascript(source: injectionJS);
} catch (e) {
// Silently handle injection errors
}
// Inject grayscale when active, remove when not active
if (settings.isGrayscaleActiveNow) {
try {
await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
} catch (e) {
// Silently handle injection errors
}
} else {
try {
await _jsEvaluator.evaluateJavascript(
source: grayscale.kGrayscaleOffJS,
);
} catch (e) {
// Silently handle injection errors
}
}
// Inject hide like counts JS when enabled
if (hideLikeCounts) {
try {
await _jsEvaluator.evaluateJavascript(
source: ui_hider.kHideLikeCountsJS,
);
} catch (e) {
// Silently handle injection errors
}
}
// Stories hiding functionality removed per user request
// No stories overlay injection needed
// Inject video downloader UI when enabled
if (settings.videoDownloadEnabled) {
try {
await _jsEvaluator.evaluateJavascript(
source: video_downloader.kVideoDownloadJS,
);
} catch (e) {
// Silently handle injection errors
}
}
// Inject DM Reel blocker when disableReelsEntirely is enabled
if (disableReelsEntirely) {
try {
await _jsEvaluator.evaluateJavascript(
source: content_disabling.kDmReelBlockerJS,
);
} catch (e) {
// Silently handle injection errors
}
}
}
}
+105 -6
View File
@@ -9,18 +9,22 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
Future<void> init() async {
Future<void> init({bool requestPermissions = false}) async {
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings initializationSettingsIOS =
// Request permissions for iOS
final DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
requestAlertPermission: requestPermissions,
requestBadgePermission: requestPermissions,
requestSoundPermission: requestPermissions,
defaultPresentAlert: true,
defaultPresentBadge: true,
defaultPresentSound: true,
);
const InitializationSettings initializationSettings =
final InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsIOS,
@@ -32,6 +36,39 @@ class NotificationService {
// Handle notification tap
},
);
if (requestPermissions) {
await requestPermissionsNow();
}
}
Future<void> requestPermissionsNow() async {
await _requestIOSPermissions();
await _requestAndroidPermissions();
}
Future<void> _requestIOSPermissions() async {
try {
await _notificationsPlugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>()
?.requestPermissions(alert: true, badge: true, sound: true);
} catch (e) {
debugPrint('iOS permission request error: $e');
}
}
Future<void> _requestAndroidPermissions() async {
try {
await _notificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.requestNotificationsPermission();
} catch (e) {
debugPrint('Android permission request error: $e');
}
}
Future<void> showNotification({
@@ -71,4 +108,66 @@ class NotificationService {
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
@@ -0,0 +1,84 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class RemotePopupData {
final bool show;
final String id;
final String title;
final String body;
final int maxShows;
final String buttonText;
RemotePopupData({
required this.show,
required this.id,
required this.title,
required this.body,
required this.maxShows,
required this.buttonText,
});
factory RemotePopupData.fromJson(Map<String, dynamic> json) {
return RemotePopupData(
show: json['show'] ?? false,
id: json['id']?.toString() ?? '',
title: json['header']?.toString() ?? 'Notice',
body: json['body']?.toString() ?? '',
maxShows: json['max_shows'] ?? 1,
buttonText: json['button_text']?.toString() ?? 'OK',
);
}
}
class RemotePopupService {
// Keep placeholder value until you replace it.
static const String popupUrl =
'https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/android/popup.json';
static Future<RemotePopupData?> fetchPopup() async {
try {
// Cache-busting to avoid stale popup configs from GitHub raw URLs.
final uri = Uri.parse(
'$popupUrl?t=${DateTime.now().millisecondsSinceEpoch}',
);
final response = await http.get(
uri,
headers: const {
'Cache-Control': 'no-cache',
},
);
if (response.statusCode != 200) return null;
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) return null;
return RemotePopupData.fromJson(decoded);
} catch (_) {
return null;
}
}
static Future<bool> shouldShow(RemotePopupData data) async {
if (!data.show) return false;
if (data.id.isEmpty) return false;
final prefs = await SharedPreferences.getInstance();
final key = 'popup_count_${data.id}';
final shownCount = prefs.getInt(key) ?? 0;
return shownCount < data.maxShows;
}
static Future<void> markShown(RemotePopupData data) async {
if (data.id.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final key = 'popup_count_${data.id}';
final current = prefs.getInt(key) ?? 0;
await prefs.setInt(key, current + 1);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Tracks total in-app screen time per day.
///
/// Storage format (in SharedPreferences, key `screen_time_data`):
/// {
/// "2026-05-26": 3420, // seconds
/// "2026-05-25": 1800
/// }
///
/// All data stays on-device only.
class ScreenTimeService extends ChangeNotifier {
static const String prefKey = 'screen_time_data';
SharedPreferences? _prefs;
Map<String, int> _secondsByDate = {};
Timer? _ticker;
bool _tracking = false;
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
int get totalSeconds =>
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
_load();
}
void _load() {
final raw = _prefs?.getString(prefKey);
if (raw == null || raw.isEmpty) {
_secondsByDate = {};
return;
}
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
_secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
}
} catch (_) {
_secondsByDate = {};
}
}
Future<void> _save() async {
// Prune entries older than 30 days
final now = DateTime.now();
final cutoff = now.subtract(const Duration(days: 30));
_secondsByDate.removeWhere((key, value) {
try {
final d = DateTime.parse(key);
return d.isBefore(DateTime(cutoff.year, cutoff.month, cutoff.day));
} catch (_) {
return true;
}
});
await _prefs?.setString(prefKey, jsonEncode(_secondsByDate));
notifyListeners();
}
String _todayKey() {
final now = DateTime.now();
return '${now.year.toString().padLeft(4, '0')}-'
'${now.month.toString().padLeft(2, '0')}-'
'${now.day.toString().padLeft(2, '0')}';
}
void startTracking() {
if (_tracking) return;
_tracking = true;
_ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
if (!_tracking) return;
final key = _todayKey();
_secondsByDate[key] = (_secondsByDate[key] ?? 0) + 1;
// Persist every 10 seconds to reduce writes.
if (_secondsByDate[key]! % 10 == 0) {
_save();
} else {
notifyListeners();
}
});
}
void stopTracking() {
if (!_tracking) return;
_tracking = false;
_save();
}
Future<void> resetAll() async {
_secondsByDate.clear();
await _prefs?.remove(prefKey);
notifyListeners();
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
}
}
+60 -15
View File
@@ -57,6 +57,7 @@ class SessionManager extends ChangeNotifier {
static const _keyAppSessionEnd = 'app_sess_end_ts';
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
static const _keyDailyOpenCount = 'app_open_count';
static const _keyScheduleEnabled = 'sched_enabled';
static const _keyScheduleStartHour = 'sched_start_h';
@@ -81,6 +82,7 @@ class SessionManager extends ChangeNotifier {
bool _appSessionExpiredFlag =
false; // set when time runs out, waiting for user action
int _dailyOpenCount = 0;
int _lastAppSessionMinutes = 5;
// ── Scheduled Blocking runtime ─────────────────────────────
bool _scheduleEnabled = false;
@@ -90,6 +92,10 @@ class SessionManager extends ChangeNotifier {
int _schedEndMin = 0;
List<FocusSchedule> _schedules = [];
bool _lastScheduleState = false;
bool _scheduleNotificationShown =
false; // Track if schedule notification was shown
bool _sessionEndNotificationShown =
true; // Default to true to prevent notification on app startup (will be reset when new session starts)
bool _isInForeground = true; // Tracking app lifecycle state
int _cachedRemainingSessionSeconds = 0;
@@ -173,6 +179,7 @@ class SessionManager extends ChangeNotifier {
/// How many times the user has opened the app today.
int get dailyOpenCount => _dailyOpenCount;
int get lastAppSessionMinutes => _lastAppSessionMinutes;
// ── Scheduled Blocking Getters ─────────────────────────────
bool get scheduleEnabled => _scheduleEnabled;
@@ -292,7 +299,8 @@ class SessionManager extends ChangeNotifier {
_sessionExpiry = expiry;
_isSessionActive = true;
} else {
_cleanupExpiredReelSession();
// Don't show notification for expired sessions from previous app session
_cleanupExpiredReelSession(showNotification: false);
}
}
final lastEndMs = _prefs!.getInt(_keyLastSessionEnd) ?? 0;
@@ -306,6 +314,7 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
}
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
if (lastAppEndMs > 0) {
@@ -359,22 +368,25 @@ class SessionManager extends ChangeNotifier {
// and update expiry ONLY when in foreground.
if (remainingSessionSeconds <= 0) {
_cleanupExpiredReelSession();
// Only cleanup if session was actually active and has expired naturally
_cleanupExpiredReelSession(showNotification: true);
changed = true;
} else {
_dailyUsedSeconds++;
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted) _cleanupExpiredReelSession();
if (isDailyLimitExhausted) {
_cleanupExpiredReelSession(showNotification: true);
}
changed = true;
}
}
// App session expiry check
// App session countdown / expiry check
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
if (DateTime.now().isAfter(_appSessionEnd!)) {
_appSessionExpiredFlag = true;
changed = true;
}
changed = true;
}
if (isCooldownActive) {
@@ -390,24 +402,49 @@ class SessionManager extends ChangeNotifier {
if (sched != _lastScheduleState) {
_lastScheduleState = sched;
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();
}
void _cleanupExpiredReelSession() {
void _cleanupExpiredReelSession({bool showNotification = true}) {
// 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;
_sessionExpiry = null;
_lastSessionEnd = DateTime.now();
_prefs?.setInt(_keySessionExpiry, 0);
_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 ───────────────────────────────────────
@@ -418,6 +455,8 @@ class SessionManager extends ChangeNotifier {
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
_isSessionActive = true;
_sessionEndNotificationShown =
false; // Reset notification flag for new session
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
notifyListeners();
return true;
@@ -425,7 +464,8 @@ class SessionManager extends ChangeNotifier {
void endSession() {
if (!_isSessionActive) return;
_cleanupExpiredReelSession();
// Don't show notification when user manually ends the session
_cleanupExpiredReelSession(showNotification: false);
notifyListeners();
}
@@ -435,7 +475,10 @@ class SessionManager extends ChangeNotifier {
_dailyLimitSeconds,
);
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
if (isDailyLimitExhausted && _isSessionActive) _cleanupExpiredReelSession();
if (isDailyLimitExhausted && _isSessionActive) {
// Daily limit exhausted - show notification
_cleanupExpiredReelSession(showNotification: true);
}
notifyListeners();
}
@@ -447,8 +490,10 @@ class SessionManager extends ChangeNotifier {
_appSessionEnd = end;
_appSessionExpiredFlag = false;
_appExtensionUsed = false;
_lastAppSessionMinutes = minutes;
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
_prefs?.setBool(_keyAppSessionExtUsed, false);
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
notifyListeners();
}
+649 -70
View File
@@ -1,53 +1,150 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'notification_service.dart';
/// Stores and retrieves all user-configurable app settings.
class SettingsService extends ChangeNotifier {
static const _keyBlurExplore = 'set_blur_explore';
static const _keyBlurReels = 'set_blur_reels';
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyTapToUnblur = 'set_tap_to_unblur';
static const _keyShowBreathGate = 'set_show_breath_gate';
static const _keyRequireWordChallenge = 'set_require_word_challenge';
static const _keyRequireLongPress = 'set_require_long_press';
static const _keyBreathGateSeconds = 'breath_gate_seconds';
static const _keyWordChallengeCount = 'word_challenge_count';
static const _keyEnableTextSelection = 'set_enable_text_selection';
static const _keyEnabledTabs = 'set_enabled_tabs';
static const _keyShowInstaSettings = 'set_show_insta_settings';
static const _keyIsFirstRun = 'set_is_first_run';
// Granular Ghost Mode keys
static const _keyGhostTyping = 'set_ghost_typing';
static const _keyGhostSeen = 'set_ghost_seen';
static const _keyGhostStories = 'set_ghost_stories';
static const _keyGhostDmPhotos = 'set_ghost_dm_photos';
// Focus / playback
static const _keyBlockAutoplay = 'block_autoplay';
// Extras (Phase 2)
static const _keyVideoDownloadEnabled = 'video_download_enabled';
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
static const _keyV2GhostModeEnabled = 'v2_ghost_mode_enabled';
static const _keyV2AdBlockerDomEnabled = 'v2_adblock_dom_enabled';
static const _keyV2ContentHiderEnabled = 'v2_content_hider_enabled';
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
static const _keyContentStories = 'content_stories';
static const _keyContentPosts = 'content_posts';
static const _keyContentReels = 'content_reels';
static const _keyContentSuggested = 'content_suggested';
// Grayscale mode - now supports multiple schedules
static const _keyGrayscaleEnabled = 'grayscale_enabled';
static const _keyGrayscaleSchedules = 'grayscale_schedules';
// Content filtering / UI hiding
static const _keyHideLikeCounts = 'hide_like_counts';
static const _keyHideFollowerCounts = 'hide_follower_counts';
static const _keyHideShopTab = 'hide_shop_tab';
// Minimal mode
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
// Minimal mode state tracking for smart restore
static const _keyMinimalModePrevDisableReels =
'minimal_mode_prev_disable_reels';
static const _keyMinimalModePrevDisableExplore =
'minimal_mode_prev_disable_explore';
static const _keyMinimalModePrevBlurExplore =
'minimal_mode_prev_blur_explore';
static const _keyMinimalModePrevBlockHomeFeedScroll =
'minimal_mode_prev_block_home_feed_scroll';
// Reels History
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
// Privacy keys
static const _keySanitizeLinks = 'set_sanitize_links';
static const _keyNotifyDMs = 'set_notify_dms';
static const _keyNotifyActivity = 'set_notify_activity';
static const _keyNotifySessionEnd = 'set_notify_session_end';
static const _keyNotifyPersistent = 'set_notify_persistent';
// Legacy key for migration
static const _keyGhostModeLegacy = 'set_ghost_mode';
// Focus mode settings
static const _keyGhostMode = 'ghost_mode';
static const _keyNoAds = 'no_ads';
static const _keyNoStories = 'no_stories';
static const _keyNoReels = 'no_reels';
static const _keyNoAutoplay = 'no_autoplay';
static const _keyNoDMs = 'no_dms';
SharedPreferences? _prefs;
bool _blurExplore = true;
bool _blurReels = false;
bool _tapToUnblur = true;
bool _requireLongPress = true;
bool _showBreathGate = true;
bool _requireWordChallenge = true;
int _breathGateSeconds = 10;
int _wordChallengeCount = 30;
bool _enableTextSelection = false;
bool _showInstaSettings = true;
bool _isDarkMode = true; // Default to dark as per existing app theme
// Granular Ghost Mode defaults (all on)
bool _ghostTyping = true;
bool _ghostSeen = true;
bool _ghostStories = true;
bool _ghostDmPhotos = true;
bool _blockAutoplay = true;
// Privacy defaults
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;
List<Map<String, dynamic>> _grayscaleSchedules = [];
// Content filtering / UI hiding
bool _hideLikeCounts = false;
bool _hideFollowerCounts = false;
bool _hideShopTab = false;
// These are now controlled internally by minimal mode
bool _disableReelsEntirely = false;
bool _disableExploreEntirely = false;
bool _blockHomeFeedScroll = false;
bool _minimalModeEnabled = false;
// Tracking for smart restore
bool _prevDisableReels = false;
bool _prevDisableExplore = false;
bool _prevBlurExplore = false;
bool _prevBlockHomeFeedScroll = false;
bool _reelsHistoryEnabled = true;
// Privacy defaults - notifications OFF by default
bool _sanitizeLinks = true;
bool _notifyDMs = true;
bool _notifyActivity = true;
bool _notifyDMs = false;
bool _notifyActivity = 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 = [
'Home',
@@ -60,26 +157,100 @@ class SettingsService extends ChangeNotifier {
bool get blurExplore => _blurExplore;
bool get blurReels => _blurReels;
bool get tapToUnblur => _tapToUnblur;
bool get requireLongPress => _requireLongPress;
bool get showBreathGate => _showBreathGate;
bool get requireWordChallenge => _requireWordChallenge;
int get breathGateSeconds => _breathGateSeconds;
int get wordChallengeCount => _wordChallengeCount;
bool get enableTextSelection => _enableTextSelection;
bool get showInstaSettings => _showInstaSettings;
List<String> get enabledTabs => _enabledTabs;
bool get isFirstRun => _isFirstRun;
bool get isDarkMode => _isDarkMode;
bool get blockAutoplay => _blockAutoplay;
// Granular Ghost Mode getters
bool get ghostTyping => _ghostTyping;
bool get ghostSeen => _ghostSeen;
bool get ghostStories => _ghostStories;
bool get ghostDmPhotos => _ghostDmPhotos;
// Extras (Phase 2)
bool get videoDownloadEnabled => _videoDownloadEnabled;
bool get hideSuggestedPosts => _hideSuggestedPosts;
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
bool get v2GhostModeEnabled => _v2GhostModeEnabled;
bool get v2AdBlockerDomEnabled => _v2AdBlockerDomEnabled;
bool get v2ContentHiderEnabled => _v2ContentHiderEnabled;
bool get contentStories => _contentStories;
bool get contentPosts => _contentPosts;
bool get contentReels => _contentReels;
bool get contentSuggested => _contentSuggested;
bool get notifyDMs => _notifyDMs;
bool get notifyActivity => _notifyActivity;
bool get notifySessionEnd => _notifySessionEnd;
bool get notifyPersistent => _notifyPersistent;
/// True if ANY ghost mode setting is enabled (for injection logic).
bool get anyGhostModeEnabled =>
_ghostTyping || _ghostSeen || _ghostStories || _ghostDmPhotos;
bool get grayscaleEnabled => _grayscaleEnabled;
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
bool get hideLikeCounts => _hideLikeCounts;
bool get hideFollowerCounts => _hideFollowerCounts;
bool get hideShopTab => _hideShopTab;
// Focus mode settings
bool get ghostMode => _ghostMode;
bool get noAds => _noAds;
bool get noStories => _noStories;
bool get noReels => _noReels;
bool get noAutoplay => _noAutoplay;
bool get noDMs => _noDMs;
// These are now controlled by minimal mode only
bool get disableReelsEntirely => _disableReelsEntirely;
bool get disableExploreEntirely => _disableExploreEntirely;
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
bool get minimalModeEnabled => _minimalModeEnabled;
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
/// True if grayscale should currently be applied, considering the manual
/// toggle and the optional schedules.
bool get isGrayscaleActiveNow {
if (_grayscaleEnabled) return true;
if (_grayscaleSchedules.isEmpty) return false;
final now = DateTime.now();
final currentMinutes = now.hour * 60 + now.minute;
for (final schedule in _grayscaleSchedules) {
if (schedule['enabled'] != true) continue;
try {
final startParts = (schedule['startTime'] as String).split(':');
final endParts = (schedule['endTime'] as String).split(':');
if (startParts.length != 2 || endParts.length != 2) continue;
final startMinutes =
int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
final 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 (_) {
continue;
}
}
return false;
}
// Privacy getters
bool get sanitizeLinks => _sanitizeLinks;
@@ -88,36 +259,89 @@ class SettingsService extends ChangeNotifier {
_prefs = await SharedPreferences.getInstance();
_blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true;
_blurReels = _prefs!.getBool(_keyBlurReels) ?? false;
_tapToUnblur = _prefs!.getBool(_keyTapToUnblur) ?? true;
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
_breathGateSeconds = (_prefs!.getInt(_keyBreathGateSeconds) ?? 10)
.clamp(3, 60)
.toInt();
_wordChallengeCount = _normaliseWordChallengeCount(
_prefs!.getInt(_keyWordChallengeCount) ?? 30,
);
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
// Migrate legacy ghostMode key -> all granular keys
final legacyGhostMode = _prefs!.getBool(_keyGhostModeLegacy);
if (legacyGhostMode != null) {
// Seed all four granular keys with the legacy value
_ghostTyping = legacyGhostMode;
_ghostSeen = legacyGhostMode;
_ghostStories = legacyGhostMode;
_ghostDmPhotos = legacyGhostMode;
// Save granular keys and remove legacy key
await _prefs!.setBool(_keyGhostTyping, legacyGhostMode);
await _prefs!.setBool(_keyGhostSeen, legacyGhostMode);
await _prefs!.setBool(_keyGhostStories, legacyGhostMode);
await _prefs!.setBool(_keyGhostDmPhotos, legacyGhostMode);
await _prefs!.remove(_keyGhostModeLegacy);
} else {
_ghostTyping = _prefs!.getBool(_keyGhostTyping) ?? true;
_ghostSeen = _prefs!.getBool(_keyGhostSeen) ?? true;
_ghostStories = _prefs!.getBool(_keyGhostStories) ?? true;
_ghostDmPhotos = _prefs!.getBool(_keyGhostDmPhotos) ?? true;
// Extras (Phase 2) - defaults OFF for safety/non-invasive behavior
_videoDownloadEnabled = _prefs!.getBool(_keyVideoDownloadEnabled) ?? false;
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
_v2GhostModeEnabled = _prefs!.getBool(_keyV2GhostModeEnabled) ?? false;
_v2AdBlockerDomEnabled =
_prefs!.getBool(_keyV2AdBlockerDomEnabled) ?? false;
_v2ContentHiderEnabled =
_prefs!.getBool(_keyV2ContentHiderEnabled) ?? false;
_contentStories = _prefs!.getBool(_keyContentStories) ?? false;
_contentPosts = _prefs!.getBool(_keyContentPosts) ?? false;
_contentReels = _prefs!.getBool(_keyContentReels) ?? false;
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
// Load grayscale schedules
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
if (schedulesJson != null) {
try {
_grayscaleSchedules = List<Map<String, dynamic>>.from(
(jsonDecode(schedulesJson) as List).map(
(e) => Map<String, dynamic>.from(e),
),
);
} catch (_) {
_grayscaleSchedules = [];
}
}
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
// Load minimal mode
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
// Load previous states for smart restore
_prevDisableReels =
_prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
_prevDisableExplore =
_prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
_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;
// Focus mode settings
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
_noReels = _prefs!.getBool(_keyNoReels) ?? false;
_noAutoplay = _prefs!.getBool(_keyNoAutoplay) ?? false;
_noDMs = _prefs!.getBool(_keyNoDMs) ?? false;
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? true;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? true;
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
_notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false;
_notifyPersistent = _prefs!.getBool(_keyNotifyPersistent) ?? false;
_enabledTabs =
(_prefs!.getStringList(_keyEnabledTabs) ??
@@ -140,15 +364,31 @@ class SettingsService extends ChangeNotifier {
Future<void> setBlurExplore(bool v) async {
_blurExplore = v;
await _prefs?.setBool(_keyBlurExplore, v);
if (_minimalModeEnabled) {
await _checkAndAutoDisableMinimalMode();
}
notifyListeners();
}
Future<void> setBlurReels(bool v) async {
_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);
notifyListeners();
}
Future<void> setTapToUnblur(bool v) async {
_tapToUnblur = v;
await _prefs?.setBool(_keyTapToUnblur, v);
notifyListeners();
}
Future<void> setRequireLongPress(bool v) async {
_requireLongPress = v;
await _prefs?.setBool(_keyRequireLongPress, v);
@@ -167,6 +407,32 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
Future<void> setBreathGateSeconds(int seconds) async {
final clamped = seconds.clamp(3, 60);
_breathGateSeconds = clamped.toInt();
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
// Defer notifyListeners to next microtask to avoid rebuild conflicts
Future.microtask(notifyListeners);
}
Future<void> setWordChallengeCount(int count) async {
_wordChallengeCount = _normaliseWordChallengeCount(count);
await _prefs?.setInt(_keyWordChallengeCount, _wordChallengeCount);
notifyListeners();
}
int resolvedWordChallengeCount() {
if (_wordChallengeCount != 0) return _wordChallengeCount;
final now = DateTime.now().microsecondsSinceEpoch;
return 10 + (now % 26);
}
static int _normaliseWordChallengeCount(int count) {
if (count == 0) return 0;
const allowed = [20, 25, 30, 35];
return allowed.contains(count) ? count : 30;
}
Future<void> setEnableTextSelection(bool v) async {
_enableTextSelection = v;
await _prefs?.setBool(_keyEnableTextSelection, v);
@@ -179,6 +445,287 @@ class SettingsService extends ChangeNotifier {
notifyListeners();
}
Future<void> setBlockAutoplay(bool v) async {
_blockAutoplay = v;
await _prefs?.setBool(_keyBlockAutoplay, v);
notifyListeners();
}
// ── Extras (Phase 2) ──────────────────────────────────────────────────────
Future<void> setVideoDownloadEnabled(bool v) async {
_videoDownloadEnabled = v;
await _prefs?.setBool(_keyVideoDownloadEnabled, v);
notifyListeners();
}
Future<void> setHideSuggestedPosts(bool v) async {
_hideSuggestedPosts = v;
await _prefs?.setBool(_keyHideSuggestedPosts, v);
notifyListeners();
}
Future<void> setGrayscaleEnabled(bool v) async {
_grayscaleEnabled = v;
await _prefs?.setBool(_keyGrayscaleEnabled, v);
notifyListeners();
}
Future<void> setGrayscaleSchedules(
List<Map<String, dynamic>> schedules,
) async {
_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();
}
Future<void> setHideFollowerCounts(bool v) async {
_hideFollowerCounts = v;
await _prefs?.setBool(_keyHideFollowerCounts, v);
notifyListeners();
}
/// Setter for internal disable reels state (used by minimal mode submenu)
/// Auto-disables minimal mode if all features are turned off
Future<void> setDisableReelsEntirelyInternal(bool v) async {
_disableReelsEntirely = v;
await _prefs?.setBool('internal_disable_reels_entirely', v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners();
}
/// Setter for internal disable explore state (used by minimal mode submenu)
/// Auto-disables minimal mode if all features are turned off
Future<void> setDisableExploreEntirelyInternal(bool v) async {
_disableExploreEntirely = v;
await _prefs?.setBool('internal_disable_explore_entirely', v);
// Check if minimal mode should auto-disable
await _checkAndAutoDisableMinimalMode();
notifyListeners();
}
/// Setter for home feed scroll blocking state (used by minimal mode submenu).
Future<void> setBlockHomeFeedScrollInternal(bool v) async {
_blockHomeFeedScroll = v;
await _prefs?.setBool('internal_block_home_feed_scroll', v);
await _checkAndAutoDisableMinimalMode();
notifyListeners();
}
/// Helper: Auto-disable minimal mode if all its features are disabled
/// This ensures minimal mode auto-turns-off when user disables all sub-features
///
/// NOTE: We must check the RAW state variables here, NOT the public getters
/// (disableReelsEntirely/disableExploreEntirely), because those getters
/// unconditionally return true when _minimalModeEnabled is true, which would
/// make the "all disabled" condition impossible to reach.
Future<void> _checkAndAutoDisableMinimalMode() async {
if (!_minimalModeEnabled) return;
// Check the RAW saved state, not the getters
final rawReels =
_prefs?.getBool('internal_disable_reels_entirely') ??
_disableReelsEntirely;
final rawExplore =
_prefs?.getBool('internal_disable_explore_entirely') ??
_disableExploreEntirely;
final rawHomeFeedScroll =
_prefs?.getBool('internal_block_home_feed_scroll') ??
_blockHomeFeedScroll;
final allDisabled =
!rawReels && !rawExplore && !rawHomeFeedScroll && !_blurExplore;
if (allDisabled) {
_minimalModeEnabled = false;
await _prefs?.setBool(_keyMinimalModeEnabled, false);
}
}
/// Smart minimal mode toggle with state preservation
Future<void> setMinimalModeEnabled(bool v) async {
if (v) {
// ── Turning ON ──────────────────────────────────────────────────────────
// Save current 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();
}
Future<void> setReelsHistoryEnabled(bool v) async {
_reelsHistoryEnabled = v;
await _prefs?.setBool(_keyReelsHistoryEnabled, v);
notifyListeners();
}
void setDarkMode(bool dark) {
if (_isDarkMode != dark) {
_isDarkMode = dark;
@@ -186,31 +733,6 @@ class SettingsService extends ChangeNotifier {
}
}
// Granular Ghost Mode setters
Future<void> setGhostTyping(bool v) async {
_ghostTyping = v;
await _prefs?.setBool(_keyGhostTyping, v);
notifyListeners();
}
Future<void> setGhostSeen(bool v) async {
_ghostSeen = v;
await _prefs?.setBool(_keyGhostSeen, v);
notifyListeners();
}
Future<void> setGhostStories(bool v) async {
_ghostStories = v;
await _prefs?.setBool(_keyGhostStories, v);
notifyListeners();
}
Future<void> setGhostDmPhotos(bool v) async {
_ghostDmPhotos = v;
await _prefs?.setBool(_keyGhostDmPhotos, v);
notifyListeners();
}
Future<void> setSanitizeLinks(bool v) async {
_sanitizeLinks = v;
await _prefs?.setBool(_keySanitizeLinks, v);
@@ -220,12 +742,69 @@ class SettingsService extends ChangeNotifier {
Future<void> setNotifyDMs(bool v) async {
_notifyDMs = v;
await _prefs?.setBool(_keyNotifyDMs, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
Future<void> setNotifyActivity(bool v) async {
_notifyActivity = v;
await _prefs?.setBool(_keyNotifyActivity, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
Future<void> setNotifySessionEnd(bool v) async {
_notifySessionEnd = v;
await _prefs?.setBool(_keyNotifySessionEnd, v);
if (v) await NotificationService().requestPermissionsNow();
notifyListeners();
}
Future<void> setNotifyPersistent(bool v) async {
_notifyPersistent = v;
await _prefs?.setBool(_keyNotifyPersistent, v);
if (v) {
await NotificationService().requestPermissionsNow();
} else {
await NotificationService().cancelPersistentNotification(id: 5001);
}
notifyListeners();
}
// ── Focus mode settings ──────────────────────────────────────────────────────
Future<void> setGhostMode(bool v) async {
_ghostMode = v;
await _prefs?.setBool(_keyGhostMode, v);
notifyListeners();
}
Future<void> setNoAds(bool v) async {
_noAds = v;
await _prefs?.setBool(_keyNoAds, v);
notifyListeners();
}
Future<void> setNoStories(bool v) async {
_noStories = v;
await _prefs?.setBool(_keyNoStories, v);
notifyListeners();
}
Future<void> setNoReels(bool v) async {
_noReels = v;
await _prefs?.setBool(_keyNoReels, v);
notifyListeners();
}
Future<void> setNoAutoplay(bool v) async {
_noAutoplay = v;
await _prefs?.setBool(_keyNoAutoplay, v);
notifyListeners();
}
Future<void> setNoDMs(bool v) async {
_noDMs = v;
await _prefs?.setBool(_keyNoDMs, v);
notifyListeners();
}
+1 -1
View File
@@ -517,7 +517,7 @@ class DisciplineChallenge {
];
/// Shows the word challenge dialog. Returns true if successful.
static Future<bool> show(BuildContext context, {int count = 15}) async {
static Future<bool> show(BuildContext context, {int count = 30}) async {
final list = List<String>.from(_words)..shuffle();
final challenge = list.take(count).join(' ');
final controller = TextEditingController();
@@ -0,0 +1,141 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'script_registry_v2_overlay.dart';
class ScriptEngineV2Overlay {
final InAppWebViewController controller;
final SharedPreferences prefs;
final Map<String, String> _cache = {};
ScriptEngineV2Overlay({required this.controller, required this.prefs});
Future<void> initDocumentStartScripts() async {
for (final s in V2OverlayScriptRegistry.all) {
final enabled = _getEnabled(s.id);
s.enabled = enabled;
if (!enabled) continue;
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
final code = await _load(s.assetPath);
if (code == null) continue;
await controller.addUserScript(
userScript: UserScript(
source: code,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
allowedOriginRules: {'https://www.instagram.com'},
),
);
}
}
}
Future<void> injectDocumentEndScripts() async {
for (final s in V2OverlayScriptRegistry.all) {
final enabled = _getEnabled(s.id);
s.enabled = enabled;
if (!enabled) continue;
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END) {
final code = await _load(s.assetPath);
if (code == null) continue;
try {
await controller.evaluateJavascript(source: code);
} catch (_) {
// Best-effort injection; never crash UI.
}
}
}
await _pushContentFlagsIfNeeded();
}
Future<void> toggle(V2OverlayScriptId id, bool enabled) async {
await prefs.setBool(_enabledKey(id), enabled);
// For DOCUMENT_START scripts, require reload for clean removal.
if (V2OverlayScriptRegistry.byId(id).injectionTime ==
UserScriptInjectionTime.AT_DOCUMENT_START) {
await controller.reload();
return;
}
// For DOCUMENT_END scripts: just reload too to ensure DOM effects stop.
await controller.reload();
}
bool _getEnabled(V2OverlayScriptId id) {
return prefs.getBool(_enabledKey(id)) ??
(id == V2OverlayScriptId.themeDetector);
}
String _enabledKey(V2OverlayScriptId id) => 'fg_v2_${id.name}_enabled';
Future<void> _pushContentFlagsIfNeeded() async {
final contentScriptEnabled = _getEnabled(V2OverlayScriptId.contentHider);
final contentFlags = <String, bool>{
'stories': prefs.getBool('content_stories') ?? false,
'posts': prefs.getBool('content_posts') ?? false,
'reels': prefs.getBool('content_reels') ?? false,
'suggested': prefs.getBool('content_suggested') ?? false,
};
// Apply DOM content hider flags
if (contentScriptEnabled) {
await controller.evaluateJavascript(
source: 'window.__fgContent?.applyAll(${jsonEncode(contentFlags)});',
);
}
// Also push network filter flags used by fetch_interceptor.js
// so toggles actually affect request/response behavior.
final noAds =
(prefs.getBool('no_ads') ?? false) ||
(prefs.getBool(_enabledKey(V2OverlayScriptId.adBlockerDom)) ?? false);
final blockFeedPosts = contentFlags['posts'] ?? false;
final blockSuggested = contentFlags['suggested'] ?? false;
final blockReels = contentFlags['reels'] ?? false;
final blockAutoplay =
prefs.getBool(_enabledKey(V2OverlayScriptId.autoplayBlocker)) ?? false;
await controller.evaluateJavascript(
source:
'window.__fgSetFilterConfig?.(${jsonEncode({
// Strictly requested: when Hide Feed Posts is ON, block ALL graphql/query.
'blockGraphQLQueryWhenFeedPosts': blockFeedPosts,
// Ads blocker: use existing FocusGram "noAds" toggle (wired elsewhere in prefs).
'blockAds': noAds,
'blockSponsored': noAds,
'blockSuggested': blockSuggested,
// Keep video blocking controlled by existing toggles if desired.
'blockVideos': blockReels,
'blockAutoplay': blockAutoplay,
})});',
);
await controller.evaluateJavascript(
source: 'window.__fgSetBlockAutoplay?.($blockAutoplay);',
);
}
Future<String?> _load(String assetPath) async {
if (_cache.containsKey(assetPath)) return _cache[assetPath];
try {
final code = await rootBundle.loadString(assetPath);
_cache[assetPath] = code;
return code;
} catch (_) {
return null;
}
}
}
@@ -0,0 +1,77 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
enum V2OverlayScriptId {
ghostMode,
themeDetector,
adBlockerDom,
contentHider,
fetchInterceptor,
autoplayBlocker,
}
class V2OverlayInstaScript {
final V2OverlayScriptId id;
final String name;
final String assetPath;
final UserScriptInjectionTime injectionTime;
bool enabled;
V2OverlayInstaScript({
required this.id,
required this.name,
required this.assetPath,
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
this.enabled = false,
});
}
class V2OverlayScriptRegistry {
static final List<V2OverlayInstaScript> all = [
V2OverlayInstaScript(
id: V2OverlayScriptId.ghostMode,
name: 'ghost_mode',
assetPath: 'assets/scripts/ghost_mode.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.themeDetector,
name: 'theme_detector',
assetPath: 'assets/scripts/theme_detector.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: true,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.adBlockerDom,
name: 'ad_blocker_dom',
assetPath: 'assets/scripts/ad_blocker_dom.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.contentHider,
name: 'content_hider',
assetPath: 'assets/scripts/content_hider.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.fetchInterceptor,
name: 'fetch_interceptor',
assetPath: 'assets/scripts/fetch_interceptor.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
V2OverlayInstaScript(
id: V2OverlayScriptId.autoplayBlocker,
name: 'autoplay_blocker',
assetPath: 'assets/scripts/autoplay_blocker.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
];
static V2OverlayInstaScript byId(V2OverlayScriptId id) {
return all.firstWhere((s) => s.id == id);
}
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import '../services/remote_popup_service.dart';
class RemotePopupHandler {
static Future<void> checkAndShow(BuildContext context) async {
final popup = await RemotePopupService.fetchPopup();
if (popup == null) return;
final shouldShow = await RemotePopupService.shouldShow(popup);
if (!shouldShow) return;
await RemotePopupService.markShown(popup);
if (!context.mounted) return;
showDialog(
context: context,
barrierDismissible: true,
builder: (_) {
return AlertDialog(
title: Text(popup.title),
content: Text(popup.body),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(popup.buttonText),
),
],
);
},
);
}
}
-23
View File
@@ -1,23 +0,0 @@
Categories:
- Multimedia
- Social
License: AGPL-3.0-only
SourceCode: https://github.com/Ujwal223/FocusGram
IssueTracker: https://github.com/Ujwal223/FocusGram/issues
AutoName: FocusGram
RepoType: git
Repo: https://github.com/Ujwal223/FocusGram
Builds:
- versionName: '0.9.8'
versionCode: 1
commit: v0.9.8
gradle:
- yes
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: '0.9.8'
CurrentVersionCode: 1
+120 -48
View File
@@ -37,10 +37,10 @@ packages:
dependency: "direct main"
description:
name: app_settings
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
url: "https://pub.dev"
source: hosted
version: "6.1.1"
version: "7.0.0"
archive:
dependency: transitive
description:
@@ -77,10 +77,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
@@ -145,6 +145,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
equatable:
dependency: transitive
description:
name: equatable
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.8"
fake_async:
dependency: transitive
description:
@@ -201,19 +209,91 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.9.3+5"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
url: "https://pub.dev"
source: hosted
version: "0.71.0"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_inappwebview:
dependency: "direct main"
description:
name: flutter_inappwebview
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
name: flutter_inappwebview_internal_annotations
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
url: "https://pub.dev"
source: hosted
version: "1.3.0"
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://pub.dev"
source: hosted
version: "1.3.0+1"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -292,10 +372,10 @@ packages:
dependency: "direct main"
description:
name: google_fonts
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
url: "https://pub.dev"
source: hosted
version: "8.0.2"
version: "8.1.0"
gtk:
dependency: transitive
description:
@@ -460,18 +540,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
@@ -512,6 +592,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
@@ -572,18 +668,18 @@ packages:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
url: "https://pub.dev"
source: hosted
version: "12.0.1"
version: "11.4.0"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
url: "https://pub.dev"
source: hosted
version: "13.0.1"
version: "12.1.0"
permission_handler_apple:
dependency: transitive
description:
@@ -668,10 +764,10 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.4"
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
@@ -769,10 +865,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.9"
timezone:
dependency: transitive
description:
@@ -877,38 +973,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
url: "https://pub.dev"
source: hosted
version: "4.13.1"
webview_flutter_android:
dependency: "direct main"
description:
name: webview_flutter_android
sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510
url: "https://pub.dev"
source: hosted
version: "4.10.11"
webview_flutter_platform_interface:
win32:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "2.14.0"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "108bd85d0ff20bff1e8b52a040f5c19b6b9fc4a78fdf3160534ff5a11a82e267"
url: "https://pub.dev"
source: hosted
version: "3.23.7"
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
+23 -15
View File
@@ -1,8 +1,8 @@
name: focusgram
description: "FocusGram — Distraction-free Instagram WebView wrapper."
description: "FocusGram is a free, open-source digital wellness tool for Android. Blocks distracting content, sets time limits, and helps you use social media intentionally."
publish_to: 'none'
version: 0.9.8+1
version: 2.0.0
environment:
sdk: ^3.10.7
@@ -11,13 +11,11 @@ dependencies:
flutter:
sdk: flutter
# WebView engine — latest stable
webview_flutter: ^4.13.1
# Android-specific WebView platform (explicit dep required for direct import)
webview_flutter_android: ^4.3.0
# WebView engine
flutter_inappwebview: ^6.1.5
# Local key-value persistence — latest stable
shared_preferences: ^2.5.4
shared_preferences: ^2.5.5
# Date/time formatting for daily resets — latest stable
intl: ^0.20.2
@@ -30,22 +28,26 @@ dependencies:
# URL launcher for About page links — latest stable
url_launcher: ^6.3.2
package_info_plus: ^9.0.0
# Handling Instagram deep links — latest stable
app_links: ^6.3.2
app_links: ^6.4.1
# Open system settings — latest stable
app_settings: ^6.1.1
google_fonts: ^8.0.2
http: ^1.3.0
permission_handler: ^12.0.1
app_settings: ^7.0.0
google_fonts: ^8.1.0
http: ^1.6.0
permission_handler: ^11.4.0
# Image/file picker for story uploads on Android
image_picker: ^1.1.2
image_picker: ^1.2.0
flutter_windowmanager_plus: ^1.0.1
# Charts for on-device screen time dashboard (MIT)
fl_chart: ^0.71.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.13.1
flutter_launcher_icons: ^0.14.4
flutter:
uses-material-design: true
@@ -53,6 +55,12 @@ flutter:
assets:
- assets/images/focusgram.png
- assets/images/focusgram.ico
- assets/scripts/ghost_mode.js
- assets/scripts/ad_blocker_dom.js
- assets/scripts/content_hider.js
- assets/scripts/theme_detector.js
- assets/scripts/fetch_interceptor.js
- assets/scripts/autoplay_blocker.js
flutter_launcher_icons:
android: true
@@ -60,6 +68,6 @@ flutter_launcher_icons:
image_path: "assets/images/focusgram.png"
adaptive_icon_background: "#000000"
adaptive_icon_foreground: "assets/images/focusgram.png"
min_sdk_android: 21
min_sdk_android: 24
@@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focusgram/screens/main_webview_page.dart';
void main() {
group('handleFocusGramMediaDownload', () {
test('rejects non-http(s) schemes', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{"type":"video","url":"file:///etc/passwd","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
test('accepts http(s) instagram-like hosts and calls launcher', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw:
'{"type":"video","url":"https://cdninstagram.com/v/1.mp4","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isTrue);
expect(launched, hasLength(1));
expect(launched.first.scheme, 'https');
expect(launched.first.host.toLowerCase(), contains('cdninstagram.com'));
});
test('rejects non-instagram hosts even if http(s)', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw:
'{"type":"video","url":"https://example.com/video.mp4","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
test('rejects malformed JSON safely', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{not json',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
test('rejects missing url field', () async {
final launched = <Uri>[];
final ok = await handleFocusGramMediaDownload(
raw: '{"type":"video","filename":"x"}',
launch: (uri) async => launched.add(uri),
);
expect(ok, isFalse);
expect(launched, isEmpty);
});
});
}
+90
View File
@@ -0,0 +1,90 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/injection_manager.dart';
import 'package:focusgram/services/adblock/adblock_content_blocker_loader.dart';
import 'package:focusgram/services/session_manager.dart';
import 'package:focusgram/services/settings_service.dart';
class _FakeJsEvaluator implements JsEvaluator {
final List<String> sources = [];
@override
Future<void> evaluateJavascript({required String source}) async {
sources.add(source);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test(
'v2AdBlockerDomEnabled(true) does NOT trigger sponsored-post JS injection (handled by V2 engine)',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setV2AdBlockerDomEnabled(true);
expect(settings.v2AdBlockerDomEnabled, isTrue);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
// Verify that sponsored posts JS injection is NOT triggered by InjectionManager
// (it's handled by the V2 DOM Ad Blocker engine instead)
final sponsoredPostsInjected = fakeEval.sources.any(
(s) => s.contains('hideSponsoredPosts') || s.contains('Sponsored'),
);
expect(
sponsoredPostsInjected,
isFalse,
reason:
'Sponsored posts blocking is now handled by V2 DOM Ad Blocker, not JS injection',
);
},
);
test(
'adblock parser extracts strict host rules and ignores allow/cosmetic rules',
() {
final hosts = AdblockContentBlockerLoader.parseHostsFromFilterText('''
! comment
[Adblock Plus 2.0]
||ads.example.com^
||tracker.example.net/path.js\$third-party
@@||allowed.example.com^
example.com##.sponsored
||wild*.example.com^
||bad,domain.example^
||sub.adguard.example.org^\$script,third-party
''');
expect(
hosts,
containsAll({
'ads.example.com',
'tracker.example.net',
'sub.adguard.example.org',
}),
);
expect(hosts, isNot(contains('allowed.example.com')));
expect(hosts, isNot(contains('wild*.example.com')));
expect(hosts, isNot(contains('bad,domain.example')));
},
);
}
@@ -1,372 +0,0 @@
// test/services/injection_controller_test.dart
//
// Tests for InjectionController JS/CSS builder, Ghost Mode keyword resolver,
// and JS string generation.
//
// Run with: flutter test test/services/injection_controller_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:focusgram/services/injection_controller.dart';
void main() {
// resolveBlockedKeywords
group('InjectionController.resolveBlockedKeywords', () {
test('returns empty list when all flags are false', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: false,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(kws, isEmpty);
});
test('includes seen keywords when seenStatus is true', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: false,
seenStatus: true,
stories: false,
dmPhotos: false,
);
expect(
kws,
containsAll(['/seen', 'media/seen', 'reel/seen', '/mark_seen']),
);
});
test('includes typing keywords when typingIndicator is true', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: true,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(kws, containsAll(['set_typing_status', '/typing']));
});
test('includes live keywords when stories is true', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: false,
seenStatus: false,
stories: true,
dmPhotos: false,
);
expect(kws, contains('/live/'));
});
test('includes visual_item_seen when dmPhotos is true', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: false,
seenStatus: false,
stories: false,
dmPhotos: true,
);
expect(kws, contains('visual_item_seen'));
});
test('all flags true — returns all groups combined', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: true,
seenStatus: true,
stories: true,
dmPhotos: true,
);
// Must contain at least one keyword from every group
expect(
kws,
containsAll([
'/seen',
'set_typing_status',
'/live/',
'visual_item_seen',
]),
);
});
test('no duplicates in result (seen + typing + stories + dmPhotos)', () {
final kws = InjectionController.resolveBlockedKeywords(
typingIndicator: true,
seenStatus: true,
stories: true,
dmPhotos: true,
);
final unique = kws.toSet();
expect(kws.length, unique.length);
});
});
// resolveWsBlockedKeywords
group('InjectionController.resolveWsBlockedKeywords', () {
test('returns empty list when typingIndicator is false', () {
expect(
InjectionController.resolveWsBlockedKeywords(typingIndicator: false),
isEmpty,
);
});
test('returns non-empty list when typingIndicator is true', () {
final kws = InjectionController.resolveWsBlockedKeywords(
typingIndicator: true,
);
expect(kws, isNotEmpty);
expect(kws, contains('activity_status'));
});
});
// buildGhostModeJS
group('InjectionController.buildGhostModeJS', () {
test('returns empty string when all flags are false', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(js.trim(), isEmpty);
});
test('generated JS contains seen keywords when seenStatus=true', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: true,
stories: false,
dmPhotos: false,
);
expect(js, contains('/seen'));
expect(js, contains('media/seen'));
});
test('generated JS contains typing keywords when typingIndicator=true', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: true,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(js, contains('set_typing_status'));
});
test('generated JS contains live keyword when stories=true', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: false,
stories: true,
dmPhotos: false,
);
expect(js, contains('/live/'));
});
test('generated JS contains BLOCKED array and shouldBlock function', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: true,
stories: false,
dmPhotos: false,
);
expect(js, contains('BLOCKED'));
expect(js, contains('shouldBlock'));
});
test('generated JS wraps XHR and fetch', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: true,
seenStatus: true,
stories: true,
dmPhotos: true,
);
expect(js, contains('window.fetch'));
expect(js, contains('XMLHttpRequest.prototype.open'));
expect(js, contains('XMLHttpRequest.prototype.send'));
});
test('WS patch is included when typingIndicator=true', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: true,
seenStatus: false,
stories: false,
dmPhotos: false,
);
expect(js, contains('WebSocket'));
});
test('WS patch is NOT included when typingIndicator=false', () {
final js = InjectionController.buildGhostModeJS(
typingIndicator: false,
seenStatus: true,
stories: false,
dmPhotos: false,
);
// WS_KEYS will be empty array; the `if (WS_KEYS.length > 0)` guard
// prevents the WS override from running but the string may still be present.
// At minimum, WS_KEYS should be empty in the output.
expect(js, contains('WS_KEYS = []'));
});
});
// buildSessionStateJS
group('InjectionController.buildSessionStateJS', () {
test('returns true assignment when active', () {
expect(
InjectionController.buildSessionStateJS(true),
contains('__focusgramSessionActive = true'),
);
});
test('returns false assignment when inactive', () {
expect(
InjectionController.buildSessionStateJS(false),
contains('__focusgramSessionActive = false'),
);
});
});
// softNavigateJS
group('InjectionController.softNavigateJS', () {
test('contains the target path', () {
final js = InjectionController.softNavigateJS('/direct/inbox/');
expect(js, contains('/direct/inbox/'));
});
test('contains location.href assignment', () {
final js = InjectionController.softNavigateJS('/explore/');
expect(js, contains('location.href'));
});
});
// buildInjectionJS
group('InjectionController.buildInjectionJS', () {
InjectionController.buildInjectionJS; // reference check
test('contains session state flag', () {
final js = _buildFull();
expect(js, contains('__focusgramSessionActive'));
});
test('contains path tracker when assembled', () {
final js = _buildFull();
expect(js, contains('fgTrackPath'));
});
test('includes reels block JS when session is not active', () {
final js = InjectionController.buildInjectionJS(
sessionActive: false,
blurExplore: false,
blurReels: false,
ghostTyping: false,
ghostSeen: false,
ghostStories: false,
ghostDmPhotos: false,
enableTextSelection: false,
);
expect(js, contains('fgReelsBlock'));
});
test('does NOT include reels block JS when session is active', () {
final js = InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
ghostTyping: false,
ghostSeen: false,
ghostStories: false,
ghostDmPhotos: false,
enableTextSelection: false,
);
expect(js, isNot(contains('fgReelsBlock')));
});
test('always includes link sanitizer', () {
final js = InjectionController.buildInjectionJS(
sessionActive: false,
blurExplore: false,
blurReels: false,
ghostTyping: false,
ghostSeen: false,
ghostStories: false,
ghostDmPhotos: false,
enableTextSelection: false,
);
// linkSanitizationJS is now always injected (not togglable)
expect(js, contains('fgSanitize'));
});
test('returns non-empty string in all cases', () {
expect(_buildFull().trim(), isNotEmpty);
});
});
// iOSUserAgent sanity
group('InjectionController.iOSUserAgent', () {
test('contains iPhone identifier', () {
expect(InjectionController.iOSUserAgent, contains('iPhone'));
});
test('contains FBAN (Instagram app identifier)', () {
expect(InjectionController.iOSUserAgent, contains('FBAN'));
});
test('is non-empty', () {
expect(InjectionController.iOSUserAgent, isNotEmpty);
});
});
// notificationBridgeJS
group('InjectionController.notificationBridgeJS', () {
test('contains Notification bridge guard', () {
expect(
InjectionController.notificationBridgeJS,
contains('fgNotifBridged'),
);
});
test('patches window.Notification', () {
expect(
InjectionController.notificationBridgeJS,
contains('window.Notification'),
);
});
});
// linkSanitizationJS
group('InjectionController.linkSanitizationJS', () {
test('strips igsh param', () {
expect(InjectionController.linkSanitizationJS, contains('igsh'));
});
test('strips utm params', () {
expect(InjectionController.linkSanitizationJS, contains('utm_source'));
});
test('strips fbclid', () {
expect(InjectionController.linkSanitizationJS, contains('fbclid'));
});
test('patches navigator.share', () {
expect(
InjectionController.linkSanitizationJS,
contains('navigator.share'),
);
});
});
}
/// Helper to create a fully-featured injection JS for common assertions.
String _buildFull() => InjectionController.buildInjectionJS(
sessionActive: false,
blurExplore: true,
blurReels: true,
ghostTyping: true,
ghostSeen: true,
ghostStories: true,
ghostDmPhotos: true,
enableTextSelection: false,
);
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/injection_manager.dart';
import 'package:focusgram/services/session_manager.dart';
import 'package:focusgram/services/settings_service.dart';
class _FakeJsEvaluator implements JsEvaluator {
final List<String> sources = [];
@override
Future<void> evaluateJavascript({required String source}) async {
sources.add(source);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test(
'does NOT inject hideSuggestedPosts JS even when legacy setting is true',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setHideSuggestedPosts(true);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
expect(any, isFalse);
},
);
test(
'does NOT inject hideSuggestedPosts JS when settings.hideSuggestedPosts=false',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setHideSuggestedPosts(false);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
expect(any, isFalse);
},
);
test(
'injects video downloader JS only when settings.videoDownloadEnabled=true',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setVideoDownloadEnabled(true);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any(
(s) => s.contains('__fgMediaDownloadRunning'),
);
expect(any, isTrue);
},
);
test(
'does NOT inject video downloader JS when settings.videoDownloadEnabled=false',
() async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setVideoDownloadEnabled(false);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any(
(s) => s.contains('__fgMediaDownloadRunning'),
);
expect(any, isFalse);
},
);
test('injects home feed scroll lock flag when enabled', () async {
final prefs = await SharedPreferences.getInstance();
final sm = SessionManager();
final fakeEval = _FakeJsEvaluator();
final mgr = InjectionManager.forTest(
jsEvaluator: fakeEval,
prefs: prefs,
sessionManager: sm,
);
final settings = SettingsService();
await settings.init();
await settings.setBlockHomeFeedScrollInternal(true);
mgr.setSettingsService(settings);
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
final any = fakeEval.sources.any(
(s) => s.contains('window.__fgBlockHomeFeedScroll = true;'),
);
expect(any, isTrue);
});
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:focusgram/services/injection_controller.dart';
void main() {
group('InjectionController reels blocker', () {
test('includes strict reels blocker JS when sessionActive=false', () {
final js = InjectionController.buildInjectionJS(
sessionActive: false,
blurExplore: false,
blurReels: false,
tapToUnblur: false,
enableTextSelection: false,
hideSuggestedPosts: false,
hideSponsoredPosts: false,
hideLikeCounts: false,
hideFollowerCounts: false,
hideExploreTab: false,
hideReelsTab: false,
hideShopTab: false,
disableReelsEntirely: false,
blockHomeFeedScroll: false,
);
expect(js, contains('window.__fgReelsBlockPatched'));
expect(js, contains("window.location.href = '/reels/?fg=blocked';"));
});
test(
'does NOT include strict reels blocker JS when sessionActive=true',
() {
final js = InjectionController.buildInjectionJS(
sessionActive: true,
blurExplore: false,
blurReels: false,
tapToUnblur: false,
enableTextSelection: false,
hideSuggestedPosts: false,
hideSponsoredPosts: false,
hideLikeCounts: false,
hideFollowerCounts: false,
hideExploreTab: false,
hideReelsTab: false,
hideShopTab: false,
disableReelsEntirely: false,
blockHomeFeedScroll: false,
);
expect(js, isNot(contains('window.__fgReelsBlockPatched')));
expect(
js,
isNot(contains("window.location.href = '/reels/?fg=blocked';")),
);
},
);
});
}
@@ -0,0 +1,69 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/screen_time_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('init loads persisted secondsByDate', () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
ScreenTimeService.prefKey,
'{"2026-01-01": 42, "2026-01-02": 7}',
);
final s = ScreenTimeService();
await s.init();
expect(s.secondsByDate['2026-01-01'], 42);
expect(s.secondsByDate['2026-01-02'], 7);
});
test('resetAll clears stored data and in-memory map', () async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(ScreenTimeService.prefKey, '{"2026-01-01": 42}');
final s = ScreenTimeService();
await s.init();
expect(s.secondsByDate.isNotEmpty, isTrue);
await s.resetAll();
expect(s.secondsByDate, isEmpty);
final raw = prefs.getString(ScreenTimeService.prefKey);
expect(raw, isNull);
});
test(
'startTracking increments today seconds and stopTracking persists',
() async {
final s = ScreenTimeService();
await s.init();
final beforeTodayKey = DateTime.now();
final todayKey =
'${beforeTodayKey.year.toString().padLeft(4, '0')}-'
'${beforeTodayKey.month.toString().padLeft(2, '0')}-'
'${beforeTodayKey.day.toString().padLeft(2, '0')}';
s.startTracking();
// Wait ~2 seconds (test is unit-ish; still acceptable).
await Future<void>.delayed(const Duration(seconds: 2));
s.stopTracking();
expect(s.secondsByDate[todayKey], isNotNull);
expect(s.secondsByDate[todayKey]!, greaterThanOrEqualTo(2));
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(ScreenTimeService.prefKey);
expect(stored, isNotNull);
expect(stored, contains(todayKey));
},
);
}
+73 -218
View File
@@ -1,249 +1,104 @@
// test/services/settings_service_test.dart
//
// Tests for SettingsService default values, setters, tab management,
// and the legacy GhostMode key migration.
//
// Note: SettingsService requires SharedPreferences. We use
// SharedPreferences.setMockInitialValues({}) to avoid platform channel calls.
//
// Run with: flutter test test/services/settings_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:focusgram/services/settings_service.dart';
/// Helper: create an initialised SettingsService with a clean prefs slate.
Future<SettingsService> makeService() async {
SharedPreferences.setMockInitialValues({});
final svc = SettingsService();
await svc.init();
return svc;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
// Default values
group('SettingsService — Phase 2 Extras', () {
test('defaults are OFF for video download/hide suggested', () async {
final s = SettingsService();
await s.init();
group('SettingsService — defaults', () {
test('blurExplore defaults to true', () async {
expect((await makeService()).blurExplore, isTrue);
expect(s.videoDownloadEnabled, isFalse);
expect(s.hideSuggestedPosts, isFalse);
});
test('blurReels defaults to false', () async {
expect((await makeService()).blurReels, 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('requireLongPress defaults to true', () async {
expect((await makeService()).requireLongPress, isTrue);
});
test('setHideSuggestedPosts persists', () async {
final s = SettingsService();
await s.init();
test('showBreathGate defaults to true', () async {
expect((await makeService()).showBreathGate, isTrue);
});
await s.setHideSuggestedPosts(true);
test('requireWordChallenge defaults to true', () async {
expect((await makeService()).requireWordChallenge, isTrue);
});
test('enableTextSelection defaults to false', () async {
expect((await makeService()).enableTextSelection, isFalse);
});
test('ghostTyping defaults to true', () async {
expect((await makeService()).ghostTyping, isTrue);
});
test('ghostSeen defaults to true', () async {
expect((await makeService()).ghostSeen, isTrue);
});
test('ghostStories defaults to true', () async {
expect((await makeService()).ghostStories, isTrue);
});
test('ghostDmPhotos defaults to true', () async {
expect((await makeService()).ghostDmPhotos, isTrue);
});
test('sanitizeLinks defaults to true', () async {
expect((await makeService()).sanitizeLinks, isTrue);
});
test('isFirstRun defaults to true', () async {
expect((await makeService()).isFirstRun, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(s.hideSuggestedPosts, isTrue);
expect(prefs.getBool('hide_suggested_posts'), isTrue);
});
});
group('SettingsService — minimal mode', () {
test(
'anyGhostModeEnabled is true when all ghost settings are true',
'home feed scroll can be disabled while minimal mode stays on',
() async {
expect((await makeService()).anyGhostModeEnabled, isTrue);
},
);
});
final s = SettingsService();
await s.init();
// Setters
await s.setMinimalModeEnabled(true);
await s.setBlockHomeFeedScrollInternal(false);
group('SettingsService — setters persist and notify', () {
test('setBlurExplore changes value and notifies', () async {
final svc = await makeService();
bool notified = false;
svc.addListener(() => notified = true);
await svc.setBlurExplore(false);
expect(svc.blurExplore, isFalse);
expect(notified, isTrue);
});
expect(s.minimalModeEnabled, isTrue);
expect(s.blockHomeFeedScroll, isFalse);
test('setBlurReels persists', () async {
final svc = await makeService();
await svc.setBlurReels(true);
expect(svc.blurReels, isTrue);
});
test('setRequireLongPress persists', () async {
final svc = await makeService();
await svc.setRequireLongPress(false);
expect(svc.requireLongPress, isFalse);
});
test('setGhostTyping turns off ghost typing', () async {
final svc = await makeService();
await svc.setGhostTyping(false);
expect(svc.ghostTyping, isFalse);
});
test('setGhostSeen turns off ghost seen', () async {
final svc = await makeService();
await svc.setGhostSeen(false);
expect(svc.ghostSeen, isFalse);
});
test('setGhostStories turns off ghost stories', () async {
final svc = await makeService();
await svc.setGhostStories(false);
expect(svc.ghostStories, isFalse);
});
test('setGhostDmPhotos turns off ghost dm photos', () async {
final svc = await makeService();
await svc.setGhostDmPhotos(false);
expect(svc.ghostDmPhotos, isFalse);
});
test('setSanitizeLinks persists', () async {
final svc = await makeService();
await svc.setSanitizeLinks(false);
expect(svc.sanitizeLinks, isFalse);
});
test('setFirstRunCompleted sets isFirstRun to false', () async {
final svc = await makeService();
await svc.setFirstRunCompleted();
expect(svc.isFirstRun, isFalse);
});
test('setEnableTextSelection persists', () async {
final svc = await makeService();
await svc.setEnableTextSelection(true);
expect(svc.enableTextSelection, isTrue);
});
});
// anyGhostModeEnabled
group('SettingsService.anyGhostModeEnabled', () {
test('is false when all ghost flags are off', () async {
final svc = await makeService();
await svc.setGhostTyping(false);
await svc.setGhostSeen(false);
await svc.setGhostStories(false);
await svc.setGhostDmPhotos(false);
expect(svc.anyGhostModeEnabled, isFalse);
});
test('is true when only one ghost flag is on', () async {
final svc = await makeService();
await svc.setGhostTyping(false);
await svc.setGhostSeen(false);
await svc.setGhostStories(false);
await svc.setGhostDmPhotos(true); // only dmPhotos on
expect(svc.anyGhostModeEnabled, isTrue);
});
});
// Tab management
group('SettingsService — tab management', () {
test('default tabs include Home, Reels, Messages, Profile', () async {
final svc = await makeService();
expect(
svc.enabledTabs,
containsAll(['Home', 'Reels', 'Messages', 'Profile']),
);
});
test('toggleTab removes an enabled tab', () async {
final svc = await makeService();
final before = List<String>.from(svc.enabledTabs);
await svc.toggleTab('Reels');
expect(svc.enabledTabs, isNot(contains('Reels')));
expect(svc.enabledTabs.length, before.length - 1);
});
test('toggleTab adds a tab back when toggled again', () async {
final svc = await makeService();
await svc.toggleTab('Reels');
await svc.toggleTab('Reels');
expect(svc.enabledTabs, contains('Reels'));
});
test('toggleTab does not remove the last remaining tab', () async {
final svc = await makeService();
final tabs = List<String>.from(svc.enabledTabs);
for (final t in tabs.sublist(0, tabs.length - 1)) {
await svc.toggleTab(t);
}
final last = svc.enabledTabs.first;
await svc.toggleTab(last); // try to remove the last one
expect(svc.enabledTabs.length, 1); // still 1
});
test('reorderTab moves item correctly — no tabs are lost', () async {
final svc = await makeService();
final original = List<String>.from(svc.enabledTabs);
await svc.reorderTab(0, 1);
expect(svc.enabledTabs.toSet(), original.toSet());
});
});
// Legacy Ghost Mode migration
group('SettingsService — legacy ghost mode migration', () {
test(
'migrates legacy ghost_mode=true to all four granular flags',
() async {
SharedPreferences.setMockInitialValues({'set_ghost_mode': true});
final svc = SettingsService();
await svc.init();
expect(svc.ghostTyping, isTrue);
expect(svc.ghostSeen, isTrue);
expect(svc.ghostStories, isTrue);
expect(svc.ghostDmPhotos, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool('internal_block_home_feed_scroll'), isFalse);
expect(prefs.getBool('minimal_mode_enabled'), isTrue);
},
);
test(
'migrates legacy ghost_mode=false to all four granular flags off',
'minimal mode turns off when all child features are disabled',
() async {
SharedPreferences.setMockInitialValues({'set_ghost_mode': false});
final svc = SettingsService();
await svc.init();
expect(svc.ghostTyping, isFalse);
expect(svc.ghostSeen, isFalse);
expect(svc.ghostStories, isFalse);
expect(svc.ghostDmPhotos, isFalse);
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
@@ -0,0 +1,84 @@
// android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt
//
// Ghost mode WebView integration notes
package com.focusgram.focusgram
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// WEBVIEW WIDGET INTEGRATION
// ─────────────────────────────────────────────────────────────────────────────
//
// In your WebView widget (wherever InAppWebView is constructed):
//
// class InstagramWebView extends StatefulWidget { ... }
//
// class _InstagramWebViewState extends State<InstagramWebView> {
// late GhostModeService _ghost;
//
// @override
// void initState() {
// super.initState();
// _ghost = GhostModeService();
// _ghost.load().then((_) {
// setState(() {});
// });
// }
//
// @override
// Widget build(BuildContext context) {
// return InAppWebView(
// initialUrlRequest: URLRequest(
// url: WebUri('https://www.instagram.com'),
// ),
// initialSettings: _ghost.buildWebViewSettings(),
// initialUserScripts: UnmodifiableListView(_ghost.buildUserScripts()),
// onWebViewCreated: (controller) {
// _ghost.onWebViewCreated(controller);
// },
// onLoadStop: (controller, url) async {
// await _ghost.onPageLoaded(url?.uriValue);
// },
// shouldInterceptRequest: (controller, request) {
// return _ghost.shouldInterceptRequest(controller, request);
// },
// );
// }
// }
//
// ─────────────────────────────────────────────────────────────────────────────
// PUBSPEC ADDITIONS
// ─────────────────────────────────────────────────────────────────────────────
//
// dependencies:
// flutter_inappwebview: ^6.1.5 # already present
// shared_preferences: ^2.3.0
//
// ─────────────────────────────────────────────────────────────────────────────
// DEBUGGING: HOW TO VERIFY GHOST MODE WORKING
// ─────────────────────────────────────────────────────────────────────────────
//
// 1. Enable WebView remote debugging:
// In main.dart: if (kDebugMode) { InAppWebViewController.setWebContentsDebuggingEnabled(true); }
//
// 2. Open chrome://inspect in desktop Chrome while app runs on USB device.
//
// 3. In DevTools console, run:
// window.fetch('/api/v1/media/seen/test/', {method:'POST'})
// .then(r => r.text()).then(console.log)
// → Should print: {"status":"ok"} (blocked, not sent)
//
// 4. Check Network tab — blocked requests should NOT appear (they resolve locally).
//
// 5. For story view test: open a Story, check Network tab for any request to
// /media/seen/ or /viewed_story/ — should be absent.
+66
View File
@@ -0,0 +1,66 @@
# ── pubspec.yaml additions for FocusGram Phase 1 ──────────────────────────
#
# Merge these into your existing pubspec.yaml
#
dependencies:
flutter:
sdk: flutter
# WebView — already in project
flutter_inappwebview: ^6.1.5
# Persistence
shared_preferences: ^2.3.2
sqflite: ^2.3.3+1 # Phase 2 history DB — add now, use later
path_provider: ^2.1.4
# Network (Phase 2 download manager — add now)
dio: ^5.7.0
# Gallery save (Phase 2)
gal: ^2.3.0
# Permissions (Phase 2)
permission_handler: ^11.3.1
flutter:
assets:
- assets/scripts/ghost_mode.js
- assets/scripts/theme_detector.js
- assets/scripts/ad_blocker_dom.js
- assets/scripts/content_hider.js
- assets/scripts/media_detector.js # empty for now
- assets/scripts/history_tracker.js # empty for now
- assets/blocklists/easylist_mini.txt # Phase 1.5 — download and bundle
# ── AndroidManifest.xml additions ─────────────────────────────────────────
#
# In android/app/src/main/AndroidManifest.xml, inside <application>:
#
# <activity
# android:name=".MainActivity"
# android:windowSoftInputMode="adjustResize"
# android:hardwareAccelerated="true" ← ADD THIS
# android:exported="true">
#
# Also add permissions:
# <uses-permission android:name="android.permission.INTERNET"/>
# <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
# android:maxSdkVersion="28"/>
# <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
# <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
# ── android/app/src/main/res/values/styles.xml ────────────────────────────
#
# Add to your launch theme for true edge-to-edge:
#
# <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
# <item name="android:statusBarColor">@android:color/transparent</item>
# <item name="android:navigationBarColor">@android:color/transparent</item>
# <item name="android:windowTranslucentStatus">false</item>
# <item name="android:windowTranslucentNavigation">false</item>
# <item name="android:enforceNavigationBarContrast">false</item> ← Android 10+
+98
View File
@@ -0,0 +1,98 @@
/**
* FocusGram DOM Ad Blocker
* SHould have Removed sponsored posts, "Suggested for you" injections, and ad elements.
* Uses structure-based selectors NOT class names (those change weekly).
* Injected at DOCUMENT_END.
*/
(function () {
'use strict';
// ─── Sponsored text signals (Instagram localizes these) ───────────────────
// We match the STRUCTURE not just English text.
// In IG mobile web, sponsored label appears as a <span> or <div>
// that is a direct sibling/child of the article header area.
const SPONSORED_TEXTS = new Set([
'sponsored', // en
'gesponsert', // de
'patrocinado', // es/pt
'sponsorisé', // fr
'sponsorizzato', // it
'sponsrad', // sv
'sponsoreret', // da
'gesponsord', // nl
'рекламa', // ru
'विज्ञापन', // hi
'广告', // zh
'ad', // en short
]);
const isSponsoredText = (text) =>
SPONSORED_TEXTS.has(text.trim().toLowerCase());
// ─── Remove a single article element ──────────────────────────────────────
const removeArticle = (el) => {
// Walk up to find the article or main feed item container
const target = el.closest('article') ?? el.closest('div[data-media-id]') ?? el;
target.remove();
};
// ─── Core ad scanner ──────────────────────────────────────────────────────
const scanAndRemove = () => {
// Strategy 1: <a href="/ads/..."> inside feed
document.querySelectorAll('a[href*="/ads/"]').forEach((a) => {
a.closest('article')?.remove();
});
// Strategy 2: Sponsored text in article spans
document.querySelectorAll('article').forEach((article) => {
const spans = article.querySelectorAll('span, div');
for (const span of spans) {
if (
span.children.length === 0 && // leaf node
isSponsoredText(span.textContent)
) {
article.remove();
return;
}
}
});
// Strategy 3: "Suggested for you" feed injections
document.querySelectorAll('article, section').forEach((el) => {
const firstText = el.querySelector('span, div, h4')?.textContent?.trim();
if (
firstText &&
(firstText.toLowerCase().startsWith('suggested') ||
firstText.toLowerCase().startsWith('you might') ||
firstText.toLowerCase() === 'posts you might like')
) {
el.remove();
}
});
// Strategy 4: Instagram marks some ad containers with aria-label
document
.querySelectorAll('[aria-label*="Sponsored"], [aria-label*="Ad"]')
.forEach((el) => {
el.closest('article')?.remove();
});
// Strategy 5: Tracking pixel iframes / hidden images
document.querySelectorAll('iframe[width="0"], iframe[height="0"]').forEach((el) => el.remove());
document
.querySelectorAll('img[width="1"][height="1"], img[width="0"][height="0"]')
.forEach((el) => el.remove());
};
// ─── Run on load + watch for new content ──────────────────────────────────
scanAndRemove();
const observer = new MutationObserver((mutations) => {
// Only scan if nodes were added (skip attribute/text changes)
const hasAdditions = mutations.some((m) => m.addedNodes.length > 0);
if (hasAdditions) scanAndRemove();
});
const feed = document.querySelector('main') ?? document.body;
observer.observe(feed, { childList: true, subtree: true });
})();
+100
View File
@@ -0,0 +1,100 @@
/**
* FocusGram DOM Ad Blocker
* Removes sponsored posts, "Suggested for you" injections, and ad elements.
* Uses structure-based selectors NOT class names (those change weekly).
* Injected at DOCUMENT_END.
*/
(function () {
'use strict';
// ─── Sponsored text signals (Instagram localizes these) ───────────────────
// We match the STRUCTURE not just English text.
// In IG mobile web, sponsored label appears as a <span> or <div>
// that is a direct sibling/child of the article header area.
const SPONSORED_TEXTS = new Set([
'sponsored', // en
'gesponsert', // de
'patrocinado', // es/pt
'sponsorisé', // fr
'sponsorizzato', // it
'sponsrad', // sv
'sponsoreret', // da
'gesponsord', // nl
'рекламa', // ru
'विज्ञापन', // hi
'广告', // zh
'ad', // en short
]);
const isSponsoredText = (text) =>
SPONSORED_TEXTS.has(text.trim().toLowerCase());
// ─── Remove a single article element ──────────────────────────────────────
const removeArticle = (el) => {
// Walk up to find the article or main feed item container
const target = el.closest('article') ?? el.closest('div[data-media-id]') ?? el;
target.remove();
};
// ─── Core ad scanner ──────────────────────────────────────────────────────
const scanAndRemove = () => {
// Strategy 1: <a href="/ads/..."> inside feed
document.querySelectorAll('a[href*="/ads/"]').forEach((a) => {
a.closest('article')?.remove();
});
// Strategy 2: Sponsored text in article spans
document.querySelectorAll('article').forEach((article) => {
const spans = article.querySelectorAll('span, div');
for (const span of spans) {
if (
span.children.length === 0 && // leaf node
isSponsoredText(span.textContent)
) {
article.remove();
return;
}
}
});
// Strategy 3: "Suggested for you" feed injections
document.querySelectorAll('article, section').forEach((el) => {
const firstText = el.querySelector('span, div, h4')?.textContent?.trim();
if (
firstText &&
(firstText.toLowerCase().startsWith('suggested') ||
firstText.toLowerCase().startsWith('you might') ||
firstText.toLowerCase() === 'posts you might like')
) {
el.remove();
}
});
// Strategy 4: Instagram marks some ad containers with aria-label
document
.querySelectorAll('[aria-label*="Sponsored"], [aria-label*="Ad"]')
.forEach((el) => {
el.closest('article')?.remove();
});
// Strategy 5: Tracking pixel iframes / hidden images
document.querySelectorAll('iframe[width="0"], iframe[height="0"]').forEach((el) => el.remove());
document
.querySelectorAll('img[width="1"][height="1"], img[width="0"][height="0"]')
.forEach((el) => el.remove());
};
// ─── Run on load + watch for new content ──────────────────────────────────
scanAndRemove();
const observer = new MutationObserver((mutations) => {
// Only scan if nodes were added (skip attribute/text changes)
const hasAdditions = mutations.some((m) => m.addedNodes.length > 0);
if (hasAdditions) scanAndRemove();
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
})();
+83
View File
@@ -0,0 +1,83 @@
/**
* FocusGram Autoplay Blocker
* Injected at DOCUMENT_START before Instagram's JS loads.
* Prevents video autoplay by:
* 1. Blocking play() calls on video elements
* 2. Disabling autoplay attribute
* 3. Removing preload attributes
*/
(function () {
'use strict';
window.__fgBlockAutoplay = false;
// Override HTMLMediaElement.play() to check our flag
const _play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () {
if (window.__fgBlockAutoplay) {
// Return a resolved promise to avoid breaking Instagram's code
return Promise.resolve();
}
return _play.call(this);
};
// Override autoplay property setter
const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
const _originalAutoplaySetter = _videoDescriptor.set;
Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
set: function (value) {
if (window.__fgBlockAutoplay && value) {
// Silently ignore autoplay attempts when blocking is enabled
return;
}
if (_originalAutoplaySetter) {
_originalAutoplaySetter.call(this, value);
}
},
get: function () {
if (_videoDescriptor.get) {
return _videoDescriptor.get.call(this);
}
return this.getAttribute('autoplay') !== null;
},
enumerable: _videoDescriptor.enumerable,
configurable: true,
});
// On page load and SPA navigation, scan for video elements and remove autoplay
const removeAutoplayFromVideos = () => {
document.querySelectorAll('video, [role="video"]').forEach(el => {
if (window.__fgBlockAutoplay) {
el.autoplay = false;
el.removeAttribute('autoplay');
if (el.paused === false) {
el.pause();
}
}
});
};
// Run on load and when document changes
removeAutoplayFromVideos();
if (!window.__fgAutoplayObserver) {
let _timer = null;
window.__fgAutoplayObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(removeAutoplayFromVideos, 500);
});
window.__fgAutoplayObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// Allow Flutter to toggle
window.__fgSetBlockAutoplay = function (enabled) {
window.__fgBlockAutoplay = !!enabled;
if (enabled) {
removeAutoplayFromVideos();
}
};
})();
+304
View File
@@ -0,0 +1,304 @@
/**
* FocusGram Content Hider
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
* Flutter controls via window.__fgContent.*
* Injected at DOCUMENT_END.
*
* Key fixes applied:
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
* - Stories tray detection strengthened for fresh SPA navigations
* - Suggested posts detection uses multiple text-node matching strategies
*/
(function () {
'use strict';
if (window.__fgContent && window.__fgContent.__focusgramReady) {
return;
}
const STYLE_ID = 'fg-content-hider';
let hideStories = false;
let hidePosts = false;
let hideSuggested = false;
let hideReels = false;
// ─── CSS rules ─────────────────────────────────────────────────────────────
function buildCSS() {
const selectors = [];
if (hideStories) {
selectors.push(
'[role="list"]:has([aria-label*="tory"])',
'[role="listbox"]:has([aria-label*="tory"])',
'[role="menu"] > ul',
'section > div > div:first-child [style*="overflow"]',
'[role="list"] [style*="overflow"]',
);
}
if (hidePosts) {
selectors.push(
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
);
}
// hideReels CSS is intentionally NOT added here.
// We use DOM removal instead (see removeReels()) so that room is never left
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
return selectors.length
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
: '';
}
function applyCSS() {
if (document.body) {
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
}
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = buildCSS();
}
// ─── Story tray JS ─────────────────────────────────────────────────────────
function hideStoryTray() {
if (!hideStories) return;
// Strategy 1: <ul> children of a named list or menu
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
try {
const items = ul.querySelectorAll('li, button, a');
if (items.length < 2) return;
ul.style.setProperty('display', 'none', 'important');
} catch (_) {}
});
// Strategy 2: horizontally scrolling container with circle items
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
try {
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
if (cands.length < 2) return;
const s0 = window.getComputedStyle(cands[0]);
if (s0.width && parseFloat(s0.width) <= 90) {
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
}
} catch (_) {}
});
}
// ─── Suggested posts ───────────────────────────────────────────────────────
function removeSuggested() {
if (!hideSuggested) return;
var SIGNALS = [
'suggested for you',
'suggested posts',
'suggested reels',
'suggested',
'because you watched',
'because you follow',
'you might like',
'posts you might like',
'accounts you might like',
'recommendations',
];
function norm(s) {
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function hasSignal(s) {
var t = norm(s);
if (!t) return false;
return SIGNALS.some(function (signal) {
if (signal === 'suggested') return t === signal;
return t.indexOf(signal) >= 0;
});
}
function hideContainer(from) {
var parent = from;
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
var role = parent.getAttribute && parent.getAttribute('role');
var tag = parent.tagName;
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
if (
tag === 'ARTICLE' ||
tag === 'SECTION' ||
role === 'listitem' ||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
) {
parent.style.setProperty('display', 'none', 'important');
parent.setAttribute('data-fg-hidden-suggested', '1');
return true;
}
parent = parent.parentElement;
}
return false;
}
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
try {
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
var ownLabel = node.getAttribute('aria-label');
if (hasSignal(ownLabel)) { hideContainer(node); return; }
var text = norm(node.innerText || node.textContent || '');
if (
text.indexOf('suggested for you') >= 0 ||
text.indexOf('suggested posts') >= 0 ||
text.indexOf('suggested reels') >= 0 ||
text.indexOf('because you watched') >= 0 ||
text.indexOf('because you follow') >= 0
) {
hideContainer(node);
}
} catch (_) {}
});
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
try {
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
hideContainer(el);
}
} catch (_) {}
});
}
// ─── Reels DOM REMOVE (not display:none) ─────────────────────────────────
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
// gap cleanly and lets the feed flow naturally.
function removeReels() {
if (!hideReels) return;
var toRemove = [];
document.querySelectorAll('article').forEach(function (el) {
try {
// Fast path: check for a reel-signal attribute first
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
if (mt === '2') { toRemove.push(el); return; }
// Fallback: text-node scan for /reels/ markers
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
var n;
while ((n = walker.nextNode())) {
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
toRemove.push(el); break;
}
}
} catch (_) {}
});
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
}
// ─── Public API ────────────────────────────────────────────────────────────
window.__fgContent = {
__focusgramReady: true,
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
setHideSuggested: function (val) {
hideSuggested = !!val;
applyCSS();
if (val) removeSuggested();
},
setHideReels: function (val) {
hideReels = !!val;
applyCSS();
if (val) removeReels();
},
applyAll: function (flags) {
hideStories = !!flags.stories;
hidePosts = !!flags.posts;
hideReels = !!flags.reels;
hideSuggested = !!flags.suggested;
applyCSS();
if (hideSuggested) removeSuggested();
if (hideStories) hideStoryTray();
if (hideReels) removeReels();
},
};
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
// pushState/replaceState don't fire any DOM event we can listen for.
// Hook the methods themselves so we know a navigation happened, then debounce
// re-apply. This also catches the case where the MutationObserver was on `body`
// and that node got replaced by Instagram's SPA re-render.
function scheduleReapply() {
clearTimeout(window.__fg_applyTimer);
window.__fg_applyTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 250);
}
var _origPush = history.pushState;
var _origReplace = history.replaceState;
history.pushState = function () {
_origPush.apply(this, arguments);
scheduleReapply();
};
history.replaceState = function () {
_origReplace.apply(this, arguments);
scheduleReapply();
};
// Reinforce on popstate too (user hits back/forward)
window.addEventListener('popstate', scheduleReapply, { passive: true });
// For pushState on the same URL (rare but possible) poll path briefly
window.addEventListener('pageshow', scheduleReapply, { passive: true });
window.addEventListener('focus', scheduleReapply, { passive: true });
// ─── MutationObserver ───────────────────────────────────────────────────────
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
// re-applies everything on each cycle. Does NOT guard on a per-element timer
// that would never re-fire after the body is replaced by SPA re-render.
if (!window.__fgContentObserver) {
window.__fgContentObserver = new MutationObserver(function () {
clearTimeout(window.__fg_moTimer);
window.__fg_moTimer = setTimeout(function () {
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
}, 300);
});
// `document.documentElement` survives SPA navigations (body gets replaced
// but <html> stays). Observing it catches both subtree mutations and, via
// the SPA heartbeat above, re-applies after pushState.
window.__fgContentObserver.observe(document.documentElement, {
childList: true,
subtree: true,
});
}
// ─── Initial run ────────────────────────────────────────────────────────────
applyCSS();
if (hideStories) hideStoryTray();
if (hideSuggested) removeSuggested();
if (hideReels) removeReels();
// Signal ready — Flutter will call applyAll() with stored prefs
if (window.ContentChannel) {
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+281
View File
@@ -0,0 +1,281 @@
/**
* FocusGram Unified Feed Filter via Fetch Interception
* Injected at DOCUMENT_START before Instagram's JS loads.
*
* This script intercepts GraphQL fetch calls and filters feed content based on:
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
* - Sponsored posts (ad_action_link, ad_header_style)
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
* - Videos/Reels (is_video, media_type, clips_metadata)
* - Autoplay blocking (video autoplay prevention)
*/
(function () {
'use strict';
// Configuration flags (set by Flutter via prefs)
window.__fgFilterConfig = {
blockAds: false,
blockSponsored: false,
blockSuggested: false,
blockVideos: false,
blockAutoplay: false,
blockGraphQLQueryWhenFeedPosts: false,
};
// Helper: Check if a node is an ad
const isAdNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_ad ||
node.ad_action_link ||
node.ad_id ||
(node.product_type && node.product_type === 'ad') ||
(node.ad_header_style && node.ad_header_style !== 'none') ||
(node.__typename && node.__typename === 'GraphAdStory')
);
};
// Helper: Check if a node is sponsored
const isSponsoredNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
(node.ad_action_link && node.ad_action_link.href) ||
(node.ad_header_style && node.ad_header_style !== 'none')
);
};
// Helper: Check if a node is suggested content
const isSuggestedNode = (node) => {
if (!node || typeof node !== 'object') return false;
const typename = String(node.__typename || '');
const reason = JSON.stringify({
reason: node.suggested_reason,
social_context: node.social_context,
title: node.title,
header: node.header,
label: node.label,
}).toLowerCase();
return !!(
node.is_suggested ||
node.is_suggested_for_you ||
node.is_recommendation ||
node.suggested_users ||
node.suggested_media ||
node.suggested_content ||
node.recommendation_source ||
typename.includes('Suggested') ||
typename.includes('Recommendation') ||
reason.includes('suggested') ||
reason.includes('recommend')
);
};
// Helper: Check if a node is a video/reel
const isVideoNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_video ||
(node.media_type === 2) ||
node.clips_metadata ||
(node.__typename && (
node.__typename.includes('Clips') ||
node.__typename.includes('Video')
))
);
};
const isFeedMediaNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.pk ||
node.id ||
node.code ||
node.media_type ||
node.image_versions2 ||
node.video_versions ||
node.carousel_media ||
node.__typename?.includes('Media') ||
node.__typename?.includes('Timeline')
);
};
// Helper: Check for media in carousel
const hasVideoInCarousel = (node) => {
if (!node || typeof node !== 'object') return false;
if (node.media_type === 8) {
const edges = node.edge_sidecar_to_children?.edges || [];
return edges.some(edge => isVideoNode(edge.node));
}
return false;
};
// Main filter function for feed nodes
const shouldFilterNode = (node) => {
const config = window.__fgFilterConfig;
if (!node || typeof node !== 'object') return false;
if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
return true;
}
// Check ads
if (config.blockAds && isAdNode(node)) {
return true;
}
// Check sponsored (separate from ads)
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
return true;
}
// Check suggested content
if (config.blockSuggested && isSuggestedNode(node)) {
return true;
}
// Check videos/reels
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
return true;
}
return false;
};
// Recursively filter GraphQL response edges
const filterEdges = (edges, path = []) => {
if (!Array.isArray(edges)) return edges;
return edges.filter(edge => {
if (!edge || !edge.node) return true;
const node = edge.node;
// Keep the edge if it doesn't match any filter
if (!shouldFilterNode(node)) return true;
// Log filtered content for debugging
if (window.__fgDebugFilter) {
const type = node.__typename || 'Unknown';
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
}
return false;
});
};
// Recursively walk GraphQL response and filter edges
const walkAndFilter = (obj, visited = new Set()) => {
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
visited.add(obj);
// Handle arrays
if (Array.isArray(obj)) {
obj.forEach(item => walkAndFilter(item, visited));
return;
}
// Check for edges array (common GraphQL pattern)
if (obj.edges && Array.isArray(obj.edges)) {
obj.edges = filterEdges(obj.edges);
}
// Recurse into children
for (const key in obj) {
if (obj.hasOwnProperty(key) && key !== '__typename') {
const val = obj[key];
if (val && typeof val === 'object') {
walkAndFilter(val, visited);
}
}
}
};
// Override fetch
const _fetch = window.fetch.bind(window);
window.fetch = async function (input, init) {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Call original fetch
let response = await _fetch(input, init);
// Only intercept GraphQL feed queries
if (!url.includes('/graphql') && !url.includes('/api/v1/feed')) {
return response;
}
// Clone response to read body
const cloned = response.clone();
try {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return response;
}
const data = await cloned.json();
// Filter the response data
walkAndFilter(data);
// Return modified response
return new Response(JSON.stringify(data), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} catch (e) {
// On error, return original response
return response;
}
};
// Preserve native function appearance
Object.defineProperty(window, 'fetch', {
value: window.fetch,
writable: true,
configurable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
const _xhrOpen = XMLHttpRequest.prototype.open;
const _xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__fgUrl = typeof url === 'string' ? url : String(url || '');
return _xhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
if (
window.__fgFilterConfig.blockVideos &&
this.__fgUrl &&
(this.__fgUrl.includes('/api/v1/clips/') ||
this.__fgUrl.includes('/api/v1/discover/'))
) {
try { this.abort(); } catch (_) {}
return;
}
return _xhrSend.apply(this, arguments);
};
// Allow Flutter to update config flags
window.__fgSetFilterConfig = function (config) {
if (typeof config === 'object') {
Object.assign(window.__fgFilterConfig, config);
if (window.__fgDebugFilter) {
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
}
}
};
// Enable debug logging
window.__fgDebugFilter = false;
})();
+207
View File
@@ -0,0 +1,207 @@
/**
* FocusGram Ghost Mode
* Injected at DOCUMENT_START before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals.
*/
(function () {
'use strict';
// ─── Seen API patterns ────────────────────────────────────────────────────
const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//,
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
];
// ─── Activity patterns (like, comment) — intercepted for local history ────
const ACTIVITY_PATTERNS = [
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
/\/api\/v1\/web\/comments\/add\//,
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
];
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
const fakeOkResponse = () =>
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// ─── Fetch override ───────────────────────────────────────────────────────
const _fetch = window.fetch.bind(window);
const patchedFetch = async function (input, init) {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Block seen
if (isSeen(url)) {
if (window.GhostChannel) {
window.GhostChannel.postMessage(
JSON.stringify({ type: 'seen_blocked', url })
);
}
return fakeOkResponse();
}
// Intercept activity for local history
if (isActivity(url) && window.ActivityChannel) {
const body = init?.body;
const bodyText =
body instanceof URLSearchParams
? body.toString()
: typeof body === 'string'
? body
: '';
window.ActivityChannel.postMessage(
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
);
}
return _fetch(input, init);
};
// Disguise as native
Object.defineProperty(window, 'fetch', {
value: patchedFetch,
writable: true,
configurable: true,
enumerable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
window.fetch[Symbol.toStringTag] = 'fetch';
// ─── XMLHttpRequest override ──────────────────────────────────────────────
const _XHROpen = XMLHttpRequest.prototype.open;
const _XHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._fg_url = url ?? '';
this._fg_method = (method ?? '').toUpperCase();
return _XHROpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function (body) {
if (this._fg_url && isSeen(this._fg_url)) {
// Fire readyState 4 with fake success without actually sending
const self = this;
setTimeout(() => {
Object.defineProperty(self, 'readyState', { get: () => 4 });
Object.defineProperty(self, 'status', { get: () => 200 });
Object.defineProperty(self, 'responseText', {
get: () => '{"status":"ok"}',
});
Object.defineProperty(self, 'response', {
get: () => '{"status":"ok"}',
});
self.dispatchEvent(new Event('readystatechange'));
self.dispatchEvent(new Event('load'));
}, 10);
return;
}
return _XHRSend.call(this, body);
};
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
// Strict WS URL blocking (ghost mode requirement)
// sid/cid vary per user/chat; block by endpoint prefix, not exact query.
const isBlockedWssUrl = (u) => {
if (!u) return false;
const urlStr = String(u);
return (
urlStr.startsWith('wss://gateway.instagram.com/ws/streamcontroller') ||
urlStr.startsWith('wss://edge-chat.instagram.com/chat?sid=')
);
};
// Signal to other injected scripts that ghost-mode is active
window.__fgGhostModeActive = true;
const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) {
const urlStr = typeof url === 'string' ? url : url?.toString?.() ?? '';
// If the WebSocket URL is one of the blocked endpoints, return an inert WS-like object
if (isBlockedWssUrl(urlStr)) {
return {
send: () => {},
close: () => {},
readyState: 1,
addEventListener: () => {},
removeEventListener: () => {},
};
}
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
const _send = ws.send.bind(ws);
ws.send = function (data) {
if (typeof data === 'string') {
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
try {
const parsed = JSON.parse(data);
if (
parsed?.op === '4' ||
parsed?.op === 'seen' ||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
) {
return; // drop
}
} catch (_) {}
// Text-based seen signal check
if (data.includes('"seen"') && data.includes('"thread_id"')) {
return;
}
}
return _send(data);
};
return ws;
}
// Preserve WebSocket prototype chain so IG's ws checks pass
PatchedWebSocket.prototype = _WS.prototype;
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
PatchedWebSocket.OPEN = _WS.OPEN;
PatchedWebSocket.CLOSING = _WS.CLOSING;
PatchedWebSocket.CLOSED = _WS.CLOSED;
window.WebSocket = PatchedWebSocket;
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
// Only applied if user enables online-status hiding
// Wrapped in a named fn so Flutter can call it:
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
window.__fgEnableOnlineHide = function () {
Object.defineProperty(document, 'visibilityState', {
get: () => 'hidden',
configurable: true,
});
Object.defineProperty(document, 'hidden', {
get: () => true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
};
window.__fgDisableOnlineHide = function () {
// Restore by deleting the overrides (falls back to native getter)
delete document.visibilityState;
delete document.hidden;
document.dispatchEvent(new Event('visibilitychange'));
};
// Signal to Flutter that ghost mode JS is active
if (window.GhostChannel) {
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+89
View File
@@ -0,0 +1,89 @@
/**
* FocusGram Theme Detector
* Reads Instagram's background + bottom nav color and reports to Flutter.
* Injected at DOCUMENT_END so DOM is ready.
*/
(function () {
'use strict';
const parseRgb = (str) => {
// Parses "rgb(255, 255, 255)" or "rgba(0, 0, 0, 1)" → { r, g, b, a }
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return null;
return {
r: parseInt(m[1]),
g: parseInt(m[2]),
b: parseInt(m[3]),
a: m[4] !== undefined ? parseFloat(m[4]) : 1,
};
};
const toHex = ({ r, g, b }) =>
'#' +
[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
const detectColors = () => {
// Background — Instagram sets it on <body> or a root div
const bodyBg = getComputedStyle(document.body).backgroundColor;
// Bottom nav — IG mobile web renders a fixed bottom bar
// Target by role="navigation" or position:fixed at bottom
let navBg = bodyBg;
const navCandidates = document.querySelectorAll(
'nav, [role="navigation"], div[style*="bottom"]'
);
for (const el of navCandidates) {
const style = getComputedStyle(el);
if (
style.position === 'fixed' &&
parseInt(style.bottom) <= 10 &&
style.backgroundColor !== 'rgba(0, 0, 0, 0)'
) {
navBg = style.backgroundColor;
break;
}
}
const bodyColor = parseRgb(bodyBg);
const navColor = parseRgb(navBg);
if (!bodyColor) return;
// Determine dark/light
const luminance = (0.299 * bodyColor.r + 0.587 * bodyColor.g + 0.114 * bodyColor.b) / 255;
const isDark = luminance < 0.5;
const payload = {
bodyHex: toHex(bodyColor),
navHex: navColor ? toHex(navColor) : toHex(bodyColor),
isDark,
};
if (window.ThemeChannel) {
window.ThemeChannel.postMessage(JSON.stringify(payload));
}
};
// Run on load
detectColors();
// Watch for Instagram's dark mode toggle (adds/removes class on <html>)
const observer = new MutationObserver(detectColors);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style', 'color-scheme'],
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'style'],
});
// Also run after navigation (Instagram is SPA, URL changes without reload)
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(detectColors, 300); // small delay for IG to render new page
}
}).observe(document.body, { childList: true, subtree: true });
})();
+63
View File
@@ -0,0 +1,63 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../theme/system_ui_manager.dart';
typedef ActivityCallback = void Function(Map<String, dynamic> event);
class ChannelRegistry {
final ActivityCallback? onActivityEvent;
const ChannelRegistry({this.onActivityEvent});
// Build all JavaScript channels
Set<JavaScriptChannel> build() {
return {
_ghostChannel(),
_themeChannel(),
_contentChannel(),
_activityChannel(),
};
}
//
JavaScriptChannel _ghostChannel() => JavaScriptChannel(
name: 'GhostChannel',
onMessageReceived: (msg) {
try {
final data = jsonDecode(msg.message) as Map<String, dynamic>;
if (kDebugMode) {
debugPrint('[Ghost] ${data['type']}${data['url'] ?? ''}');
}
// In release: silent. Could surface to a debug overlay in dev builds.
} catch (_) {}
},
);
JavaScriptChannel _themeChannel() => JavaScriptChannel(
name: 'ThemeChannel',
onMessageReceived: (msg) {
SystemUiManager.applyFromThemePayload(msg.message);
},
);
JavaScriptChannel _contentChannel() => JavaScriptChannel(
name: 'ContentChannel',
onMessageReceived: (msg) {
// 'ready' signal engine pushes flags back via evaluateJavascript
// handled in ScriptEngine.injectDocumentEndScripts()
if (kDebugMode) debugPrint('[Content] ${msg.message}');
},
);
JavaScriptChannel _activityChannel() => JavaScriptChannel(
name: 'ActivityChannel',
onMessageReceived: (msg) {
try {
final data = jsonDecode(msg.message) as Map<String, dynamic>;
onActivityEvent?.call(data);
} catch (_) {}
},
);
}
+166
View File
@@ -0,0 +1,166 @@
/**
* FocusGram Content Hider
* Toggleable visibility for: stories tray, feed posts, suggested content.
* Flutter controls via window.__fgContent.*
* Injected at DOCUMENT_END.
*
* Improvements:
* - Better story tray detection using multiple strategies
* - Overlay for hidden feed content with loading indicator
* - Improved suggested posts detection
* - Fixed reels hiding to avoid blank feed issues
*/
(function () {
'use strict';
const STYLE_ID = 'fg-content-hider';
const OVERLAY_ID = 'fg-content-overlay';
let hideStories = false;
let hidePosts = false;
let hideSuggested = false;
let hideReels = false;
// ─── CSS rules ────────────────────────────────────────────────────────────
const buildCSS = () => {
let css = '';
if (hideStories) {
// Story tray: IG mobile web renders as a scrollable <ul> of circles
// near the top of the main feed. We target the outermost container
// by its scroll behaviour and presence of story-like items.
css += `
/* Story tray */
div[style*="overflow-x"] > ul,
div[role="menu"] > ul,
section > div > div:first-child ul[style*="scroll"] {
display: none !important;
}
`;
}
if (hidePosts) {
// Feed articles — but NOT DM threads or profile pages
// Only apply on /, /reels/ — not /direct/ or /p/ or /@username/
css += `
/* Feed posts */
main article {
display: none !important;
}
`;
}
if (hideReels) {
css += `
/* Reels in feed */
article:has(video) {
display: none !important;
}
`;
}
return css;
};
const applyCSS = () => {
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = buildCSS();
};
// ─── JS-based removal for suggested (CSS can't catch dynamic text) ────────
const removeSuggested = () => {
if (!hideSuggested) return;
document.querySelectorAll('article, section, div').forEach((el) => {
const firstLeaf = el.querySelector('span:not(:has(*)), h4');
if (!firstLeaf) return;
const t = firstLeaf.textContent.trim().toLowerCase();
if (
t === 'suggested for you' ||
t === 'you might like' ||
t === 'suggested posts' ||
t === 'posts you might like'
) {
(el.closest('article') ?? el).remove();
}
});
};
// ─── Story tray JS fallback (for when CSS selector misses) ───────────────
const hideStoryTrayJS = () => {
if (!hideStories) return;
document.querySelectorAll('ul').forEach((ul) => {
const items = ul.querySelectorAll('li');
if (items.length < 2) return;
// Story bubbles: li contains a button with a circular image
const first = items[0];
const hasCircleImg =
first.querySelector('canvas') ||
first.querySelector('img') ||
first.querySelector('button');
const isHorizontal = ul.scrollWidth > ul.clientWidth;
if (hasCircleImg && isHorizontal) {
ul.style.setProperty('display', 'none', 'important');
}
});
};
// ─── Public API — Flutter calls these via evaluateJavascript ─────────────
window.__fgContent = {
setHideStories: (val) => {
hideStories = !!val;
applyCSS();
hideStoryTrayJS();
},
setHidePosts: (val) => {
hidePosts = !!val;
applyCSS();
},
setHideReels: (val) => {
hideReels = !!val;
applyCSS();
},
setHideSuggested: (val) => {
hideSuggested = !!val;
if (val) removeSuggested();
},
applyAll: (flags) => {
hideStories = !!flags.stories;
hidePosts = !!flags.posts;
hideReels = !!flags.reels;
hideSuggested = !!flags.suggested;
applyCSS();
if (hideSuggested) removeSuggested();
if (hideStories) hideStoryTrayJS();
},
};
// ─── MutationObserver to re-apply on SPA navigation ──────────────────────
let lastUrl = location.href;
const mo = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(() => {
applyCSS();
if (hideSuggested) removeSuggested();
if (hideStories) hideStoryTrayJS();
}, 400);
}
if (hideSuggested) removeSuggested();
});
mo.observe(document.body, { childList: true, subtree: true });
// Signal ready — Flutter will call applyAll() with stored prefs
if (window.ContentChannel) {
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+233
View File
@@ -0,0 +1,233 @@
/**
* FocusGram Unified Feed Filter via Fetch Interception
* Injected at DOCUMENT_START before Instagram's JS loads.
*
* This script intercepts GraphQL fetch calls and filters feed content based on:
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
* - Sponsored posts (ad_action_link, ad_header_style)
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
* - Videos/Reels (is_video, media_type, clips_metadata)
* - Autoplay blocking (video autoplay prevention)
*/
(function () {
'use strict';
// Configuration flags (set by Flutter via prefs)
window.__fgFilterConfig = {
blockAds: false,
blockSponsored: false,
blockSuggested: false,
blockVideos: false,
blockAutoplay: false,
};
// Helper: Check if a node is an ad
const isAdNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_ad ||
node.ad_id ||
node.ad_action_link ||
node.ad_action_links?.length > 0 ||
node.is_paid_partnership ||
node.sponsor_tags?.length > 0 ||
(node.commerciality_status === 'ad') ||
(node.commerciality_status === 'shoppable_feed_ad') ||
(node.product_type === 'ad') ||
(node.ad_header_style && node.ad_header_style !== 'none') ||
node.__typename === 'GraphAdStory' ||
node.__typename === 'XDTAdFeedUnit' ||
(node.__typename?.toLowerCase().includes('ad'))
);
};
// Helper: Check if a node is sponsored
const isSponsoredNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
(node.ad_action_link && node.ad_action_link.href) ||
(node.ad_header_style && node.ad_header_style !== 'none')
);
};
// Helper: Check if a node is suggested content
const isSuggestedNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_suggested ||
node.is_suggested_for_you ||
(node.__typename && node.__typename.includes('Suggested'))
);
};
// Helper: Check if a node is a video/reel
const isVideoNode = (node) => {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_video ||
(node.media_type === 2) ||
node.clips_metadata ||
(node.__typename && (
node.__typename.includes('Clips') ||
node.__typename.includes('Video')
))
);
};
// Helper: Check for media in carousel
const hasVideoInCarousel = (node) => {
if (!node || typeof node !== 'object') return false;
if (node.media_type === 8) {
const edges = node.edge_sidecar_to_children?.edges || [];
return edges.some(edge => isVideoNode(edge.node));
}
return false;
};
// Main filter function for feed nodes
const shouldFilterNode = (node) => {
const config = window.__fgFilterConfig;
if (!node || typeof node !== 'object') return false;
// Check ads
if (config.blockAds && isAdNode(node)) {
return true;
}
// Check sponsored (separate from ads)
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
return true;
}
// Check suggested content
if (config.blockSuggested && isSuggestedNode(node)) {
return true;
}
// Check videos/reels
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
return true;
}
return false;
};
// Recursively filter GraphQL response edges
const filterEdges = (edges, path = []) => {
if (!Array.isArray(edges)) return edges;
return edges.filter(edge => {
if (!edge || !edge.node) return true;
const node = edge.node;
// Keep the edge if it doesn't match any filter
if (!shouldFilterNode(node)) return true;
// Log filtered content for debugging
if (window.__fgDebugFilter) {
const type = node.__typename || 'Unknown';
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
}
return false;
});
};
// Recursively walk GraphQL response and filter edges
const walkAndFilter = (obj, visited = new Set()) => {
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
visited.add(obj);
// Handle arrays
if (Array.isArray(obj)) {
obj.forEach(item => walkAndFilter(item, visited));
return;
}
// Check for edges array (common GraphQL pattern)
if (obj.edges && Array.isArray(obj.edges)) {
obj.edges = filterEdges(obj.edges);
}
// Recurse into children
for (const key in obj) {
if (obj.hasOwnProperty(key) && key !== '__typename') {
const val = obj[key];
if (val && typeof val === 'object') {
walkAndFilter(val, visited);
}
}
}
};
// Override fetch
const _fetch = window.fetch.bind(window);
window.fetch = async function (input, init) {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Call original fetch
let response = await _fetch(input, init);
// Only intercept GraphQL feed queries
if (!url.includes('/graphql') && !url.includes('/api/v1/feed')) {
return response;
}
// Clone response to read body
const cloned = response.clone();
try {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return response;
}
const data = await cloned.json();
// Filter the response data
walkAndFilter(data);
// Return modified response
return new Response(JSON.stringify(data), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
} catch (e) {
// On error, return original response
return response;
}
};
// Preserve native function appearance
Object.defineProperty(window, 'fetch', {
value: window.fetch,
writable: true,
configurable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
// Allow Flutter to update config flags
window.__fgSetFilterConfig = function (config) {
if (typeof config === 'object') {
Object.assign(window.__fgFilterConfig, config);
if (window.__fgDebugFilter) {
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
}
}
};
// Enable debug logging
window.__fgDebugFilter = false;
})();
+179
View File
@@ -0,0 +1,179 @@
/**
* FocusGram Ghost Mode
* Injected at DOCUMENT_START before Instagram's JS loads.
* Blocks story-seen, message-seen, and online-presence signals.
*/
(function () {
'use strict';
// ─── Seen API patterns ────────────────────────────────────────────────────
const SEEN_PATTERNS = [
/\/api\/v1\/media\/[\w-]+\/seen\//,
/\/api\/v1\/stories\/reel\/seen\//,
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
];
// ─── Activity patterns (like, comment) — intercepted for local history ────
const ACTIVITY_PATTERNS = [
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
/\/api\/v1\/web\/comments\/add\//,
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
];
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
const fakeOkResponse = () =>
new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
// ─── Fetch override ───────────────────────────────────────────────────────
const _fetch = window.fetch.bind(window);
const patchedFetch = async function (input, init) {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input?.url ?? '';
// Block seen
if (isSeen(url)) {
if (window.GhostChannel) {
window.GhostChannel.postMessage(
JSON.stringify({ type: 'seen_blocked', url })
);
}
return fakeOkResponse();
}
// Intercept activity for local history
if (isActivity(url) && window.ActivityChannel) {
const body = init?.body;
const bodyText =
body instanceof URLSearchParams
? body.toString()
: typeof body === 'string'
? body
: '';
window.ActivityChannel.postMessage(
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
);
}
return _fetch(input, init);
};
// Disguise as native
Object.defineProperty(window, 'fetch', {
value: patchedFetch,
writable: true,
configurable: true,
enumerable: true,
});
window.fetch.toString = () => 'function fetch() { [native code] }';
window.fetch[Symbol.toStringTag] = 'fetch';
// ─── XMLHttpRequest override ──────────────────────────────────────────────
const _XHROpen = XMLHttpRequest.prototype.open;
const _XHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._fg_url = url ?? '';
this._fg_method = (method ?? '').toUpperCase();
return _XHROpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function (body) {
if (this._fg_url && isSeen(this._fg_url)) {
// Fire readyState 4 with fake success without actually sending
const self = this;
setTimeout(() => {
Object.defineProperty(self, 'readyState', { get: () => 4 });
Object.defineProperty(self, 'status', { get: () => 200 });
Object.defineProperty(self, 'responseText', {
get: () => '{"status":"ok"}',
});
Object.defineProperty(self, 'response', {
get: () => '{"status":"ok"}',
});
self.dispatchEvent(new Event('readystatechange'));
self.dispatchEvent(new Event('load'));
}, 10);
return;
}
return _XHRSend.call(this, body);
};
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
const _WS = window.WebSocket;
function PatchedWebSocket(url, protocols) {
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
const _send = ws.send.bind(ws);
ws.send = function (data) {
if (typeof data === 'string') {
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
try {
const parsed = JSON.parse(data);
if (
parsed?.op === '4' ||
parsed?.op === 'seen' ||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
) {
return; // drop
}
} catch (_) {}
// Text-based seen signal check
if (data.includes('"seen"') && data.includes('"thread_id"')) {
return;
}
}
return _send(data);
};
return ws;
}
// Preserve WebSocket prototype chain so IG's ws checks pass
PatchedWebSocket.prototype = _WS.prototype;
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
PatchedWebSocket.OPEN = _WS.OPEN;
PatchedWebSocket.CLOSING = _WS.CLOSING;
PatchedWebSocket.CLOSED = _WS.CLOSED;
window.WebSocket = PatchedWebSocket;
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
// Only applied if user enables online-status hiding
// Wrapped in a named fn so Flutter can call it:
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
window.__fgEnableOnlineHide = function () {
Object.defineProperty(document, 'visibilityState', {
get: () => 'hidden',
configurable: true,
});
Object.defineProperty(document, 'hidden', {
get: () => true,
configurable: true,
});
document.dispatchEvent(new Event('visibilitychange'));
};
window.__fgDisableOnlineHide = function () {
// Restore by deleting the overrides (falls back to native getter)
delete document.visibilityState;
delete document.hidden;
document.dispatchEvent(new Event('visibilitychange'));
};
// Signal to Flutter that ghost mode JS is active
if (window.GhostChannel) {
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
}
})();
+283
View File
@@ -0,0 +1,283 @@
// lib/services/ghost_mode_script.dart
// Injected at AT_DOCUMENT_START before Instagram's JS caches fetch/XHR refs
const String kGhostModeJS = r"""
(function () {
'use strict';
// BLOCKED REST ENDPOINTS
// Patterns matched against full request URL
const URL_BLOCKLIST = [
// Story viewed receipts
/\/api\/v1\/media\/seen\//,
/\/api\/v1\/feed\/viewed_story\//,
/\/api\/v1\/feed\/reels_tray\/seen\//,
// DM read receipts (REST fallback path)
/\/api\/v1\/direct_v2\/threads\/[^/]+\/mark_item_seen\//,
/\/api\/v1\/direct_v2\/mark_item_seen\//,
// Ephemeral photo/video reply viewed (Anti-Reply Image)
/\/api\/v1\/direct_v2\/threads\/[^/]+\/items\/[^/]+\/mark_visual_item_seen\//,
/\/api\/v1\/direct_v2\/visual_thread\/[^/]+\/seen\//,
// Voice message listened receipt
/\/api\/v1\/direct_v2\/threads\/[^/]+\/items\/[^/]+\/mark_audio_seen\//,
// Live join broadcast notification
/\/api\/v1\/live\/[^/]+\/join\//,
/\/api\/v1\/live\/[^/]+\/get_join_requests\//,
/\/api\/v1\/live\/[^/]+\/start_broadcast\//,
// Analytics / tracking
/\/api\/v1\/qe\//,
/\/api\/v1\/launcher\/sync\//,
/\/api\/v1\/logging\//,
/\/api\/v1\/fb_onetap_logging\//,
/\/ajax\/bz/,
/\/ajax\/logging\//,
/\/api\/v1\/stats\//,
/\/api\/v1\/fbanalytics\//,
/\/api\/v1\/growth\/account_linked_now\//,
];
// BLOCKED GRAPHQL OPERATIONS
// Instagram web uses GraphQL for many actions match by operation name in body
const GRAPHQL_OP_BLOCKLIST = [
// Story seen
'MarkStorySeen',
'markStorySeen',
'ReelSeenMutation',
'reel_seen',
'IgFeedSeen',
// DM read receipts
'MarkDirectThreadItemSeen',
'markDirectThreadItemSeen',
'DirectMarkItemSeen',
'DirectThreadMarkSeen',
// Ephemeral media seen
'MarkVisualMessageSeen',
'DirectMarkVisualItemSeen',
// Voice message listened
'MarkAudioMessageSeen',
'AudioSeenMutation',
// Live join
'LiveJoinBroadcast',
'JoinLiveBroadcast',
'MarkLiveViewer',
// Analytics mutations
'LogImpression',
'LogClick',
'FeedbackSeenMutation',
];
// HELPERS
function shouldBlockUrl(url) {
if (!url) return false;
try {
const path = new URL(url, location.origin).pathname + new URL(url, location.origin).search;
return URL_BLOCKLIST.some(p => p.test(path));
} catch {
return URL_BLOCKLIST.some(p => p.test(url));
}
}
function shouldBlockGraphQL(body) {
if (!body) return false;
let str = '';
if (typeof body === 'string') {
str = body;
} else if (body instanceof URLSearchParams) {
str = body.toString();
}
return GRAPHQL_OP_BLOCKLIST.some(op => str.includes(op));
}
function isGraphQLEndpoint(url) {
return url.includes('/graphql') || url.includes('/api/graphql');
}
function fakeOk(body) {
return new Response(
JSON.stringify(body || { status: 'ok', result: 'success' }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
);
}
// FETCH INTERCEPT
const _fetch = window.fetch;
window.fetch = async function (input, init) {
const url =
typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
if (shouldBlockUrl(url)) {
return fakeOk();
}
// Clone body for GraphQL inspection without consuming it
if (isGraphQLEndpoint(url) && init) {
let bodyStr = '';
if (typeof init.body === 'string') {
bodyStr = init.body;
} else if (init.body instanceof URLSearchParams) {
bodyStr = init.body.toString();
} else if (init.body instanceof FormData) {
// FormData: iterate entries to build string
try {
init.body.forEach((v, k) => { bodyStr += k + '=' + v + '&'; });
} catch {}
}
if (shouldBlockGraphQL(bodyStr)) {
return fakeOk();
}
}
return _fetch.apply(this, arguments);
};
// XHR INTERCEPT
const _xhrOpen = XMLHttpRequest.prototype.open;
const _xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this.__ghostUrl = url;
return _xhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
const url = this.__ghostUrl || '';
const blockByUrl = shouldBlockUrl(url);
const blockByOp = isGraphQLEndpoint(url) && shouldBlockGraphQL(
typeof body === 'string' ? body : ''
);
if (blockByUrl || blockByOp) {
const self = this;
// Must use defineProperty because readyState etc are read-only
Object.defineProperty(self, 'readyState', { get: () => 4, configurable: true });
Object.defineProperty(self, 'status', { get: () => 200, configurable: true });
Object.defineProperty(self, 'responseText', {
get: () => '{"status":"ok"}',
configurable: true,
});
Object.defineProperty(self, 'response', {
get: () => '{"status":"ok"}',
configurable: true,
});
setTimeout(() => {
try { self.onreadystatechange && self.onreadystatechange(); } catch {}
try { self.onload && self.onload(); } catch {}
// Fire events
['readystatechange', 'load'].forEach(t => {
try { self.dispatchEvent(new Event(t)); } catch {}
});
}, 10);
return;
}
return _xhrSend.apply(this, arguments);
};
// WEBSOCKET INTERCEPT (typing + live join)
// Instagram uses MQTT over WebSocket for real-time events.
// Typing indicator = MQTT PUBLISH to topic containing typing/activity tokens.
// Live join viewer notification = MQTT PUBLISH with live topic.
const _OrigWS = window.WebSocket;
function GhostWebSocket(url, protocols) {
const ws = protocols ? new _OrigWS(url, protocols) : new _OrigWS(url);
const _wsSend = ws.send.bind(ws);
ws.send = function (data) {
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
// MQTT packet type in top 4 bits of byte 0
// PUBLISH = 0x3x (0x30 QoS0, 0x32 QoS1, 0x34 QoS2)
const packetType = bytes[0] & 0xF0;
if (packetType === 0x30) {
// Read remaining length (byte 1, simplified for short packets)
// MQTT topic starts at byte 4 (2 byte remaining-len + 2 byte topic-len)
try {
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
// Block typing / activity indicator publishes
if (
decoded.includes('/t_fs') || // foreground state (typing)
decoded.includes('activity_indicator') ||
decoded.includes('is_typing') ||
decoded.includes('direct_typing') ||
decoded.includes('/live/viewer') || // live join notification
decoded.includes('live_viewer_list')
) {
return; // Drop packet silently
}
} catch {}
}
} else if (typeof data === 'string') {
// Some WS implementations send JSON
if (
data.includes('typing') ||
data.includes('live_viewer') ||
data.includes('is_typing')
) {
return;
}
}
return _wsSend(data);
};
return ws;
}
// Preserve static properties
GhostWebSocket.prototype = _OrigWS.prototype;
Object.assign(GhostWebSocket, {
CONNECTING: _OrigWS.CONNECTING,
OPEN: _OrigWS.OPEN,
CLOSING: _OrigWS.CLOSING,
CLOSED: _OrigWS.CLOSED,
});
window.WebSocket = GhostWebSocket;
// KILL SERVICE WORKER
// SW runs in separate context bypasses all JS intercepts above.
// Kill registration so our fetch/XHR overrides are the only intercept layer.
if ('serviceWorker' in navigator) {
// Block new registrations
navigator.serviceWorker.register = function () {
return Promise.reject(new Error('[GhostMode] SW blocked'));
};
// Unregister any already registered
navigator.serviceWorker.getRegistrations().then(regs => {
regs.forEach(r => r.unregister());
}).catch(() => {});
}
// BEACON API BLOCK
// Instagram uses sendBeacon for analytics on page unload
if (navigator.sendBeacon) {
navigator.sendBeacon = function (url) {
if (shouldBlockUrl(url)) return true; // Lie say it succeeded
// Block all beacon calls to ig domains analytics only
if (url.includes('instagram.com') || url.includes('facebook.com')) return true;
return false;
};
}
console.log('[FocusGram] GhostMode active');
})();
""";
+233
View File
@@ -0,0 +1,233 @@
// lib/services/ghost_mode_service.dart
//
// Three-layer ghost mode:
// 1. AT_DOCUMENT_START JS injection overrides fetch/XHR/WS before IG code runs
// 2. shouldInterceptRequest native Android intercept (catches SW requests too)
// 3. FLAG_SECURE anti-screenshot at OS level (disabled per user request)
//
// Usage:
// final service = GhostModeService();
// await service.load(); // reads saved prefs
//
// InAppWebView(
// initialUserScripts: service.buildUserScripts(),
// onWebViewCreated: (c) => service.onWebViewCreated(c),
// shouldInterceptRequest: service.shouldInterceptRequest,
// )
//
// // Anti-screenshot: disabled per user request
// // service.applyWindowFlags(context);
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'ghost_mode_script.dart';
// Feature flags
class GhostFeatures {
bool hideStoryViews;
bool hideReadReceipts;
bool hideLiveJoin;
bool hideTypingIndicator;
bool hideVoiceListened;
bool hideReplyImageViewed;
bool disableAnalytics;
GhostFeatures({
this.hideStoryViews = true,
this.hideReadReceipts = true,
this.hideLiveJoin = true,
this.hideTypingIndicator = true,
this.hideVoiceListened = true,
this.hideReplyImageViewed = true,
this.disableAnalytics = true,
});
static const _keys = {
'hideStoryViews': 'gm_story',
'hideReadReceipts': 'gm_read',
'hideLiveJoin': 'gm_live',
'hideTypingIndicator': 'gm_typing',
'hideVoiceListened': 'gm_voice',
'hideReplyImageViewed': 'gm_reply',
'disableAnalytics': 'gm_analytics',
};
Future<void> save() async {
final p = await SharedPreferences.getInstance();
await Future.wait([
p.setBool(_keys['hideStoryViews']!, hideStoryViews),
p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed),
p.setBool(_keys['disableAnalytics']!, disableAnalytics),
]);
}
static Future<GhostFeatures> load() async {
final p = await SharedPreferences.getInstance();
return GhostFeatures(
hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true,
disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
);
}
}
// Native URL blocklist (mirrors JS side belt & suspenders)
final _nativeBlocklist = [
RegExp(r'/api/v1/media/seen/'),
RegExp(r'/api/v1/feed/viewed_story/'),
RegExp(r'/api/v1/feed/reels_tray/seen/'),
RegExp(r'/api/v1/direct_v2/threads/[^/]+/mark_item_seen/'),
RegExp(r'/api/v1/direct_v2/mark_item_seen/'),
RegExp(r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_visual_item_seen/'),
RegExp(r'/api/v1/direct_v2/visual_thread/[^/]+/seen/'),
RegExp(r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_audio_seen/'),
RegExp(r'/api/v1/live/[^/]+/join/'),
RegExp(r'/api/v1/live/[^/]+/get_join_requests/'),
RegExp(r'/api/v1/qe/'),
RegExp(r'/api/v1/launcher/sync/'),
RegExp(r'/api/v1/logging/'),
RegExp(r'/api/v1/stats/'),
RegExp(r'/api/v1/fb_onetap_logging/'),
RegExp(r'/ajax/bz'),
RegExp(r'/ajax/logging/'),
];
final Uint8List _fakeOkBody = Uint8List.fromList('{"status":"ok"}'.codeUnits);
// Main service
class GhostModeService {
GhostFeatures features = GhostFeatures();
InAppWebViewController? _controller;
Future<void> load() async {
features = await GhostFeatures.load();
}
// WebView setup
/// Call from InAppWebView.onWebViewCreated
void onWebViewCreated(InAppWebViewController controller) {
_controller = controller;
}
/// Pass to InAppWebView.initialUserScripts
/// AT_DOCUMENT_START = injected before ANY page script critical for
/// overriding fetch/XHR before Instagram caches original refs.
List<UserScript> buildUserScripts() {
if (!_anyGhostEnabled()) return [];
return [
UserScript(
source: _buildConfiguredScript(),
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: false, // Apply to iframes too
),
];
}
/// Pass to InAppWebView.shouldInterceptRequest
/// Works at native Android level catches requests from service workers too.
Future<WebResourceResponse?> shouldInterceptRequest(
InAppWebViewController controller,
WebResourceRequest request,
) async {
if (!_anyGhostEnabled()) return null;
final path = request.url.path;
if (_nativeBlocklist.any((re) => re.hasMatch(path))) {
return WebResourceResponse(
statusCode: 200,
reasonPhrase: 'OK',
contentType: 'application/json',
headers: {'Content-Type': 'application/json'},
data: _fakeOkBody,
);
}
return null; // Let through
}
/// InAppWebViewSettings required for shouldInterceptRequest to fire
InAppWebViewSettings buildWebViewSettings() {
return InAppWebViewSettings(
useShouldInterceptRequest: true, // Enable native intercept callback
useShouldOverrideUrlLoading: true,
javaScriptEnabled: true,
disableDefaultErrorPage: true,
useHybridComposition:
true, // Needed for FLAG_SECURE to work (though disabled)
// Disable service worker cache that can replay seen-events offline
cacheEnabled: false, // Start clean optional, tradeoff vs perf
);
}
// Anti-screenshot
// Anti-screenshot disabled per user request
Future<void> applyWindowFlags(BuildContext context) async {
// Anti-screenshot disabled per user request
return;
}
Future<void> clearWindowFlags() async {
// Anti-screenshot disabled per user request
return;
}
// Re-inject after page nav (SPA navigation doesn't re-run userScripts) ──
/// Call from InAppWebView.onLoadStop
Future<void> onPageLoaded(Uri? url) async {
if (_controller == null || !_anyGhostEnabled()) return;
// Re-inject on each navigation SPA route changes don't re-fire userScripts
await _controller!.evaluateJavascript(source: _buildConfiguredScript());
}
// Private helpers
bool _anyGhostEnabled() =>
features.hideStoryViews ||
features.hideReadReceipts ||
features.hideLiveJoin ||
features.hideTypingIndicator ||
features.hideVoiceListened ||
features.hideReplyImageViewed ||
features.disableAnalytics;
/// Build JS with feature flags baked in disabled features skip their blocks
String _buildConfiguredScript() {
// Prepend a config object that the script reads
// The kGhostModeJS already handles all features unconditionally.
// If you need per-feature toggles, swap the const for a builder function.
//
// For now: only inject if ghost mode is on at all.
// Per-feature granularity can be added by replacing URL_BLOCKLIST
// sections conditionally left as extension point.
return '''
window.__GHOST_CONFIG__ = ${_configJson()};
$kGhostModeJS
''';
}
String _configJson() {
return '''{
"hideStoryViews": ${features.hideStoryViews},
"hideReadReceipts": ${features.hideReadReceipts},
"hideLiveJoin": ${features.hideLiveJoin},
"hideTypingIndicator": ${features.hideTypingIndicator},
"hideVoiceListened": ${features.hideVoiceListened},
"hideReplyImageViewed": ${features.hideReplyImageViewed},
"disableAnalytics": ${features.disableAnalytics}
}''';
}
}
+201
View File
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../injection/script_engine.dart';
import '../injection/script_registry.dart';
import '../channels/channel_registry.dart';
import '../webview/webview_config.dart';
import '../services/ghost_mode_service.dart';
class InstagramWebView extends StatefulWidget {
const InstagramWebView({super.key});
@override
State<InstagramWebView> createState() => InstagramWebViewState();
}
class InstagramWebViewState extends State<InstagramWebView> {
InAppWebViewController? _controller;
ScriptEngine? _engine;
GhostModeService? _ghostMode;
bool _loading = true;
// Public API call from Settings screen
Future<void> toggleScript(ScriptId id, bool enabled) async {
await _engine?.toggle(id, enabled);
}
Future<void> setContentFlag(String flag, bool value) async {
await _engine?.setContentFlag(flag, value);
}
Future<void> setOnlineHide(bool enabled) async {
await _engine?.setOnlineHide(enabled);
}
// Ghost mode controls
Future<void> setGhostModeEnabled(bool enabled) async {
if (_ghostMode != null) {
_ghostMode!.features.disableAnalytics = enabled;
_ghostMode!.features.hideStoryViews = enabled;
_ghostMode!.features.hideReadReceipts = enabled;
_ghostMode!.features.hideLiveJoin = enabled;
_ghostMode!.features.hideTypingIndicator = enabled;
_ghostMode!.features.hideVoiceListened = enabled;
_ghostMode!.features.hideReplyImageViewed = enabled;
await _ghostMode!.features.save();
// Reapply settings if webview exists
if (_controller != null) {
// Force reload to apply new settings
await _controller!.reload();
}
}
}
Future<void> setAntiScreenshot(bool enabled) async {
if (_ghostMode != null) {
_ghostMode!.features.antiScreenshot = enabled;
await _ghostMode!.features.save();
if (_ghostMode!.features.antiScreenshot) {
await _ghostMode!.applyWindowFlags(context);
} else {
await _ghostMode!.clearWindowFlags();
}
}
}
//
@override
Widget build(BuildContext context) {
return Stack(
children: [
InAppWebView(
initialUrlRequest: WebViewConfig.initialRequest,
initialSettings:
_ghostMode?.buildWebViewSettings() ?? WebViewConfig.settings,
// ContentBlockers merged base + EasyList rules
contentBlockers: WebViewConfig.baseContentBlockers,
// User Scripts AT_DOCUMENT_START critical for ghost mode
initialUserScripts: UnmodifiableListView(
_ghostMode?.buildUserScripts() ?? [],
),
// JavaScript channels
javascriptChannels: ChannelRegistry(
onActivityEvent: (event) {
// Forward to history DB in Phase 2
debugPrint('[Activity] $event');
},
).build(),
onWebViewCreated: (controller) async {
_controller = controller;
//Interceptor for adblock
shouldInterceptRequest:
(controller, request) async {
final url = request.url.toString();
const adDomains = [
'an.facebook.com',
'connect.facebook.net',
'pixel.facebook.com',
'graph.facebook.com/logging',
'www.instagram.com/ajax/bz',
'www.instagram.com/api/v1/web/comet/logcalls',
'doubleclick.net',
'googletagmanager.com',
'scorecardresearch.com',
];
if (adDomains.any(url.contains)) {
return WebResourceResponse(
contentType: 'application/json',
httpStatus: WebResourceResponseHTTPStatus(statusCode: 200),
data: Uint8List.fromList(utf8.encode('{}')),
);
}
return null;
};
// Initialize GhostModeService
_ghostMode = GhostModeService();
await _ghostMode!.load();
// Initialize existing script engine for other scripts
final prefs = await SharedPreferences.getInstance();
_engine = ScriptEngine(controller: controller, prefs: prefs);
// Inject DOCUMENT_START scripts (ghost mode, etc.)
await _engine!.initDocumentStartScripts();
},
onLoadStop: (controller, url) async {
// Inject DOCUMENT_END scripts
await _engine?.injectDocumentEndScripts();
// Re-inject ghost mode scripts on SPA navigation
await _ghostMode?.onPageLoaded(url?.uriValue);
setState(() => _loading = false);
},
onLoadStart: (controller, url) {
setState(() => _loading = true);
},
onProgressChanged: (controller, progress) {
if (progress >= 80 && _loading) {
setState(() => _loading = false);
}
},
// Navigation policy
shouldOverrideUrlLoading: (controller, navigationAction) async {
final url = navigationAction.request.url?.toString() ?? '';
// Block external redirects keep user inside instagram.com
if (!url.contains('instagram.com') &&
!url.contains('cdninstagram.com') &&
!url.contains('fbcdn.net') &&
url.startsWith('http')) {
// TODO: open in external browser via url_launcher
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
// Re-inject on SPA navigation
// Instagram is a SPA URL changes via pushState don't trigger
// onLoadStop. Re-inject DOM scripts on URL change.
onUpdateVisitedHistory: (controller, url, isReload) async {
if (!isReload!) {
await _engine?.injectDocumentEndScripts();
}
},
// Native intercept for service worker requests
shouldInterceptRequest: (controller, request) async {
return await _ghostMode?.shouldInterceptRequest(
controller,
request,
) ??
null;
},
),
// Subtle loading indicator
if (_loading)
const LinearProgressIndicator(
minHeight: 2,
backgroundColor: Colors.transparent,
),
],
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'core/theme/system_ui_manager.dart';
import 'core/webview/instagram_webview.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Enable web contents debugging for ghost mode verification
if (kDebugMode) {
InAppWebViewController.setWebContentsDebuggingEnabled(true);
}
await SystemUiManager.enableEdgeToEdge();
runApp(const FocusGramApp());
}
class FocusGramApp extends StatelessWidget {
const FocusGramApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'FocusGram',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0095F6)),
useMaterial3: true,
),
home: const MainScreen(),
);
}
}
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
final _webViewKey = GlobalKey<InstagramWebViewState>();
@override
Widget build(BuildContext context) {
return Scaffold(
// backgroundColor transparent lets WebView color bleed to system bars
backgroundColor: Colors.black,
body: SafeArea(
// bottom: false let WebView extend behind nav bar for true edge-to-edge
bottom: false,
child: InstagramWebView(key: _webViewKey),
),
);
}
}
+230
View File
@@ -0,0 +1,230 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'script_registry.dart';
class ScriptEngine {
final InAppWebViewController controller;
final SharedPreferences prefs;
// Cache raw JS per asset path to avoid repeated rootBundle reads
final Map<String, String> _cache = {};
ScriptEngine({required this.controller, required this.prefs});
// Init: restore enabled state from prefs, inject DOCUMENT_START scripts
// Call this from onWebViewCreated (for DOCUMENT_START scripts via addUserScript)
Future<void> initDocumentStartScripts() async {
for (final script in ScriptRegistry.all) {
// Restore enabled state
final saved = prefs.getBool('script_${script.id.name}');
if (saved != null) script.enabled = saved;
if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START &&
script.enabled) {
final code = await _load(script.assetPath);
if (code == null) continue;
await controller.addUserScript(
UserScript(
source: code,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
allowedOriginRules: {'https://www.instagram.com'},
),
);
}
}
// Initialize script configurations after scripts are loaded
await _initializeScriptConfigs();
}
// Initialize script configurations from saved preferences
Future<void> _initializeScriptConfigs() async {
// Fetch interceptor config
final fetchInterceptor = ScriptRegistry.byId(ScriptId.fetchInterceptor);
if (fetchInterceptor.enabled) {
await _updateFetchInterceptorConfig();
}
// Autoplay blocker config
final autoplayBlocker = ScriptRegistry.byId(ScriptId.autoplayBlocker);
if (autoplayBlocker.enabled) {
await _updateAutoplayBlockerConfig();
}
// Content hider flags
await _pushContentFlags();
}
// Called from onLoadStop: inject all DOCUMENT_END enabled scripts
Future<void> injectDocumentEndScripts() async {
for (final script in ScriptRegistry.all.where(
(s) =>
s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END &&
s.enabled,
)) {
await _inject(script);
}
// After content_hider is injected, push saved content flags
await _pushContentFlags();
}
// Toggle a script on/off
Future<void> toggle(ScriptId id, bool enabled) async {
final script = ScriptRegistry.byId(id);
script.enabled = enabled;
await prefs.setBool('script_${id.name}', enabled);
if (!enabled) {
if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
await controller.removeUserScriptsByGroupName(id.name);
}
// For DOM scripts: reload so mutations stop
await controller.reload();
return;
}
if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
final code = await _load(script.assetPath);
if (code == null) return;
await controller.addUserScript(
UserScript(
source: code,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
groupName: id.name,
allowedOriginRules: {'https://www.instagram.com'},
),
);
await controller.reload();
} else {
await _inject(script);
}
// Re-initialize configurations after toggle
await _initializeScriptConfigs();
}
// Content hider flags
Future<void> setContentFlag(String flag, bool value) async {
await prefs.setBool('content_$flag', value);
await _pushContentFlags();
}
Future<void> _pushContentFlags() async {
final contentHider = ScriptRegistry.byId(ScriptId.contentHider);
if (!contentHider.enabled) return;
final flags = {
'stories': prefs.getBool('content_stories') ?? false,
'posts': prefs.getBool('content_posts') ?? false,
'reels': prefs.getBool('content_reels') ?? false,
'suggested': prefs.getBool('content_suggested') ?? false,
};
await controller.evaluateJavascript(
source: 'window.__fgContent?.applyAll(${jsonEncode(flags)})',
);
}
// Fetch interceptor configuration
Future<void> setFetchInterceptorConfig({
bool? blockAds,
bool? blockSponsored,
bool? blockSuggested,
bool? blockVideos,
bool? blockAutoplay,
}) async {
final prefs = await SharedPreferences.getInstance();
final config = {
'blockAds': blockAds ?? prefs.getBool('fetch_block_ads') ?? false,
'blockSponsored':
blockSponsored ?? prefs.getBool('fetch_block_sponsored') ?? false,
'blockSuggested':
blockSuggested ?? prefs.getBool('fetch_block_suggested') ?? false,
'blockVideos':
blockVideos ?? prefs.getBool('fetch_block_videos') ?? false,
'blockAutoplay':
blockAutoplay ?? prefs.getBool('fetch_block_autoplay') ?? false,
};
// Save individual prefs
await prefs.setBool('fetch_block_ads', config['blockAds']!);
await prefs.setBool('fetch_block_sponsored', config['blockSponsored']!);
await prefs.setBool('fetch_block_suggested', config['blockSuggested']!);
await prefs.setBool('fetch_block_videos', config['blockVideos']!);
await prefs.setBool('fetch_block_autoplay', config['blockAutoplay']!);
// Apply to webview
await controller.evaluateJavascript(
source: 'window.__fgSetFilterConfig?.(${jsonEncode(config)})',
);
}
Future<void> _updateFetchInterceptorConfig() async {
final prefs = await SharedPreferences.getInstance();
await setFetchInterceptorConfig(
blockAds: prefs.getBool('fetch_block_ads'),
blockSponsored: prefs.getBool('fetch_block_sponsored'),
blockSuggested: prefs.getBool('fetch_block_suggested'),
blockVideos: prefs.getBool('fetch_block_videos'),
blockAutoplay: prefs.getBool('fetch_block_autoplay'),
);
}
// Autoplay blocker configuration
Future<void> setAutoplayBlockerEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('autoplay_blocker_enabled', enabled);
// Apply to webview
await controller.evaluateJavascript(
source: 'window.__fgSetBlockAutoplay?.(${jsonEncode(enabled)})',
);
}
Future<void> _updateAutoplayBlockerConfig() async {
final prefs = await SharedPreferences.getInstance();
await setAutoplayBlockerEnabled(
prefs.getBool('autoplay_blocker_enabled') ?? false,
);
}
// Online status hide
Future<void> setOnlineHide(bool enabled) async {
await prefs.setBool('ghost_online_hide', enabled);
if (enabled) {
await controller.evaluateJavascript(
source: 'window.__fgEnableOnlineHide?.()',
);
} else {
await controller.evaluateJavascript(
source: 'window.__fgDisableOnlineHide?.()',
);
}
}
// Private helpers
Future<void> _inject(InstaScript script) async {
final code = await _load(script.assetPath);
if (code == null) return;
try {
await controller.evaluateJavascript(source: code);
} catch (e) {
// Script failed log but don't crash
debugPrint('[ScriptEngine] Failed to inject ${script.id.name}: $e');
}
}
Future<String?> _load(String assetPath) async {
if (_cache.containsKey(assetPath)) return _cache[assetPath];
try {
final code = await rootBundle.loadString(assetPath);
_cache[assetPath] = code;
return code;
} catch (e) {
debugPrint('[ScriptEngine] Asset not found: $assetPath');
return null;
}
}
}
+87
View File
@@ -0,0 +1,87 @@
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
enum ScriptId {
ghostMode,
themeDetector,
contentHider,
fetchInterceptor,
autoplayBlocker,
mediaDetector,
historyTracker,
}
class InstaScript {
final ScriptId id;
final String name;
final String description;
final String assetPath;
final UserScriptInjectionTime injectionTime;
bool enabled;
InstaScript({
required this.id,
required this.name,
required this.description,
required this.assetPath,
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
this.enabled = false,
});
}
class ScriptRegistry {
static final List<InstaScript> all = [
// DOCUMENT_START must be before IG's JS loads ──
InstaScript(
id: ScriptId.fetchInterceptor,
name: 'Ad & Content Blocker',
description:
'Blocks ads, sponsored, suggested content, videos, and prevents autoplay via GraphQL interception.',
assetPath: 'assets/scripts/fetch_interceptor.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
InstaScript(
id: ScriptId.autoplayBlocker,
name: 'Autoplay Blocker',
description: 'Prevents video autoplay.',
assetPath: 'assets/scripts/autoplay_blocker.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
enabled: false,
),
// DOCUMENT_END DOM must be ready
InstaScript(
id: ScriptId.themeDetector,
name: 'Theme Detector',
description: 'Reads page colors and syncs system UI bars.',
assetPath: 'assets/scripts/theme_detector.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: true, // always on needed for native feel
),
InstaScript(
id: ScriptId.contentHider,
name: 'Content Hider',
description: 'Toggleable hide for stories, posts, reels, suggested.',
assetPath: 'assets/scripts/content_hider.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: false,
),
// Phase 2 scripts registered but empty asset paths for now
InstaScript(
id: ScriptId.mediaDetector,
name: 'Media Downloader',
description: 'Injects download buttons on photos and reels.',
assetPath: 'assets/scripts/media_detector.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: false,
),
InstaScript(
id: ScriptId.historyTracker,
name: 'History Tracker',
description: 'Locally tracks reels watched and actions taken.',
assetPath: 'assets/scripts/history_tracker.js',
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
enabled: false,
),
];
}

Some files were not shown because too many files have changed in this diff Show More