mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-07-04 10:17:52 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e5869aada | |||
| 0e97678536 | |||
| 3e1e4a6e3a | |||
| 7992d65bc8 | |||
| eecb823e62 | |||
| 3227ca1414 | |||
| 4a1ff5a9fe | |||
| 6db4e0fe92 | |||
| 7622106695 | |||
| 97e8d12f86 | |||
| 3ff20e329a | |||
| 24f43603ad | |||
| f87597276c | |||
| b5ef642683 | |||
| f497730015 | |||
| 4e6c3f122a | |||
| e751e14a6b | |||
| 5b8d59e98b | |||
| 719badb2f5 | |||
| c0354ae7aa | |||
| 3769ff2c38 | |||
| d4be176f73 | |||
| 5232b8b0a9 | |||
| 878e625f0e | |||
| e23731d9e8 | |||
| fe2d793b93 | |||
| 354f7413d1 | |||
| 9ab4fc503a | |||
| a848b9222d |
+45
@@ -0,0 +1,45 @@
|
|||||||
|
Categories:
|
||||||
|
- Connectivity
|
||||||
|
- Social Network
|
||||||
|
License: AGPL-3.0-only
|
||||||
|
AuthorName: Ujwal Chapagain
|
||||||
|
AuthorEmail: notujwal@proton.me
|
||||||
|
SourceCode: https://github.com/Ujwal223/FocusGram
|
||||||
|
IssueTracker: https://github.com/Ujwal223/FocusGram/issues
|
||||||
|
Changelog: https://github.com/Ujwal223/FocusGram/blob/main/CHANGELOG.md
|
||||||
|
|
||||||
|
AutoName: FocusGram
|
||||||
|
|
||||||
|
RepoType: git
|
||||||
|
Repo: https://github.com/Ujwal223/FocusGram
|
||||||
|
|
||||||
|
Builds:
|
||||||
|
- versionName: 1.0.0
|
||||||
|
versionCode: 3
|
||||||
|
commit: v1.0.0
|
||||||
|
output: build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
srclibs:
|
||||||
|
- flutter@stable
|
||||||
|
prebuild:
|
||||||
|
- flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release.yml)
|
||||||
|
- '[[ $flutterVersion ]]'
|
||||||
|
- git -C $$flutter$$ checkout -f $flutterVersion
|
||||||
|
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||||
|
- .flutter/bin/flutter config --no-analytics
|
||||||
|
- .flutter/bin/flutter pub get
|
||||||
|
scanignore:
|
||||||
|
- .flutter/bin/cache
|
||||||
|
scandelete:
|
||||||
|
- .flutter
|
||||||
|
- .pub-cache
|
||||||
|
build:
|
||||||
|
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||||
|
- .flutter/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"
|
||||||
|
|
||||||
|
AutoUpdateMode: Version
|
||||||
|
UpdateCheckMode: Tags
|
||||||
|
VercodeOperation:
|
||||||
|
- '%c * 10 + 1'
|
||||||
|
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
|
||||||
|
CurrentVersion: 1.0.0
|
||||||
|
CurrentVersionCode: 3
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import os, re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
version = os.environ["VERSION"]
|
|
||||||
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
|
||||||
pattern = rf"(?ms)^##\s+FocusGram\s+{re.escape(version)}\s*$.*?(?=^##\s+|\Z)"
|
|
||||||
m = re.search(pattern, text)
|
|
||||||
if not m:
|
|
||||||
raise SystemExit(f"Could not find changelog section for version {version}")
|
|
||||||
Path("release_notes.md").write_text(m.group(0).strip() + "\n", encoding="utf-8")
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
|
|
||||||
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
|
||||||
m = re.search(r"^##\s+FocusGram\s+([0-9]+\.[0-9]+\.[0-9]+)\s*$", text, re.M)
|
|
||||||
if not m:
|
|
||||||
raise SystemExit("Could not find a top changelog heading like: ## FocusGram 2.0.0")
|
|
||||||
print(m.group(1))
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
schedule:
|
||||||
|
- cron: '15 14 * * 5'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (${{ matrix.language }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write
|
||||||
|
packages: read
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: java-kotlin
|
||||||
|
build-mode: manual
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.38.7'
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
|
||||||
|
- name: Build Android (for CodeQL)
|
||||||
|
run: flutter build apk --debug
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
|
with:
|
||||||
|
category: "/language:${{ matrix.language }}"
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
name: Build APK and Create GitHub Release
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Release version without leading v. Example: 1.0.0. Leave empty to read the top CHANGELOG heading."
|
|
||||||
required: false
|
|
||||||
type: string
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Set up Java 17
|
|
||||||
uses: actions/setup-java@v5
|
|
||||||
with:
|
|
||||||
distribution: temurin
|
|
||||||
java-version: "17"
|
|
||||||
- name: Set up Android SDK
|
|
||||||
uses: android-actions/setup-android@v4
|
|
||||||
- name: Set up Flutter
|
|
||||||
uses: subosito/flutter-action@v2
|
|
||||||
with:
|
|
||||||
channel: stable
|
|
||||||
cache: true
|
|
||||||
- name: Install required Android SDK packages
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
sdkmanager \
|
|
||||||
"platform-tools" \
|
|
||||||
"platforms;android-35" \
|
|
||||||
"build-tools;34.0.0" \
|
|
||||||
"build-tools;35.0.0" \
|
|
||||||
- name: Get dependencies
|
|
||||||
run: flutter pub get
|
|
||||||
- name: Resolve version and tag
|
|
||||||
id: meta
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
INPUT_VERSION="${{ github.event.inputs.version }}"
|
|
||||||
if [[ -n "${INPUT_VERSION}" ]]; then
|
|
||||||
VERSION="${INPUT_VERSION#v}"
|
|
||||||
else
|
|
||||||
VERSION="$(python3 .github/scripts/get_version.py)"
|
|
||||||
fi
|
|
||||||
TAG="v${VERSION}"
|
|
||||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
||||||
- name: Extract release notes from CHANGELOG.md
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
VERSION: ${{ steps.meta.outputs.version }}
|
|
||||||
run: python3 .github/scripts/get_notes.py
|
|
||||||
- name: Build release APK
|
|
||||||
run: flutter build apk --release
|
|
||||||
- name: Rename APK
|
|
||||||
run: mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/focusgram-release.apk
|
|
||||||
- name: Upload APK artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: focusgram-apk-${{ steps.meta.outputs.tag }}
|
|
||||||
path: build/app/outputs/flutter-apk/focusgram-release.apk
|
|
||||||
if-no-files-found: error
|
|
||||||
- name: Create Git tag
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
TAG: ${{ steps.meta.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then
|
|
||||||
echo "Tag already exists on remote: ${TAG}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
git tag -a "${TAG}" -m "Release ${TAG}"
|
|
||||||
git push origin "${TAG}"
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v3
|
|
||||||
with:
|
|
||||||
tag_name: ${{ steps.meta.outputs.tag }}
|
|
||||||
name: FocusGram ${{ steps.meta.outputs.tag }}
|
|
||||||
body_path: release_notes.md
|
|
||||||
files: build/app/outputs/flutter-apk/focusgram-release.apk
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.38.7'
|
||||||
+2
-4
@@ -14,9 +14,6 @@ migrate_working_dir/
|
|||||||
PRD.md
|
PRD.md
|
||||||
.agents/
|
.agents/
|
||||||
TODO.md
|
TODO.md
|
||||||
v2/FOCUSGRAM_V2_PLAN.md
|
|
||||||
v2/FocusGram_Feed_Filtering_Reference.docx
|
|
||||||
|
|
||||||
|
|
||||||
# IntelliJ related
|
# IntelliJ related
|
||||||
*.iml
|
*.iml
|
||||||
@@ -28,9 +25,10 @@ v2/FocusGram_Feed_Filtering_Reference.docx
|
|||||||
# VS Code which you may wish to be included in version control, so this line
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
# is commented out by default.
|
# is commented out by default.
|
||||||
#.vscode/
|
#.vscode/
|
||||||
|
RELEASE_GUIDE.md
|
||||||
android/key.properties
|
android/key.properties
|
||||||
|
android/fdroid-config.properties
|
||||||
android/app/*.jks
|
android/app/*.jks
|
||||||
upload-keystore.jks
|
|
||||||
|
|
||||||
# Flutter/Dart/Pub related
|
# Flutter/Dart/Pub related
|
||||||
**/doc/api/
|
**/doc/api/
|
||||||
|
|||||||
+14
-18
@@ -1,23 +1,19 @@
|
|||||||
## FocusGram 2.0.0
|
## FocusGram 1.0.0
|
||||||
|
|
||||||
|
First stable release.
|
||||||
|
|
||||||
### What's new
|
### What's new
|
||||||
|
- Minimal Mode — Feed and DMs only, everything else gone
|
||||||
- NEW: Added Media Downloader for downloading images and videos
|
- Disable Reels / Disable Explore toggles
|
||||||
- NEW: Added Ghost Mode
|
- Autoplay blocker
|
||||||
- NEW: Added a toggle for scroll lock in minimal mode
|
- Screen Time Dashboard with 7-day chart
|
||||||
- NEW: Added Option to Choose Duration of Mindfulness Gate
|
- Grayscale Mode with optional daily schedule
|
||||||
- NEW: Added ability to customize number of words in typing challenge
|
- Removed the Browser Like Feel
|
||||||
- UPDATED: Redesigned Focus Control Flyout
|
- Moved from webview_flutter to flutter_inappwebview
|
||||||
- UPDATED: Settings and Reordered items
|
- Changed UA
|
||||||
- UPDATED: Added more time Choices for reels session
|
|
||||||
- UPDATED: Improved Permission Request invocation in onboarding page.
|
|
||||||
- UPDATED: Improved Notification Alerts
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
- Fixed: back button on homepage didnt exit the app.
|
- Message input bar no longer hidden behind keyboard in DMs
|
||||||
- Fixed: Only First image of multiple imaged posts was blurred.
|
- Fixed a bug where sending message was not possible.
|
||||||
- FIxed: Couldn't scroll the home feed after enabling minimal mode
|
- Reels scrolling is now smooth
|
||||||
- Perfomance Optimizations
|
- Perfomance Optimizations
|
||||||
- A lof of other Minor fixes .
|
|
||||||
@@ -7,14 +7,11 @@
|
|||||||
**Use social media on your terms.**
|
**Use social media on your terms.**
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://flutter.dev)
|
[](https://flutter.dev)
|
||||||
[](https://github.com/ujwal223/focusgram/releases)
|
[](https://github.com/ujwal223/focusgram/releases)
|
||||||
|
[](https://f-droid.org)
|
||||||
|
|
||||||
<a href='https://focusgram.en.uptodown.com/android' title='Download FocusGram'>
|
[Download APK](https://github.com/ujwal223/focusgram/releases) · [View Changelog](CHANGELOG.md) · [Report a Bug](https://github.com/ujwal223/focusgram/issues)
|
||||||
<img src='https://stc.utdstc.com/img/mediakit/download-gio-small.png' alt='Download FocusGram on Uptodown'>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
[Download APK](https://github.com/ujwal223/focusgram/releases) · [View Changelog](CHANGELOG.md) · [Report a Bug](https://github.com/ujwal223/focusgram/issues/new)
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -24,12 +21,9 @@ Most people don't want to quit Instagram. They want to check their messages, pos
|
|||||||
|
|
||||||
FocusGram is an Android app that loads the Instagram website with the distracting parts removed. No private APIs. No data collection. No accounts. Just a cleaner way to use a platform you already use.
|
FocusGram is an Android app that loads the Instagram website with the distracting parts removed. No private APIs. No data collection. No accounts. Just a cleaner way to use a platform you already use.
|
||||||
|
|
||||||
> FocusGram is free and always will be. If it's saved you some time, show your support by buying me a momo 👉👈.
|
|
||||||
>
|
|
||||||
> [](https://buymemomo.com/ujwal)
|
|
||||||
|
|
||||||
<img width="1920" height="1080" alt="FocusGram App Screenshots" src="https://github.com/user-attachments/assets/cffd4012-4cf3-4ba8-aa1a-883e1f85478e" />
|
<img width="1920" height="1080" alt="FocusGram App Screenshots" src="https://github.com/user-attachments/assets/cffd4012-4cf3-4ba8-aa1a-883e1f85478e" />
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
@@ -37,14 +31,14 @@ FocusGram is an Android app that loads the Instagram website with the distractin
|
|||||||
**Focus tools**
|
**Focus tools**
|
||||||
|
|
||||||
- Block Reels entirely, or allow them in timed sessions (1–15 min) with daily limits and cooldowns
|
- Block Reels entirely, or allow them in timed sessions (1–15 min) with daily limits and cooldowns
|
||||||
- Autoplay blocker — videos won't play until you tap them
|
- Autoplay blocker — videos don't play until you tap them
|
||||||
- Minimal Mode — strips everything down to Feed and DMs
|
- Minimal Mode — strips everything down to Feed and DMs
|
||||||
|
|
||||||
**Content filtering**
|
**Content filtering**
|
||||||
|
|
||||||
- Hide the Explore tab, Reels tab, or Shop tab individually
|
- Hide the Explore tab, Reels tab, or Shop tab individually
|
||||||
- Disable Explore and blur posts entirely
|
- Disable Explore and suggested content entirely
|
||||||
- Disable Reels entirely
|
- Disable Reels Entirely
|
||||||
|
|
||||||
**Habit tools**
|
**Habit tools**
|
||||||
|
|
||||||
@@ -54,7 +48,7 @@ FocusGram is an Android app that loads the Instagram website with the distractin
|
|||||||
|
|
||||||
**The app itself**
|
**The app itself**
|
||||||
|
|
||||||
- Feels (almost) like a native app, not a browser
|
- Feels (almost) like a native app, not a browser.
|
||||||
- No blank loading screen — content loads in the background before you get there
|
- No blank loading screen — content loads in the background before you get there
|
||||||
- Instant updates via pull-to-refresh
|
- Instant updates via pull-to-refresh
|
||||||
- Dark mode follows your system
|
- Dark mode follows your system
|
||||||
@@ -68,11 +62,8 @@ FocusGram is an Android app that loads the Instagram website with the distractin
|
|||||||
2. Download `focusgram-release.apk`
|
2. Download `focusgram-release.apk`
|
||||||
3. Open the file and allow "Install from unknown sources" if prompted
|
3. Open the file and allow "Install from unknown sources" if prompted
|
||||||
|
|
||||||
### Uptodown
|
### F-Droid
|
||||||
1. Go to the [FocusGram on Uptodown](https://focusgram.en.uptodown.com/android) page
|
Submission is in progress. Updates will publish automatically once accepted.
|
||||||
2. Click "Get the Latest Version"
|
|
||||||
3. Click "Download"
|
|
||||||
4. Open the file and allow "Install from unknown sources" if prompted
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,13 +75,14 @@ FocusGram has no access to your Instagram account credentials. It loads `instagr
|
|||||||
- No crash reporting
|
- No crash reporting
|
||||||
- No third-party SDKs
|
- No third-party SDKs
|
||||||
- No data leaves your device
|
- No data leaves your device
|
||||||
|
- All settings and history are stored locally using Android's standard storage APIs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Frequently asked questions
|
## Frequently asked questions
|
||||||
|
|
||||||
**Will this get my account banned?**
|
**Will this get my account banned?**
|
||||||
Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials.
|
Unlikely. FocusGram's traffic is indistinguishable from someone using Instagram in Chrome. It does not use Instagram's private API, does not automate any actions, and does not intercept credentials. See the technical details below for specifics.
|
||||||
|
|
||||||
**Is this a mod of Instagram's app?**
|
**Is this a mod of Instagram's app?**
|
||||||
No. FocusGram is a separate app that loads `instagram.com` in a WebView. It does not modify Instagram's APK or use any of Meta's proprietary code.
|
No. FocusGram is a separate app that loads `instagram.com` in a WebView. It does not modify Instagram's APK or use any of Meta's proprietary code.
|
||||||
@@ -108,10 +100,6 @@ Because it should be. FocusGram is built and maintained by [Ujwal Chapagain](htt
|
|||||||
### Requirements
|
### Requirements
|
||||||
- Flutter stable channel (3.38+)
|
- Flutter stable channel (3.38+)
|
||||||
- Android SDK
|
- Android SDK
|
||||||
- NDK 28.2.12676356
|
|
||||||
- Android SDK cmdline tools 20
|
|
||||||
- Android build tools 34 and 35
|
|
||||||
- JDK 17 (Eclipse Adoptium 17.0.17+)
|
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
```bash
|
```bash
|
||||||
@@ -125,17 +113,15 @@ FocusGram uses a standard Android System WebView to load `instagram.com`. All fe
|
|||||||
- CSS injection (element hiding, grayscale, scroll behaviour)
|
- CSS injection (element hiding, grayscale, scroll behaviour)
|
||||||
- URL interception via NavigationDelegate (Reels blocking, Explore blocking)
|
- URL interception via NavigationDelegate (Reels blocking, Explore blocking)
|
||||||
|
|
||||||
### Permissions
|
Nothing is modified server-side. The app never reads, intercepts, or stores Instagram content beyond what is explicitly listed (Reel URL, title, and thumbnail URL for the local history feature).
|
||||||
|
|
||||||
|
### Permissions
|
||||||
| Permission | Reason |
|
| Permission | Reason |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `INTERNET` | Load instagram.com |
|
| `INTERNET` | Load instagram.com |
|
||||||
| `RECEIVE_BOOT_COMPLETED` | Keep session timers accurate after device restart |
|
| `RECEIVE_BOOT_COMPLETED` | Keep session timers accurate after device restart |
|
||||||
| `WAKE_LOCK` | Keep device awake during active Focus sessions |
|
|
||||||
| `FOREGROUND_SERVICE` | Run background service for session tracking |
|
|
||||||
|
|
||||||
### Stack
|
### Stack
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Framework | Flutter (Dart) |
|
| Framework | Flutter (Dart) |
|
||||||
|
|||||||
@@ -9,10 +9,6 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
analyzer:
|
|
||||||
exclude:
|
|
||||||
- v2/**
|
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
|||||||
@@ -12,5 +12,3 @@ GeneratedPluginRegistrant.java
|
|||||||
key.properties
|
key.properties
|
||||||
**/*.keystore
|
**/*.keystore
|
||||||
**/*.jks
|
**/*.jks
|
||||||
upload-keystore.jks
|
|
||||||
|
|
||||||
|
|||||||
-190
@@ -1,190 +0,0 @@
|
|||||||
|
|
||||||
Copyright (c) 2005-2014, The Android Open Source Project
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
||||||
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
namespace = "com.ujwal.focusgram"
|
namespace = "com.ujwal.focusgram"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
buildToolsVersion = "35.0.0"
|
buildToolsVersion = "34.0.0"
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
@@ -42,10 +42,10 @@ android {
|
|||||||
applicationId = "com.ujwal.focusgram"
|
applicationId = "com.ujwal.focusgram"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = 24
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 35
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = 4
|
versionCode = 3
|
||||||
versionName = "2.0.0"
|
versionName = "1.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram DOM Ad Blocker (Fallback)
|
|
||||||
*
|
|
||||||
* DEPRECATED: Use fetch_interceptor.js for reliable ad blocking.
|
|
||||||
*
|
|
||||||
* This script provides DOM-based ad removal as a FALLBACK for ads that slip through
|
|
||||||
* GraphQL filtering. It's not reliable because Instagram has already rendered the content.
|
|
||||||
*
|
|
||||||
* Injected at DOCUMENT_END
|
|
||||||
* Removes sponsored/posts/tracking elements from the DOM.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const AD_SIGNALS = [
|
|
||||||
'Sponsored',
|
|
||||||
'paid partnership',
|
|
||||||
'Promoted',
|
|
||||||
];
|
|
||||||
|
|
||||||
const textMatchesSignal = (txt) => {
|
|
||||||
if (!txt) return false;
|
|
||||||
const t = txt.trim().toLowerCase();
|
|
||||||
return AD_SIGNALS.some((s) => t === s.toLowerCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeSponsoredArticles = () => {
|
|
||||||
try {
|
|
||||||
// aria-label routes (best-effort; localization may break)
|
|
||||||
document.querySelectorAll('a[aria-label]').forEach((a) => {
|
|
||||||
const aria = a.getAttribute('aria-label') || '';
|
|
||||||
if (textMatchesSignal(aria)) {
|
|
||||||
const article = a.closest('article');
|
|
||||||
if (article) article.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Text-based removal inside feed articles (best-effort)
|
|
||||||
document.querySelectorAll('article').forEach((article) => {
|
|
||||||
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
|
|
||||||
let node;
|
|
||||||
while ((node = walker.nextNode())) {
|
|
||||||
const txt = node.nodeValue;
|
|
||||||
if (textMatchesSignal(txt)) {
|
|
||||||
article.remove();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Suggested content is intentionally left alone. Removing suggested
|
|
||||||
// units after Instagram has virtualized the feed can snap the viewport
|
|
||||||
// back to the top on some accounts.
|
|
||||||
} catch (_) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => removeSponsoredArticles());
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
removeSponsoredArticles();
|
|
||||||
})();
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Autoplay Blocker
|
|
||||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
|
||||||
* Prevents video autoplay by:
|
|
||||||
* 1. Blocking play() calls on video elements
|
|
||||||
* 2. Disabling autoplay attribute
|
|
||||||
* 3. Removing preload attributes
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// This script is only registered when the setting is enabled, so default ON.
|
|
||||||
window.__fgBlockAutoplay = typeof window.__fgBlockAutoplay === 'boolean'
|
|
||||||
? window.__fgBlockAutoplay : true;
|
|
||||||
const ALLOW_KEY = '__fgUserStartedPlayback';
|
|
||||||
let userGestureUntil = 0;
|
|
||||||
|
|
||||||
function isReelRoute() {
|
|
||||||
const path = window.location.pathname || '';
|
|
||||||
return path.indexOf('/reel/') >= 0 || path === '/reels' || path.indexOf('/reels/') >= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUserGestureActive() {
|
|
||||||
return Date.now() < userGestureUntil;
|
|
||||||
}
|
|
||||||
|
|
||||||
function markUserGesture(target) {
|
|
||||||
userGestureUntil = Date.now() + 1200;
|
|
||||||
try {
|
|
||||||
let video = target && target.closest ? target.closest('video') : null;
|
|
||||||
if (!video && target && target.querySelector) video = target.querySelector('video');
|
|
||||||
if (video) video[ALLOW_KEY] = true;
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('pointerdown', function (event) {
|
|
||||||
markUserGesture(event.target);
|
|
||||||
}, true);
|
|
||||||
document.addEventListener('touchstart', function (event) {
|
|
||||||
markUserGesture(event.target);
|
|
||||||
}, true);
|
|
||||||
document.addEventListener('click', function (event) {
|
|
||||||
markUserGesture(event.target);
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
// Override HTMLMediaElement.play() to check our flag
|
|
||||||
const _play = HTMLMediaElement.prototype.play;
|
|
||||||
HTMLMediaElement.prototype.play = function () {
|
|
||||||
if (
|
|
||||||
window.__fgBlockAutoplay &&
|
|
||||||
!isReelRoute() &&
|
|
||||||
this[ALLOW_KEY] !== true &&
|
|
||||||
!isUserGestureActive()
|
|
||||||
) {
|
|
||||||
// Return a resolved promise to avoid breaking Instagram's code
|
|
||||||
try { this.pause(); } catch (_) {}
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return _play.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Override autoplay property setter
|
|
||||||
const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
|
|
||||||
const _originalAutoplaySetter = _videoDescriptor.set;
|
|
||||||
|
|
||||||
Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
|
|
||||||
set: function (value) {
|
|
||||||
if (window.__fgBlockAutoplay && value) {
|
|
||||||
// Silently ignore autoplay attempts when blocking is enabled
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_originalAutoplaySetter) {
|
|
||||||
_originalAutoplaySetter.call(this, value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
get: function () {
|
|
||||||
if (_videoDescriptor.get) {
|
|
||||||
return _videoDescriptor.get.call(this);
|
|
||||||
}
|
|
||||||
return this.getAttribute('autoplay') !== null;
|
|
||||||
},
|
|
||||||
enumerable: _videoDescriptor.enumerable,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// On page load and SPA navigation, scan for video elements and remove autoplay
|
|
||||||
const removeAutoplayFromVideos = () => {
|
|
||||||
document.querySelectorAll('video, [role="video"]').forEach(el => {
|
|
||||||
if (window.__fgBlockAutoplay && !isReelRoute() && el[ALLOW_KEY] !== true) {
|
|
||||||
el.autoplay = false;
|
|
||||||
el.removeAttribute('autoplay');
|
|
||||||
el.removeAttribute('preload');
|
|
||||||
try { el.preload = 'none'; } catch (_) {}
|
|
||||||
if (el.paused === false) {
|
|
||||||
el.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run on load and when document changes
|
|
||||||
removeAutoplayFromVideos();
|
|
||||||
|
|
||||||
if (!window.__fgAutoplayObserver) {
|
|
||||||
let _timer = null;
|
|
||||||
window.__fgAutoplayObserver = new MutationObserver(() => {
|
|
||||||
clearTimeout(_timer);
|
|
||||||
_timer = setTimeout(removeAutoplayFromVideos, 500);
|
|
||||||
});
|
|
||||||
window.__fgAutoplayObserver.observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow Flutter to toggle
|
|
||||||
window.__fgSetBlockAutoplay = function (enabled) {
|
|
||||||
window.__fgBlockAutoplay = !!enabled;
|
|
||||||
if (enabled) {
|
|
||||||
removeAutoplayFromVideos();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('play', function (event) {
|
|
||||||
if (event.target && event.target.tagName === 'VIDEO' && isUserGestureActive()) {
|
|
||||||
event.target[ALLOW_KEY] = true;
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
})();
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Content Hider
|
|
||||||
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
|
|
||||||
* Flutter controls via window.__fgContent.*
|
|
||||||
* Injected at DOCUMENT_END.
|
|
||||||
*
|
|
||||||
* Key fixes applied:
|
|
||||||
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
|
|
||||||
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
|
|
||||||
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
|
|
||||||
* - Stories tray detection strengthened for fresh SPA navigations
|
|
||||||
* - Suggested posts detection uses multiple text-node matching strategies
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (window.__fgContent && window.__fgContent.__focusgramReady) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STYLE_ID = 'fg-content-hider';
|
|
||||||
let hideStories = false;
|
|
||||||
let hidePosts = false;
|
|
||||||
let hideSuggested = false;
|
|
||||||
let hideReels = false;
|
|
||||||
|
|
||||||
// ─── CSS rules ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildCSS() {
|
|
||||||
const selectors = [];
|
|
||||||
|
|
||||||
if (hideStories) {
|
|
||||||
selectors.push(
|
|
||||||
'[role="list"]:has([aria-label*="tory"])',
|
|
||||||
'[role="listbox"]:has([aria-label*="tory"])',
|
|
||||||
'[role="menu"] > ul',
|
|
||||||
'section > div > div:first-child [style*="overflow"]',
|
|
||||||
'[role="list"] [style*="overflow"]',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hidePosts) {
|
|
||||||
selectors.push(
|
|
||||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
|
|
||||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// hideReels CSS is intentionally NOT added here.
|
|
||||||
// We use DOM removal instead (see removeReels()) so that room is never left
|
|
||||||
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
|
|
||||||
|
|
||||||
return selectors.length
|
|
||||||
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCSS() {
|
|
||||||
if (document.body) {
|
|
||||||
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
|
|
||||||
}
|
|
||||||
let style = document.getElementById(STYLE_ID);
|
|
||||||
if (!style) {
|
|
||||||
style = document.createElement('style');
|
|
||||||
style.id = STYLE_ID;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
style.textContent = buildCSS();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Story tray JS ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function hideStoryTray() {
|
|
||||||
if (!hideStories) return;
|
|
||||||
|
|
||||||
// Strategy 1: <ul> children of a named list or menu
|
|
||||||
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
|
|
||||||
try {
|
|
||||||
const items = ul.querySelectorAll('li, button, a');
|
|
||||||
if (items.length < 2) return;
|
|
||||||
ul.style.setProperty('display', 'none', 'important');
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 2: horizontally scrolling container with circle items
|
|
||||||
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
|
|
||||||
try {
|
|
||||||
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
|
|
||||||
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
|
|
||||||
if (cands.length < 2) return;
|
|
||||||
const s0 = window.getComputedStyle(cands[0]);
|
|
||||||
if (s0.width && parseFloat(s0.width) <= 90) {
|
|
||||||
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Suggested posts ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function removeSuggested() {
|
|
||||||
if (!hideSuggested) return;
|
|
||||||
|
|
||||||
var SIGNALS = [
|
|
||||||
'suggested for you',
|
|
||||||
'suggested posts',
|
|
||||||
'suggested reels',
|
|
||||||
'suggested',
|
|
||||||
'because you watched',
|
|
||||||
'because you follow',
|
|
||||||
'you might like',
|
|
||||||
'posts you might like',
|
|
||||||
'accounts you might like',
|
|
||||||
'recommendations',
|
|
||||||
];
|
|
||||||
|
|
||||||
function norm(s) {
|
|
||||||
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSignal(s) {
|
|
||||||
var t = norm(s);
|
|
||||||
if (!t) return false;
|
|
||||||
return SIGNALS.some(function (signal) {
|
|
||||||
if (signal === 'suggested') return t === signal;
|
|
||||||
return t.indexOf(signal) >= 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideContainer(from) {
|
|
||||||
var parent = from;
|
|
||||||
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
|
|
||||||
var role = parent.getAttribute && parent.getAttribute('role');
|
|
||||||
var tag = parent.tagName;
|
|
||||||
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
|
|
||||||
if (
|
|
||||||
tag === 'ARTICLE' ||
|
|
||||||
tag === 'SECTION' ||
|
|
||||||
role === 'listitem' ||
|
|
||||||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
|
|
||||||
) {
|
|
||||||
parent.style.setProperty('display', 'none', 'important');
|
|
||||||
parent.setAttribute('data-fg-hidden-suggested', '1');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
parent = parent.parentElement;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
|
|
||||||
try {
|
|
||||||
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
|
|
||||||
var ownLabel = node.getAttribute('aria-label');
|
|
||||||
if (hasSignal(ownLabel)) { hideContainer(node); return; }
|
|
||||||
var text = norm(node.innerText || node.textContent || '');
|
|
||||||
if (
|
|
||||||
text.indexOf('suggested for you') >= 0 ||
|
|
||||||
text.indexOf('suggested posts') >= 0 ||
|
|
||||||
text.indexOf('suggested reels') >= 0 ||
|
|
||||||
text.indexOf('because you watched') >= 0 ||
|
|
||||||
text.indexOf('because you follow') >= 0
|
|
||||||
) {
|
|
||||||
hideContainer(node);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
|
|
||||||
try {
|
|
||||||
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
|
|
||||||
hideContainer(el);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Reels – DOM REMOVE (not display:none) ─────────────────────────────────
|
|
||||||
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
|
|
||||||
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
|
|
||||||
// gap cleanly and lets the feed flow naturally.
|
|
||||||
function removeReels() {
|
|
||||||
if (!hideReels) return;
|
|
||||||
|
|
||||||
var toRemove = [];
|
|
||||||
document.querySelectorAll('article').forEach(function (el) {
|
|
||||||
try {
|
|
||||||
// Fast path: check for a reel-signal attribute first
|
|
||||||
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
|
|
||||||
if (mt === '2') { toRemove.push(el); return; }
|
|
||||||
|
|
||||||
// Fallback: text-node scan for /reels/ markers
|
|
||||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
||||||
var n;
|
|
||||||
while ((n = walker.nextNode())) {
|
|
||||||
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
|
|
||||||
toRemove.push(el); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public API ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
window.__fgContent = {
|
|
||||||
__focusgramReady: true,
|
|
||||||
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
|
|
||||||
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
|
|
||||||
setHideSuggested: function (val) {
|
|
||||||
hideSuggested = !!val;
|
|
||||||
applyCSS();
|
|
||||||
if (val) removeSuggested();
|
|
||||||
},
|
|
||||||
setHideReels: function (val) {
|
|
||||||
hideReels = !!val;
|
|
||||||
applyCSS();
|
|
||||||
if (val) removeReels();
|
|
||||||
},
|
|
||||||
applyAll: function (flags) {
|
|
||||||
hideStories = !!flags.stories;
|
|
||||||
hidePosts = !!flags.posts;
|
|
||||||
hideReels = !!flags.reels;
|
|
||||||
hideSuggested = !!flags.suggested;
|
|
||||||
applyCSS();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideStories) hideStoryTray();
|
|
||||||
if (hideReels) removeReels();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
|
|
||||||
// pushState/replaceState don't fire any DOM event we can listen for.
|
|
||||||
// Hook the methods themselves so we know a navigation happened, then debounce
|
|
||||||
// re-apply. This also catches the case where the MutationObserver was on `body`
|
|
||||||
// and that node got replaced by Instagram's SPA re-render.
|
|
||||||
|
|
||||||
function scheduleReapply() {
|
|
||||||
clearTimeout(window.__fg_applyTimer);
|
|
||||||
window.__fg_applyTimer = setTimeout(function () {
|
|
||||||
applyCSS();
|
|
||||||
if (hideStories) hideStoryTray();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideReels) removeReels();
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
var _origPush = history.pushState;
|
|
||||||
var _origReplace = history.replaceState;
|
|
||||||
|
|
||||||
history.pushState = function () {
|
|
||||||
_origPush.apply(this, arguments);
|
|
||||||
scheduleReapply();
|
|
||||||
};
|
|
||||||
|
|
||||||
history.replaceState = function () {
|
|
||||||
_origReplace.apply(this, arguments);
|
|
||||||
scheduleReapply();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reinforce on popstate too (user hits back/forward)
|
|
||||||
window.addEventListener('popstate', scheduleReapply, { passive: true });
|
|
||||||
// For pushState on the same URL (rare but possible) – poll path briefly
|
|
||||||
window.addEventListener('pageshow', scheduleReapply, { passive: true });
|
|
||||||
window.addEventListener('focus', scheduleReapply, { passive: true });
|
|
||||||
|
|
||||||
// ─── MutationObserver ───────────────────────────────────────────────────────
|
|
||||||
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
|
|
||||||
// re-applies everything on each cycle. Does NOT guard on a per-element timer
|
|
||||||
// that would never re-fire after the body is replaced by SPA re-render.
|
|
||||||
|
|
||||||
if (!window.__fgContentObserver) {
|
|
||||||
window.__fgContentObserver = new MutationObserver(function () {
|
|
||||||
clearTimeout(window.__fg_moTimer);
|
|
||||||
window.__fg_moTimer = setTimeout(function () {
|
|
||||||
applyCSS();
|
|
||||||
if (hideStories) hideStoryTray();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideReels) removeReels();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// `document.documentElement` survives SPA navigations (body gets replaced
|
|
||||||
// but <html> stays). Observing it catches both subtree mutations and, via
|
|
||||||
// the SPA heartbeat above, re-applies after pushState.
|
|
||||||
window.__fgContentObserver.observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Initial run ────────────────────────────────────────────────────────────
|
|
||||||
applyCSS();
|
|
||||||
if (hideStories) hideStoryTray();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideReels) removeReels();
|
|
||||||
|
|
||||||
// Signal ready — Flutter will call applyAll() with stored prefs
|
|
||||||
if (window.ContentChannel) {
|
|
||||||
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Unified Feed Filter via Fetch Interception
|
|
||||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
|
||||||
*
|
|
||||||
* This script intercepts GraphQL fetch calls and filters feed content based on:
|
|
||||||
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
|
|
||||||
* - Sponsored posts (ad_action_link, ad_header_style)
|
|
||||||
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
|
|
||||||
* - Videos/Reels (is_video, media_type, clips_metadata)
|
|
||||||
* - Autoplay blocking (video autoplay prevention)
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Configuration flags (set by Flutter via prefs)
|
|
||||||
window.__fgFilterConfig = {
|
|
||||||
blockAds: false,
|
|
||||||
blockSponsored: false,
|
|
||||||
blockSuggested: false,
|
|
||||||
blockVideos: false,
|
|
||||||
blockAutoplay: false,
|
|
||||||
blockGraphQLQueryWhenFeedPosts: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const textHasAdSignal = (value) => {
|
|
||||||
const s = String(value || '').toLowerCase();
|
|
||||||
return (
|
|
||||||
s === 'sponsored' ||
|
|
||||||
s.includes('"sponsored"') ||
|
|
||||||
s.includes('paid partnership') ||
|
|
||||||
s.includes('promoted') ||
|
|
||||||
s.includes('ad_id') ||
|
|
||||||
s.includes('ad_tracking') ||
|
|
||||||
s.includes('sponsor_tags')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is an ad
|
|
||||||
const isAdNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
const typename = String(node.__typename || '');
|
|
||||||
const adText = JSON.stringify({
|
|
||||||
organic_tracking_token: node.organic_tracking_token,
|
|
||||||
sponsor_tags: node.sponsor_tags,
|
|
||||||
social_context: node.social_context,
|
|
||||||
title: node.title,
|
|
||||||
header: node.header,
|
|
||||||
label: node.label,
|
|
||||||
overlay_text: node.overlay_text,
|
|
||||||
});
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
node.is_ad ||
|
|
||||||
node.is_paid_partnership ||
|
|
||||||
node.sponsor_tags ||
|
|
||||||
node.ad_tracking_token ||
|
|
||||||
node.ad_action_link ||
|
|
||||||
node.ad_id ||
|
|
||||||
node.ad_impression_token ||
|
|
||||||
node.ad_metadata ||
|
|
||||||
node.commerciality_status === 'commercial' ||
|
|
||||||
(node.product_type && node.product_type === 'ad') ||
|
|
||||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
|
||||||
typename === 'GraphAdStory' ||
|
|
||||||
typename.includes('Ad') ||
|
|
||||||
textHasAdSignal(adText)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is sponsored
|
|
||||||
const isSponsoredNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
node.is_paid_partnership ||
|
|
||||||
node.sponsor_tags ||
|
|
||||||
(node.ad_action_link && node.ad_action_link.href) ||
|
|
||||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
|
||||||
textHasAdSignal(JSON.stringify(node.social_context || node.header || node.label || ''))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is suggested content
|
|
||||||
const isSuggestedNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
const typename = String(node.__typename || '');
|
|
||||||
const reason = JSON.stringify({
|
|
||||||
reason: node.suggested_reason,
|
|
||||||
social_context: node.social_context,
|
|
||||||
title: node.title,
|
|
||||||
header: node.header,
|
|
||||||
label: node.label,
|
|
||||||
}).toLowerCase();
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
node.is_suggested ||
|
|
||||||
node.is_suggested_for_you ||
|
|
||||||
node.is_recommendation ||
|
|
||||||
node.suggested_users ||
|
|
||||||
node.suggested_media ||
|
|
||||||
node.suggested_content ||
|
|
||||||
node.recommendation_source ||
|
|
||||||
typename.includes('Suggested') ||
|
|
||||||
typename.includes('Recommendation') ||
|
|
||||||
reason.includes('suggested') ||
|
|
||||||
reason.includes('recommend')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is a video/reel
|
|
||||||
const isVideoNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
node.is_video ||
|
|
||||||
(node.media_type === 2) ||
|
|
||||||
node.clips_metadata ||
|
|
||||||
(node.__typename && (
|
|
||||||
node.__typename.includes('Clips') ||
|
|
||||||
node.__typename.includes('Video')
|
|
||||||
))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFeedMediaNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
return !!(
|
|
||||||
node.pk ||
|
|
||||||
node.id ||
|
|
||||||
node.code ||
|
|
||||||
node.media_type ||
|
|
||||||
node.image_versions2 ||
|
|
||||||
node.video_versions ||
|
|
||||||
node.carousel_media ||
|
|
||||||
node.__typename?.includes('Media') ||
|
|
||||||
node.__typename?.includes('Timeline')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check for media in carousel
|
|
||||||
const hasVideoInCarousel = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
if (node.media_type === 8) {
|
|
||||||
const edges = node.edge_sidecar_to_children?.edges || [];
|
|
||||||
return edges.some(edge => isVideoNode(edge.node));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main filter function for feed nodes
|
|
||||||
const shouldFilterNode = (node) => {
|
|
||||||
const config = window.__fgFilterConfig;
|
|
||||||
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check ads
|
|
||||||
if (config.blockAds && isAdNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check sponsored (separate from ads)
|
|
||||||
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check suggested content
|
|
||||||
if (config.blockSuggested && isSuggestedNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check videos/reels
|
|
||||||
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively filter GraphQL response edges
|
|
||||||
const filterEdges = (edges, path = []) => {
|
|
||||||
if (!Array.isArray(edges)) return edges;
|
|
||||||
|
|
||||||
return edges.filter(edge => {
|
|
||||||
if (!edge || !edge.node) return true;
|
|
||||||
const node = edge.node;
|
|
||||||
|
|
||||||
// Keep the edge if it doesn't match any filter
|
|
||||||
if (!shouldFilterNode(node)) return true;
|
|
||||||
|
|
||||||
// Log filtered content for debugging
|
|
||||||
if (window.__fgDebugFilter) {
|
|
||||||
const type = node.__typename || 'Unknown';
|
|
||||||
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively walk GraphQL response and filter edges
|
|
||||||
const walkAndFilter = (obj, visited = new Set()) => {
|
|
||||||
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
|
|
||||||
visited.add(obj);
|
|
||||||
|
|
||||||
// Handle arrays
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
obj.forEach(item => walkAndFilter(item, visited));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for edges array (common GraphQL pattern)
|
|
||||||
if (obj.edges && Array.isArray(obj.edges)) {
|
|
||||||
obj.edges = filterEdges(obj.edges);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse into children
|
|
||||||
for (const key in obj) {
|
|
||||||
if (obj.hasOwnProperty(key) && key !== '__typename') {
|
|
||||||
const val = obj[key];
|
|
||||||
if (val && typeof val === 'object') {
|
|
||||||
walkAndFilter(val, visited);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Override fetch
|
|
||||||
const _fetch = window.fetch.bind(window);
|
|
||||||
|
|
||||||
window.fetch = async function (input, init) {
|
|
||||||
const url = typeof input === 'string'
|
|
||||||
? input
|
|
||||||
: input instanceof URL
|
|
||||||
? input.href
|
|
||||||
: input?.url ?? '';
|
|
||||||
|
|
||||||
// Call original fetch
|
|
||||||
let response = await _fetch(input, init);
|
|
||||||
|
|
||||||
// Only intercept GraphQL feed queries
|
|
||||||
if (!url.includes('/graphql') && !url.includes('/api/v1/feed')) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone response to read body
|
|
||||||
const cloned = response.clone();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
if (!contentType.includes('application/json')) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await cloned.json();
|
|
||||||
|
|
||||||
// Filter the response data
|
|
||||||
walkAndFilter(data);
|
|
||||||
|
|
||||||
// Return modified response
|
|
||||||
return new Response(JSON.stringify(data), {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers: response.headers,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// On error, return original response
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Preserve native function appearance
|
|
||||||
Object.defineProperty(window, 'fetch', {
|
|
||||||
value: window.fetch,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
|
||||||
|
|
||||||
const _xhrOpen = XMLHttpRequest.prototype.open;
|
|
||||||
const _xhrSend = XMLHttpRequest.prototype.send;
|
|
||||||
XMLHttpRequest.prototype.open = function (method, url) {
|
|
||||||
this.__fgUrl = typeof url === 'string' ? url : String(url || '');
|
|
||||||
return _xhrOpen.apply(this, arguments);
|
|
||||||
};
|
|
||||||
XMLHttpRequest.prototype.send = function () {
|
|
||||||
if (
|
|
||||||
window.__fgFilterConfig.blockVideos &&
|
|
||||||
this.__fgUrl &&
|
|
||||||
(this.__fgUrl.includes('/api/v1/clips/') ||
|
|
||||||
this.__fgUrl.includes('/api/v1/discover/'))
|
|
||||||
) {
|
|
||||||
try { this.abort(); } catch (_) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return _xhrSend.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Allow Flutter to update config flags
|
|
||||||
window.__fgSetFilterConfig = function (config) {
|
|
||||||
if (typeof config === 'object') {
|
|
||||||
Object.assign(window.__fgFilterConfig, config);
|
|
||||||
if (window.__fgDebugFilter) {
|
|
||||||
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enable debug logging
|
|
||||||
window.__fgDebugFilter = false;
|
|
||||||
})();
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Ghost Mode
|
|
||||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
|
||||||
* Blocks story-seen, message-seen, and online-presence signals.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ─── Seen API patterns ────────────────────────────────────────────────────
|
|
||||||
const SEEN_PATTERNS = [
|
|
||||||
/\/api\/v1\/media\/[\w-]+\/seen\//,
|
|
||||||
/\/api\/v1\/stories\/reel\/seen\//,
|
|
||||||
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
|
|
||||||
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
|
|
||||||
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Activity patterns (like, comment) — intercepted for local history ────
|
|
||||||
const ACTIVITY_PATTERNS = [
|
|
||||||
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
|
|
||||||
/\/api\/v1\/web\/comments\/add\//,
|
|
||||||
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
|
|
||||||
];
|
|
||||||
|
|
||||||
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
|
|
||||||
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
|
|
||||||
|
|
||||||
const fakeOkResponse = () =>
|
|
||||||
new Response(JSON.stringify({ status: 'ok' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Fetch override ───────────────────────────────────────────────────────
|
|
||||||
const _fetch = window.fetch.bind(window);
|
|
||||||
|
|
||||||
const patchedFetch = async function (input, init) {
|
|
||||||
const url =
|
|
||||||
typeof input === 'string'
|
|
||||||
? input
|
|
||||||
: input instanceof URL
|
|
||||||
? input.href
|
|
||||||
: input?.url ?? '';
|
|
||||||
|
|
||||||
// Block seen
|
|
||||||
if (isSeen(url)) {
|
|
||||||
if (window.GhostChannel) {
|
|
||||||
window.GhostChannel.postMessage(
|
|
||||||
JSON.stringify({ type: 'seen_blocked', url })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fakeOkResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intercept activity for local history
|
|
||||||
if (isActivity(url) && window.ActivityChannel) {
|
|
||||||
const body = init?.body;
|
|
||||||
const bodyText =
|
|
||||||
body instanceof URLSearchParams
|
|
||||||
? body.toString()
|
|
||||||
: typeof body === 'string'
|
|
||||||
? body
|
|
||||||
: '';
|
|
||||||
window.ActivityChannel.postMessage(
|
|
||||||
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _fetch(input, init);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Disguise as native
|
|
||||||
Object.defineProperty(window, 'fetch', {
|
|
||||||
value: patchedFetch,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
enumerable: true,
|
|
||||||
});
|
|
||||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
|
||||||
window.fetch[Symbol.toStringTag] = 'fetch';
|
|
||||||
|
|
||||||
// ─── XMLHttpRequest override ──────────────────────────────────────────────
|
|
||||||
const _XHROpen = XMLHttpRequest.prototype.open;
|
|
||||||
const _XHRSend = XMLHttpRequest.prototype.send;
|
|
||||||
|
|
||||||
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
|
||||||
this._fg_url = url ?? '';
|
|
||||||
this._fg_method = (method ?? '').toUpperCase();
|
|
||||||
return _XHROpen.call(this, method, url, ...args);
|
|
||||||
};
|
|
||||||
|
|
||||||
XMLHttpRequest.prototype.send = function (body) {
|
|
||||||
if (this._fg_url && isSeen(this._fg_url)) {
|
|
||||||
// Fire readyState 4 with fake success without actually sending
|
|
||||||
const self = this;
|
|
||||||
setTimeout(() => {
|
|
||||||
Object.defineProperty(self, 'readyState', { get: () => 4 });
|
|
||||||
Object.defineProperty(self, 'status', { get: () => 200 });
|
|
||||||
Object.defineProperty(self, 'responseText', {
|
|
||||||
get: () => '{"status":"ok"}',
|
|
||||||
});
|
|
||||||
Object.defineProperty(self, 'response', {
|
|
||||||
get: () => '{"status":"ok"}',
|
|
||||||
});
|
|
||||||
self.dispatchEvent(new Event('readystatechange'));
|
|
||||||
self.dispatchEvent(new Event('load'));
|
|
||||||
}, 10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return _XHRSend.call(this, body);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
|
|
||||||
const _WS = window.WebSocket;
|
|
||||||
|
|
||||||
function PatchedWebSocket(url, protocols) {
|
|
||||||
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
|
||||||
const _send = ws.send.bind(ws);
|
|
||||||
|
|
||||||
ws.send = function (data) {
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
if (
|
|
||||||
parsed?.op === '4' ||
|
|
||||||
parsed?.op === 'seen' ||
|
|
||||||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
|
|
||||||
) {
|
|
||||||
return; // drop
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
// Text-based seen signal check
|
|
||||||
if (data.includes('"seen"') && data.includes('"thread_id"')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _send(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return ws;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve WebSocket prototype chain so IG's ws checks pass
|
|
||||||
PatchedWebSocket.prototype = _WS.prototype;
|
|
||||||
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
|
|
||||||
PatchedWebSocket.OPEN = _WS.OPEN;
|
|
||||||
PatchedWebSocket.CLOSING = _WS.CLOSING;
|
|
||||||
PatchedWebSocket.CLOSED = _WS.CLOSED;
|
|
||||||
window.WebSocket = PatchedWebSocket;
|
|
||||||
|
|
||||||
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
|
|
||||||
// Only applied if user enables online-status hiding
|
|
||||||
// Wrapped in a named fn so Flutter can call it:
|
|
||||||
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
|
|
||||||
window.__fgEnableOnlineHide = function () {
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
get: () => 'hidden',
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(document, 'hidden', {
|
|
||||||
get: () => true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
document.dispatchEvent(new Event('visibilitychange'));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.__fgDisableOnlineHide = function () {
|
|
||||||
// Restore by deleting the overrides (falls back to native getter)
|
|
||||||
delete document.visibilityState;
|
|
||||||
delete document.hidden;
|
|
||||||
document.dispatchEvent(new Event('visibilitychange'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Signal to Flutter that ghost mode JS is active
|
|
||||||
if (window.GhostChannel) {
|
|
||||||
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Theme Detector
|
|
||||||
* Reads light/dark theme from page and bridges to Flutter.
|
|
||||||
* Injected at DOCUMENT_END.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
(function fgThemeSync() {
|
|
||||||
if (window.__fgThemeSyncRunning) return;
|
|
||||||
window.__fgThemeSyncRunning = true;
|
|
||||||
|
|
||||||
function getTheme() {
|
|
||||||
try {
|
|
||||||
const h = document.documentElement;
|
|
||||||
if (h.classList.contains('style-dark')) return 'dark';
|
|
||||||
if (h.classList.contains('style-light')) return 'light';
|
|
||||||
|
|
||||||
const bg = window.getComputedStyle(document.body).backgroundColor;
|
|
||||||
const rgb = bg.match(/\d+/g);
|
|
||||||
if (rgb && rgb.length >= 3) {
|
|
||||||
const luminance =
|
|
||||||
(0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255;
|
|
||||||
return luminance < 0.5 ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
return 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
let last = '';
|
|
||||||
function check() {
|
|
||||||
const current = getTheme();
|
|
||||||
if (current !== last) {
|
|
||||||
last = current;
|
|
||||||
if (window.flutter_inappwebview) {
|
|
||||||
window.flutter_inappwebview.callHandler(
|
|
||||||
'FocusGramThemeChannel',
|
|
||||||
current
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(check, 1500);
|
|
||||||
check();
|
|
||||||
})();
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Initial open-source release of FocusGram.
|
||||||
|
- Complete Reels and Explore hiding.
|
||||||
|
- Timed Reel sessions and daily limits.
|
||||||
|
- Isolated DM Reel player.
|
||||||
|
- Privacy-first: No Firebase or trackers.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Same as1st version. just version pump
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
New: Reels History, Minimal Mode, Disable Reels/Explore toggles, Autoplay Blocker, Screen Time Dashboard, Grayscale Mode, 8 content hide toggles.
|
||||||
|
|
||||||
|
Fixes: DM keyboard bug, Reels scroll lag.
|
||||||
|
|
||||||
|
Performance: Native nav bar, background preloading, skeleton screen, haptic feedback, smooth scrolling.
|
||||||
|
|
||||||
|
F-Droid: Removed all Google dependencies. No Play Services in APK.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FocusGram is a free and open-source Android app designed to help you regain control over your social media usage. It wraps the mobile version of Instagram in a secure WebView and injects features to minimize distractions.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- **Focus Mode**: Blur explore posts and hide reel buttons.
|
||||||
|
- **Guardrails**: Set daily usage limits and session cooldowns.
|
||||||
|
- **Mindfulness**: A mandatory breathing exercise before entering the app.
|
||||||
|
- **Privacy First**: No analytics, no tracking, and no proprietary Google Play Services requirements.
|
||||||
|
- **Hybrid Composition**: Optimized WebView performance for smooth scrolling.
|
||||||
|
|
||||||
|
FocusGram is NOT an Instagram mod. It uses the standard web version of Instagram and applies client-side CSS and JS modifications only.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A digital wellness wrapper for Instagram.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
FocusGram
|
||||||
@@ -92,16 +92,16 @@ class _SkeletonScreenState extends State<SkeletonScreen>
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Container(
|
||||||
height: 80,
|
height: 80,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
itemCount: 6,
|
itemCount: 6,
|
||||||
itemBuilder: (context, index) => Padding(
|
itemBuilder: (context, index) => Padding(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 56,
|
width: 56,
|
||||||
@@ -111,13 +111,13 @@ class _SkeletonScreenState extends State<SkeletonScreen>
|
|||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 6,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -35,11 +35,12 @@ class NativeBottomNav extends StatelessWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final isDark = theme.brightness == Brightness.dark;
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
|
||||||
final bgColor = theme.colorScheme.surface.withValues(
|
final bgColor =
|
||||||
alpha: isDark ? 0.95 : 0.98,
|
theme.colorScheme.surface.withValues(alpha: isDark ? 0.95 : 0.98);
|
||||||
);
|
final iconColorInactive =
|
||||||
final iconColorInactive = isDark ? Colors.white70 : Colors.black54;
|
isDark ? Colors.white70 : Colors.black54;
|
||||||
final iconColorActive = theme.colorScheme.primary;
|
final iconColorActive =
|
||||||
|
theme.colorScheme.primary;
|
||||||
|
|
||||||
final tabs = <_NavItem>[
|
final tabs = <_NavItem>[
|
||||||
_NavItem(
|
_NavItem(
|
||||||
@@ -102,7 +103,8 @@ class NativeBottomNav extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
children: tabs.map((item) {
|
children: tabs.map((item) {
|
||||||
final color = item.active ? iconColorActive : iconColorInactive;
|
final color =
|
||||||
|
item.active ? iconColorActive : iconColorInactive;
|
||||||
final opacity = item.enabled ? 1.0 : 0.35;
|
final opacity = item.enabled ? 1.0 : 0.35;
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
@@ -127,7 +129,10 @@ class NativeBottomNav extends StatelessWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
item.label,
|
item.label,
|
||||||
style: TextStyle(fontSize: 10, color: color),
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -159,3 +164,4 @@ class _NavItem {
|
|||||||
required this.enabled,
|
required this.enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ class InstagramPreloader {
|
|||||||
|
|
||||||
_headlessWebView = HeadlessInAppWebView(
|
_headlessWebView = HeadlessInAppWebView(
|
||||||
keepAlive: keepAlive,
|
keepAlive: keepAlive,
|
||||||
initialUrlRequest: URLRequest(url: WebUri('https://www.instagram.com/')),
|
initialUrlRequest: URLRequest(
|
||||||
|
url: WebUri('https://www.instagram.com/'),
|
||||||
|
),
|
||||||
initialSettings: InAppWebViewSettings(
|
initialSettings: InAppWebViewSettings(
|
||||||
userAgent: userAgent,
|
userAgent: userAgent,
|
||||||
mediaPlaybackRequiresUserGesture: true,
|
mediaPlaybackRequiresUserGesture: true,
|
||||||
@@ -67,3 +69,4 @@ class InstagramPreloader {
|
|||||||
isReady = false;
|
isReady = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ class ReelsHistoryEntry {
|
|||||||
url: (json['url'] as String?) ?? '',
|
url: (json['url'] as String?) ?? '',
|
||||||
title: (json['title'] as String?) ?? 'Instagram Reel',
|
title: (json['title'] as String?) ?? 'Instagram Reel',
|
||||||
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
|
thumbnailUrl: (json['thumbnailUrl'] as String?) ?? '',
|
||||||
visitedAt:
|
visitedAt: DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
|
||||||
DateTime.tryParse((json['visitedAt'] as String?) ?? '') ??
|
|
||||||
DateTime.now().toUtc(),
|
DateTime.now().toUtc(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -115,3 +114,4 @@ class ReelsHistoryService {
|
|||||||
await prefs.setString(_prefsKey, jsonEncode(jsonList));
|
await prefs.setString(_prefsKey, jsonEncode(jsonList));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ class _UpdateBannerState extends State<UpdateBanner> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.secondaryContainer,
|
color: colorScheme.secondaryContainer,
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(color: colorScheme.outlineVariant, width: 0.5),
|
bottom: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -118,11 +121,10 @@ class _UpdateBannerState extends State<UpdateBanner> {
|
|||||||
text = text.replaceAll(RegExp(r'#{1,6}\s'), '');
|
text = text.replaceAll(RegExp(r'#{1,6}\s'), '');
|
||||||
text = text.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'\1');
|
text = text.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'\1');
|
||||||
text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1');
|
text = text.replaceAll(RegExp(r'\*(.*?)\*'), r'\1');
|
||||||
text = text.replaceAll(
|
text =
|
||||||
RegExp(r'\[([^\]]+)\]\([^)]+\)'),
|
text.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'\1'); // links -> text
|
||||||
r'\1',
|
|
||||||
); // links -> text
|
|
||||||
text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1');
|
text = text.replaceAll(RegExp(r'`([^`]+)`'), r'\1');
|
||||||
return text.trim();
|
return text.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,8 @@ class UpdateCheckerService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final cleanVersion = gitVersionTag.startsWith('v')
|
final cleanVersion =
|
||||||
? gitVersionTag.substring(1)
|
gitVersionTag.startsWith('v') ? gitVersionTag.substring(1) : gitVersionTag;
|
||||||
: gitVersionTag;
|
|
||||||
|
|
||||||
var trimmed = body.trim();
|
var trimmed = body.trim();
|
||||||
if (trimmed.length > 1500) {
|
if (trimmed.length > 1500) {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
class FocusSettings {
|
|
||||||
final bool ghostMode; // hide read receipts
|
|
||||||
final bool noAds; // strip ads and sponsored posts
|
|
||||||
final bool noStories; // hide story tray
|
|
||||||
final bool noReels; // hide reels tab
|
|
||||||
final bool noAutoplay; // stop videos autoplaying
|
|
||||||
final bool noDMs; // block direct messages
|
|
||||||
|
|
||||||
const FocusSettings({
|
|
||||||
this.ghostMode = false,
|
|
||||||
this.noAds = true,
|
|
||||||
this.noStories = false,
|
|
||||||
this.noReels = false,
|
|
||||||
this.noAutoplay = false,
|
|
||||||
this.noDMs = false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ import 'screens/cooldown_gate_screen.dart';
|
|||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'features/update_checker/update_checker_service.dart';
|
import 'features/update_checker/update_checker_service.dart';
|
||||||
import 'features/preloader/instagram_preloader.dart';
|
import 'features/preloader/instagram_preloader.dart';
|
||||||
import 'widgets/remote_popup_handler.dart';
|
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -109,10 +108,6 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_appLinks = AppLinks();
|
_appLinks = AppLinks();
|
||||||
_initDeepLinks();
|
_initDeepLinks();
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
RemotePopupHandler.checkAndShow(context);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initDeepLinks() async {
|
Future<void> _initDeepLinks() async {
|
||||||
@@ -150,7 +145,6 @@ class _InitialRouteHandlerState extends State<InitialRouteHandler> {
|
|||||||
// Step 3: Breath gate
|
// Step 3: Breath gate
|
||||||
if (settings.showBreathGate && !_breathCompleted) {
|
if (settings.showBreathGate && !_breathCompleted) {
|
||||||
return BreathGateScreen(
|
return BreathGateScreen(
|
||||||
durationSeconds: settings.breathGateSeconds,
|
|
||||||
onFinish: () => setState(() => _breathCompleted = true),
|
onFinish: () => setState(() => _breathCompleted = true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,25 +27,7 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
|||||||
55,
|
55,
|
||||||
60,
|
60,
|
||||||
];
|
];
|
||||||
int _selectedIndex = 0; // default: 5 min unless a previous choice exists
|
int _selectedIndex = 2; // default: 15 min
|
||||||
late final FixedExtentScrollController _scrollController;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final lastMinutes = context.read<SessionManager>().lastAppSessionMinutes;
|
|
||||||
final lastIndex = _minuteOptions.indexOf(lastMinutes);
|
|
||||||
_selectedIndex = lastIndex >= 0 ? lastIndex : 0;
|
|
||||||
_scrollController = FixedExtentScrollController(
|
|
||||||
initialItem: _selectedIndex,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -136,10 +118,12 @@ class _AppSessionPickerScreenState extends State<AppSessionPickerScreen> {
|
|||||||
perspective: 0.003,
|
perspective: 0.003,
|
||||||
squeeze: 1.1,
|
squeeze: 1.1,
|
||||||
diameterRatio: 2.5,
|
diameterRatio: 2.5,
|
||||||
controller: _scrollController,
|
|
||||||
onSelectedItemChanged: (i) {
|
onSelectedItemChanged: (i) {
|
||||||
setState(() => _selectedIndex = i);
|
setState(() => _selectedIndex = i);
|
||||||
},
|
},
|
||||||
|
controller: FixedExtentScrollController(
|
||||||
|
initialItem: _selectedIndex,
|
||||||
|
),
|
||||||
childDelegate: ListWheelChildListDelegate(
|
childDelegate: ListWheelChildListDelegate(
|
||||||
children: _minuteOptions.asMap().entries.map((entry) {
|
children: _minuteOptions.asMap().entries.map((entry) {
|
||||||
final isSelected = entry.key == _selectedIndex;
|
final isSelected = entry.key == _selectedIndex;
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
/// A mindfulness screen shown before Instagram opens.
|
/// A mindfulness screen shown before the app opens.
|
||||||
|
/// Forces the user to take a deep 10-second breath.
|
||||||
class BreathGateScreen extends StatefulWidget {
|
class BreathGateScreen extends StatefulWidget {
|
||||||
final VoidCallback onFinish;
|
final VoidCallback onFinish;
|
||||||
final int durationSeconds;
|
|
||||||
|
|
||||||
const BreathGateScreen({
|
const BreathGateScreen({super.key, required this.onFinish});
|
||||||
super.key,
|
|
||||||
required this.onFinish,
|
|
||||||
this.durationSeconds = 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
State<BreathGateScreen> createState() => _BreathGateScreenState();
|
||||||
@@ -20,15 +16,15 @@ class _BreathGateScreenState extends State<BreathGateScreen>
|
|||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
late AnimationController _controller;
|
late AnimationController _controller;
|
||||||
late Animation<double> _scaleAnimation;
|
late Animation<double> _scaleAnimation;
|
||||||
late int _secondsRemaining;
|
int _secondsRemaining = 10;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _canContinue = false;
|
bool _canContinue = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_secondsRemaining = widget.durationSeconds.clamp(3, 60).toInt();
|
|
||||||
|
|
||||||
|
// 10-second breathing animation: 5s in, 5s out
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(seconds: 5),
|
duration: const Duration(seconds: 5),
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
import '../services/settings_service.dart';
|
|
||||||
|
|
||||||
class ExtrasSettingsPage extends StatelessWidget {
|
|
||||||
const ExtrasSettingsPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final settings = context.watch<SettingsService>();
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text(
|
|
||||||
'Extras',
|
|
||||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: ListView(
|
|
||||||
children: [
|
|
||||||
const _SectionHeader(title: 'MEDIA'),
|
|
||||||
_SwitchTile(
|
|
||||||
title: 'Download Media (Feed + Reels)',
|
|
||||||
subtitle: 'Adds a download icon on posts and reels',
|
|
||||||
value: settings.videoDownloadEnabled,
|
|
||||||
onChanged: (v) async {
|
|
||||||
await settings.setVideoDownloadEnabled(v);
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
const _SectionHeader(title: 'FOCUS'),
|
|
||||||
_SwitchTile(
|
|
||||||
title: 'GHOST MODE',
|
|
||||||
subtitle: 'Hide seen indicator / read receipts',
|
|
||||||
value: settings.ghostMode,
|
|
||||||
onChanged: (v) async {
|
|
||||||
await settings.setGhostMode(v);
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.amber.withValues(alpha: 0.07),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: Colors.amber.withValues(alpha: 0.12)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8, top: 2),
|
|
||||||
child: Icon(
|
|
||||||
Icons.info_outline,
|
|
||||||
size: 14,
|
|
||||||
color: Colors.amber,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'NOTE: The seen indicator is not sent to the sender while you are active in the chat, but as soon as you close and reopen the chat, the seen indicator is sent.',
|
|
||||||
style: TextStyle(fontSize: 11, color: Colors.amber),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
/* TRIED BUT IT DIDNT WORK 98% oF THE TIME)
|
|
||||||
|
|
||||||
const _SectionHeader(title: 'FOCUSGRAM V2'),
|
|
||||||
_SwitchTile(
|
|
||||||
title: 'Ad Blocker',
|
|
||||||
subtitle: 'Removes ads and sponsored posts',
|
|
||||||
value: settings.v2AdBlockerDomEnabled,
|
|
||||||
onChanged: (v) async {
|
|
||||||
await settings.setV2AdBlockerDomEnabled(v);
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
_SwitchTile(
|
|
||||||
title: 'Block Suggested Posts',
|
|
||||||
subtitle: 'Removes Suggested for you and recommendation units',
|
|
||||||
value: settings.contentSuggested,
|
|
||||||
onChanged: (v) async {
|
|
||||||
await settings.setContentSuggestedEnabled(v);
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
*/
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SwitchTile extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String? subtitle;
|
|
||||||
final bool value;
|
|
||||||
final ValueChanged<bool> onChanged;
|
|
||||||
|
|
||||||
const _SwitchTile({
|
|
||||||
required this.title,
|
|
||||||
this.subtitle,
|
|
||||||
required this.value,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SwitchListTile(
|
|
||||||
title: Text(title, style: const TextStyle(fontSize: 15)),
|
|
||||||
subtitle: subtitle != null
|
|
||||||
? Text(subtitle ?? '', style: const TextStyle(fontSize: 12))
|
|
||||||
: null,
|
|
||||||
value: value,
|
|
||||||
onChanged: onChanged,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
const _SectionHeader({required this.title});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.grey,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,11 +18,7 @@ class _GuardrailsPageState extends State<GuardrailsPage> {
|
|||||||
Future<void> Function() action,
|
Future<void> Function() action,
|
||||||
) async {
|
) async {
|
||||||
if (sm.isScheduledBlockActive) {
|
if (sm.isScheduledBlockActive) {
|
||||||
final settings = context.read<SettingsService>();
|
final ok = await DisciplineChallenge.show(context, count: 35);
|
||||||
final ok = await DisciplineChallenge.show(
|
|
||||||
context,
|
|
||||||
count: settings.resolvedWordChallengeCount(),
|
|
||||||
);
|
|
||||||
if (!context.mounted || !ok) return;
|
if (!context.mounted || !ok) return;
|
||||||
}
|
}
|
||||||
await action();
|
await action();
|
||||||
@@ -325,8 +321,7 @@ class _FrictionSliderTileState extends State<_FrictionSliderTile> {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final sm = context.read<SessionManager>();
|
final sm = context.read<SessionManager>();
|
||||||
final settings = context.read<SettingsService>();
|
int wordCount = 15;
|
||||||
int wordCount = settings.resolvedWordChallengeCount();
|
|
||||||
// If we are at 0 quota, increase difficulty to 35 words
|
// If we are at 0 quota, increase difficulty to 35 words
|
||||||
if (widget.title.contains('Daily Reel Limit') &&
|
if (widget.title.contains('Daily Reel Limit') &&
|
||||||
sm.dailyRemainingSeconds <= 0) {
|
sm.dailyRemainingSeconds <= 0) {
|
||||||
|
|||||||
+218
-1018
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
|
|
||||||
// Pages: Welcome, Focus controls, Link Handling, Blur Settings, Notifications
|
// Pages: Welcome, Session Management, Link Handling, Blur Settings, Notifications
|
||||||
static const int _kTotalPages = 5;
|
static const int _kTotalPages = 5;
|
||||||
|
|
||||||
static const int _kBlurPage = 3;
|
static const int _kBlurPage = 3;
|
||||||
@@ -32,26 +32,26 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
final List<Widget> slides = [
|
final List<Widget> slides = [
|
||||||
// ── Page 0: Welcome ─────────────────────────────────────────────────
|
// ── Page 0: Welcome ─────────────────────────────────────────────────
|
||||||
_StaticSlide(
|
_StaticSlide(
|
||||||
icon: Icons.auto_awesome_rounded,
|
icon: Icons.auto_awesome,
|
||||||
color: const Color(0xFF4F8DFF),
|
color: Colors.blue,
|
||||||
title: 'Welcome to FocusGram',
|
title: 'Welcome to FocusGram',
|
||||||
description:
|
description:
|
||||||
'Use Instagram with guardrails: timed Reel sessions, calmer feeds, optional media tools, and privacy-first controls that stay on your device.',
|
'The distraction-free way to use Instagram. We help you stay focused by blocking Reels and Explore content.',
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Page 1: Focus controls ───────────────────────────────────────────
|
// ── Page 1: Session Management ───────────────────────────────────────
|
||||||
_StaticSlide(
|
_StaticSlide(
|
||||||
icon: Icons.timer_outlined,
|
icon: Icons.timer,
|
||||||
color: const Color(0xFFFFB74D),
|
color: Colors.orange,
|
||||||
title: 'Time With Intent',
|
title: 'Session Management',
|
||||||
description:
|
description:
|
||||||
'Set daily limits, cooldowns, scheduled focus hours, and short Reel sessions when you choose to watch.',
|
'Plan your usage. Set daily limits and use timed sessions to stay in control of your time.',
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Page 2: Open links ───────────────────────────────────────────────
|
// ── Page 2: Open links ───────────────────────────────────────────────
|
||||||
_StaticSlide(
|
_StaticSlide(
|
||||||
icon: Icons.link_rounded,
|
icon: Icons.link,
|
||||||
color: const Color(0xFF35C2D6),
|
color: Colors.cyan,
|
||||||
title: 'Open Links in FocusGram',
|
title: 'Open Links in FocusGram',
|
||||||
description:
|
description:
|
||||||
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
|
'To open Instagram links directly here: Tap "Configure", then "Open by default" → "Add link" and select all.',
|
||||||
@@ -63,11 +63,11 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
|
|
||||||
// ── Page 4: Notifications ────────────────────────────────────────────
|
// ── Page 4: Notifications ────────────────────────────────────────────
|
||||||
_StaticSlide(
|
_StaticSlide(
|
||||||
icon: Icons.notifications_active_outlined,
|
icon: Icons.notifications_active,
|
||||||
color: const Color(0xFF5DD18A),
|
color: Colors.green,
|
||||||
title: 'Useful Alerts Only',
|
title: 'Stay Notified',
|
||||||
description:
|
description:
|
||||||
'Enable notifications only if you want session-end or persistent focus reminders. FocusGram will ask here, not before onboarding.',
|
'We need notification permissions to alert you when your session is over or a new message arrives.',
|
||||||
isPermissionPage: true,
|
isPermissionPage: true,
|
||||||
permission: Permission.notification,
|
permission: Permission.notification,
|
||||||
),
|
),
|
||||||
@@ -108,7 +108,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 32),
|
||||||
// CTA button
|
// CTA button
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
@@ -123,14 +123,14 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
final isBlur = _currentPage == _kBlurPage;
|
final isBlur = _currentPage == _kBlurPage;
|
||||||
|
|
||||||
String label;
|
String label;
|
||||||
if (isNotif) {
|
if (isLast) {
|
||||||
label = 'Allow & Start';
|
label = 'Get Started';
|
||||||
} else if (isLink) {
|
} else if (isLink) {
|
||||||
label = 'Configure';
|
label = 'Configure';
|
||||||
|
} else if (isNotif) {
|
||||||
|
label = 'Allow Notifications';
|
||||||
} else if (isBlur) {
|
} else if (isBlur) {
|
||||||
label = 'Save & Continue';
|
label = 'Save & Continue';
|
||||||
} else if (isLast) {
|
|
||||||
label = 'Get Started';
|
|
||||||
} else {
|
} else {
|
||||||
label = 'Next';
|
label = 'Next';
|
||||||
}
|
}
|
||||||
@@ -143,8 +143,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
);
|
);
|
||||||
} else if (isNotif) {
|
} else if (isNotif) {
|
||||||
await Permission.notification.request();
|
await Permission.notification.request();
|
||||||
await NotificationService()
|
await NotificationService().init();
|
||||||
.requestPermissionsNow();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -179,19 +178,9 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
|||||||
// Skip button (available on all pages except last)
|
// Skip button (available on all pages except last)
|
||||||
if (_currentPage < _kTotalPages - 1)
|
if (_currentPage < _kTotalPages - 1)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () => _finish(context),
|
||||||
if (_currentPage == _kNotifPage) {
|
|
||||||
_finish(context);
|
|
||||||
} else {
|
|
||||||
_pageController.animateToPage(
|
|
||||||
_kTotalPages - 1,
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Skip setup',
|
'Skip',
|
||||||
style: TextStyle(color: Colors.white38, fontSize: 14),
|
style: TextStyle(color: Colors.white38, fontSize: 14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -233,27 +222,18 @@ class _StaticSlide extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(28, 40, 28, 160),
|
padding: const EdgeInsets.fromLTRB(40, 40, 40, 160),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Icon(icon, size: 120, color: color),
|
||||||
width: 112,
|
const SizedBox(height: 48),
|
||||||
height: 112,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: 0.14),
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
border: Border.all(color: color.withValues(alpha: 0.28)),
|
|
||||||
),
|
|
||||||
child: Icon(icon, size: 54, color: color),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 36),
|
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 30,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -263,28 +243,10 @@ class _StaticSlide extends StatelessWidget {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isPermissionPage || isAppSettingsPage) ...[
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.06),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.white10),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
isPermissionPage
|
|
||||||
? 'Permission is optional and can be changed later.'
|
|
||||||
: 'This opens Android settings; return here when done.',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,18 +104,16 @@ class _ReelPlayerOverlayState extends State<ReelPlayerOverlay> {
|
|||||||
sessionActive: true,
|
sessionActive: true,
|
||||||
blurExplore: false,
|
blurExplore: false,
|
||||||
blurReels: false,
|
blurReels: false,
|
||||||
tapToUnblur: false,
|
|
||||||
enableTextSelection: true,
|
enableTextSelection: true,
|
||||||
hideSuggestedPosts: false,
|
hideSuggestedPosts: false,
|
||||||
hideSponsoredPosts: false,
|
hideSponsoredPosts: false,
|
||||||
hideLikeCounts: false,
|
hideLikeCounts: false,
|
||||||
hideFollowerCounts: false,
|
hideFollowerCounts: false,
|
||||||
// hideStoriesBar removed per user request
|
hideStoriesBar: false,
|
||||||
hideExploreTab: false,
|
hideExploreTab: false,
|
||||||
hideReelsTab: false,
|
hideReelsTab: false,
|
||||||
hideShopTab: false,
|
hideShopTab: false,
|
||||||
disableReelsEntirely: false,
|
disableReelsEntirely: false,
|
||||||
blockHomeFeedScroll: false,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/session_manager.dart';
|
import '../services/session_manager.dart';
|
||||||
import '../services/settings_service.dart';
|
|
||||||
import '../utils/discipline_challenge.dart';
|
import '../utils/discipline_challenge.dart';
|
||||||
|
|
||||||
class SessionModal extends StatefulWidget {
|
class SessionModal extends StatefulWidget {
|
||||||
@@ -64,12 +63,12 @@ class _SessionModalState extends State<SessionModal> {
|
|||||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Row(
|
||||||
spacing: 8,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
runSpacing: 8,
|
children: [1, 5, 10, 15].map((m) {
|
||||||
children: [1, 3, 5, 10, 15, 20, 30].map((m) {
|
return Expanded(
|
||||||
return SizedBox(
|
child: Padding(
|
||||||
width: 72,
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
onPressed: (sm.isCooldownActive || sm.isDailyLimitExhausted)
|
||||||
? null
|
? null
|
||||||
@@ -81,6 +80,7 @@ class _SessionModalState extends State<SessionModal> {
|
|||||||
),
|
),
|
||||||
child: Text('${m}m'),
|
child: Text('${m}m'),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
@@ -92,8 +92,8 @@ class _SessionModalState extends State<SessionModal> {
|
|||||||
Slider(
|
Slider(
|
||||||
value: _customMinutes,
|
value: _customMinutes,
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 60,
|
max: 30,
|
||||||
divisions: 59,
|
divisions: 29,
|
||||||
label: '${_customMinutes.toInt()}m',
|
label: '${_customMinutes.toInt()}m',
|
||||||
onChanged: (v) => setState(() => _customMinutes = v),
|
onChanged: (v) => setState(() => _customMinutes = v),
|
||||||
),
|
),
|
||||||
@@ -126,15 +126,10 @@ class _SessionModalState extends State<SessionModal> {
|
|||||||
|
|
||||||
void _start(int minutes) async {
|
void _start(int minutes) async {
|
||||||
final sm = context.read<SessionManager>();
|
final sm = context.read<SessionManager>();
|
||||||
final settings = context.read<SettingsService>();
|
|
||||||
|
|
||||||
if (settings.requireWordChallenge) {
|
// Always require word challenge for reel sessions (User request)
|
||||||
final success = await DisciplineChallenge.show(
|
final success = await DisciplineChallenge.show(context);
|
||||||
context,
|
|
||||||
count: settings.resolvedWordChallengeCount(),
|
|
||||||
);
|
|
||||||
if (!success) return;
|
if (!success) return;
|
||||||
}
|
|
||||||
|
|
||||||
if (sm.startSession(minutes)) {
|
if (sm.startSession(minutes)) {
|
||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
|
|||||||
+80
-818
File diff suppressed because it is too large
Load Diff
@@ -1,225 +1,113 @@
|
|||||||
/// JavaScript to block autoplaying videos on Instagram feed/explore while:
|
/// JavaScript to block autoplaying videos on Instagram while still allowing
|
||||||
/// - Allowing videos to play normally when "Block Autoplay Videos" is OFF
|
/// explicit user-initiated playback.
|
||||||
/// - Allowing user-initiated playback on click when blocking is ON
|
|
||||||
/// - NEVER blocking reels (they should always play normally per user request)
|
|
||||||
///
|
///
|
||||||
/// This script:
|
/// This script:
|
||||||
/// - Overrides HTMLVideoElement.prototype.play before Instagram initialises.
|
/// - Overrides HTMLVideoElement.prototype.play before Instagram initialises.
|
||||||
/// - PAUSES any playing videos immediately when autoplay is blocked (only for feed/explore).
|
|
||||||
/// - Returns Promise.resolve() for blocked autoplay calls (never throws).
|
/// - Returns Promise.resolve() for blocked autoplay calls (never throws).
|
||||||
/// - Uses a per-element flag set by user clicks to permanently allow that video to play.
|
/// - Uses a short-lived per-element flag set by user clicks to allow play().
|
||||||
/// - Strips the autoplay attribute from dynamically added <video> elements.
|
/// - Strips the autoplay attribute from dynamically added <video> elements.
|
||||||
/// - Respects session state - allows autoplay when session is active.
|
|
||||||
/// - NEVER blocks reels - they always play normally.
|
|
||||||
/// - Once a video is explicitly played by user, it plays fully without interruption.
|
|
||||||
const String kAutoplayBlockerJS = r'''
|
const String kAutoplayBlockerJS = r'''
|
||||||
(function fgAutoplayBlocker() {
|
(function fgAutoplayBlocker() {
|
||||||
if (window.__fgAutoplayPatched) return;
|
if (window.__fgAutoplayPatched) return;
|
||||||
window.__fgAutoplayPatched = true;
|
window.__fgAutoplayPatched = true;
|
||||||
|
|
||||||
// Default to blocking autoplay if not set
|
// Toggleable at runtime from Flutter:
|
||||||
window.__fgBlockAutoplay = window.__fgBlockAutoplay !== false;
|
// window.__fgBlockAutoplay = true/false
|
||||||
|
if (typeof window.__fgBlockAutoplay === 'undefined') {
|
||||||
// Session state - set by FocusGram when session is active
|
window.__fgBlockAutoplay = true;
|
||||||
// window.__focusgramSessionActive = true/false
|
|
||||||
|
|
||||||
// Helper to check if this is a reel video (should NEVER be blocked)
|
|
||||||
function isReelVideo() {
|
|
||||||
try {
|
|
||||||
const url = window.location.href || '';
|
|
||||||
// Check if we're on a reel page
|
|
||||||
if (url.includes('/reels/') || url.includes('/reel/')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to check if we should allow autoplay
|
const ALLOW_KEY = '__fgAllowPlayUntil';
|
||||||
function shouldBlockAutoplay() {
|
const ALLOW_WINDOW_MS = 1000;
|
||||||
// If we're on reels page, never block
|
|
||||||
if (isReelVideo()) return false;
|
|
||||||
|
|
||||||
// If autoplay setting is false, don't block at all
|
|
||||||
if (window.__fgBlockAutoplay === false) return false;
|
|
||||||
|
|
||||||
// If session is active, don't block autoplay (allow all videos)
|
|
||||||
if (window.__focusgramSessionActive === true) return false;
|
|
||||||
|
|
||||||
// Otherwise block autoplay for feed/explore videos
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key to mark a video as explicitly started by user (permanent for that video instance)
|
|
||||||
const ALLOW_KEY = '__fgUserExplicitlyPlayed';
|
|
||||||
|
|
||||||
// Mark video as allowed permanently once user explicitly plays it
|
|
||||||
function markAllow(video) {
|
function markAllow(video) {
|
||||||
try {
|
try {
|
||||||
video[ALLOW_KEY] = true;
|
video[ALLOW_KEY] = Date.now() + ALLOW_WINDOW_MS;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has explicitly played this video
|
|
||||||
function shouldAllow(video) {
|
function shouldAllow(video) {
|
||||||
try {
|
try {
|
||||||
return video[ALLOW_KEY] === true;
|
const until = video[ALLOW_KEY] || 0;
|
||||||
|
return Date.now() <= until;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pause video and strip autoplay attribute (for blocked autoplay videos)
|
function stripAutoplay(root) {
|
||||||
function pauseAndFreezeVideo(video) {
|
|
||||||
try {
|
try {
|
||||||
// Remove autoplay attribute completely
|
if (window.__fgBlockAutoplay !== true) return;
|
||||||
video.removeAttribute('autoplay');
|
const all = root.querySelectorAll
|
||||||
try { video.autoplay = false; } catch (_) {}
|
? root.querySelectorAll('video')
|
||||||
// Pause the video
|
: (root.tagName === 'VIDEO' ? [root] : []);
|
||||||
video.pause();
|
all.forEach(v => {
|
||||||
// Reset to beginning
|
v.removeAttribute('autoplay');
|
||||||
video.currentTime = 0;
|
try { v.autoplay = false; } catch (_) {}
|
||||||
|
});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store original play and pause
|
// Initial pass
|
||||||
const _origPlay = HTMLVideoElement.prototype.play;
|
|
||||||
const _origPause = HTMLVideoElement.prototype.pause;
|
|
||||||
|
|
||||||
// Override play method
|
|
||||||
if (HTMLVideoElement.prototype.play) {
|
|
||||||
HTMLVideoElement.prototype.play = function() {
|
|
||||||
try {
|
try {
|
||||||
// NEVER block reels - they always play normally
|
document.querySelectorAll('video').forEach(v => stripAutoplay(v));
|
||||||
if (isReelVideo()) {
|
} catch (_) {}
|
||||||
return _origPlay.apply(this, arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should block based on both settings and session
|
// MutationObserver for dynamically added videos
|
||||||
if (!shouldBlockAutoplay()) {
|
|
||||||
// Autoplay is OFF or session is active - allow all playback
|
|
||||||
return _origPlay.apply(this, arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user has explicitly played this video before, allow it to continue
|
|
||||||
if (shouldAllow(this)) {
|
|
||||||
return _origPlay.apply(this, arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block autoplay: pause immediately and return resolved promise
|
|
||||||
pauseAndFreezeVideo(this);
|
|
||||||
return Promise.resolve();
|
|
||||||
} catch (_) {
|
|
||||||
// Fall back to original behaviour
|
|
||||||
try {
|
try {
|
||||||
return _origPlay.apply(this, arguments);
|
const mo = new MutationObserver(ms => {
|
||||||
} catch (_) {
|
if (window.__fgBlockAutoplay !== true) return;
|
||||||
return Promise.resolve();
|
ms.forEach(m => {
|
||||||
}
|
m.addedNodes.forEach(node => {
|
||||||
}
|
if (!node || node.nodeType !== 1) return;
|
||||||
};
|
if (node.tagName === 'VIDEO') {
|
||||||
}
|
stripAutoplay(node);
|
||||||
|
} else {
|
||||||
// Override pause method to work normally
|
stripAutoplay(node);
|
||||||
if (HTMLVideoElement.prototype.pause) {
|
|
||||||
HTMLVideoElement.prototype.pause = function() {
|
|
||||||
try {
|
|
||||||
return _origPause.apply(this, arguments);
|
|
||||||
} catch (_) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional safeguard for dynamically created videos
|
|
||||||
try {
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.querySelectorAll('video').forEach(function(v) {
|
|
||||||
if (v.play) {
|
|
||||||
const originalPlay = v.play;
|
|
||||||
v.play = function() {
|
|
||||||
// NEVER block reels
|
|
||||||
if (isReelVideo()) {
|
|
||||||
return originalPlay.apply(this, arguments);
|
|
||||||
}
|
|
||||||
if (!shouldBlockAutoplay()) {
|
|
||||||
return originalPlay.apply(this, arguments);
|
|
||||||
}
|
|
||||||
if (shouldAllow(this)) {
|
|
||||||
return originalPlay.apply(this, arguments);
|
|
||||||
}
|
|
||||||
pauseAndFreezeVideo(this);
|
|
||||||
return Promise.resolve();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
mo.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
// Also handle videos that might be created after DOMContentLoaded
|
// Allow play() shortly after a direct user click on a video.
|
||||||
try {
|
|
||||||
const originalCreateElement = document.createElement;
|
|
||||||
document.createElement = function(tagName) {
|
|
||||||
const element = originalCreateElement.apply(this, arguments);
|
|
||||||
if (tagName.toLowerCase() === 'video') {
|
|
||||||
// Intercept the play method on dynamically created videos
|
|
||||||
const originalPlay = element.play;
|
|
||||||
if (originalPlay) {
|
|
||||||
element.play = function() {
|
|
||||||
// NEVER block reels
|
|
||||||
if (isReelVideo()) {
|
|
||||||
return originalPlay.apply(this, arguments);
|
|
||||||
}
|
|
||||||
if (!shouldBlockAutoplay()) {
|
|
||||||
return originalPlay.apply(this, arguments);
|
|
||||||
}
|
|
||||||
if (shouldAllow(this)) {
|
|
||||||
return originalPlay.apply(this, arguments);
|
|
||||||
}
|
|
||||||
pauseAndFreezeVideo(this);
|
|
||||||
return Promise.resolve();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return element;
|
|
||||||
};
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
// Mark video as allowed on user interaction (click/tap) - permanent for that video
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
try {
|
try {
|
||||||
const video = e.target.closest ? e.target.closest('video') : e.target;
|
const video = e.target && e.target.closest && e.target.closest('video');
|
||||||
if (video) {
|
if (!video) return;
|
||||||
// Mark this specific video as user-initiated - permanent
|
|
||||||
markAllow(video);
|
markAllow(video);
|
||||||
// Try to play the video if it was previously blocked
|
|
||||||
if (shouldBlockAutoplay() && !shouldAllow(video)) {
|
|
||||||
// Video will be allowed now, try to play
|
|
||||||
try { video.play(); } catch (_) {}
|
try { video.play(); } catch (_) {}
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
document.addEventListener('touchstart', function(e) {
|
// Prototype override
|
||||||
try {
|
try {
|
||||||
const video = e.target.closest ? e.target.closest('video') : e.target;
|
const origPlay = HTMLVideoElement.prototype.play;
|
||||||
if (video) {
|
if (!origPlay) return;
|
||||||
markAllow(video);
|
if (!window.__fgOrigVideoPlay) window.__fgOrigVideoPlay = origPlay;
|
||||||
if (shouldBlockAutoplay() && !shouldAllow(video)) {
|
|
||||||
try { video.play(); } catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
// Also handle play events directly (for Instagram's internal play buttons)
|
HTMLVideoElement.prototype.play = function() {
|
||||||
document.addEventListener('play', function(e) {
|
try {
|
||||||
if (e.target && e.target.tagName === 'VIDEO') {
|
if (window.__fgBlockAutoplay !== true) {
|
||||||
markAllow(e.target);
|
return origPlay.apply(this, arguments);
|
||||||
}
|
}
|
||||||
}, true);
|
if (shouldAllow(this)) {
|
||||||
|
return origPlay.apply(this, arguments);
|
||||||
|
}
|
||||||
|
// Block autoplay: resolve without actually starting playback.
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (_) {
|
||||||
|
// If anything goes wrong, fall back to original behaviour to avoid
|
||||||
|
// breaking Instagram's player.
|
||||||
|
try {
|
||||||
|
return origPlay.apply(this, arguments);
|
||||||
|
} catch (_) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (_) {}
|
||||||
})();
|
})();
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
|||||||
@@ -86,144 +86,6 @@ const String kHideStoriesBarJS = r'''
|
|||||||
})();
|
})();
|
||||||
''';
|
''';
|
||||||
|
|
||||||
/// Robust stories overlay - blocks clicking and applies blur when hide stories is enabled.
|
|
||||||
/// This is a more aggressive approach that places an overlay with blur on top of stories area.
|
|
||||||
const String kStoriesOverlayJS = r'''
|
|
||||||
(function() {
|
|
||||||
if (window.__fgStoriesOverlayRunning) return;
|
|
||||||
window.__fgStoriesOverlayRunning = true;
|
|
||||||
|
|
||||||
const BLOCKED_ATTR = 'data-fg-stories-blocked';
|
|
||||||
|
|
||||||
function buildOverlay(container) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.setAttribute(BLOCKED_ATTR, '1');
|
|
||||||
div.style.cssText = [
|
|
||||||
'position: absolute',
|
|
||||||
'inset: 0',
|
|
||||||
'z-index: 99998',
|
|
||||||
'display: flex',
|
|
||||||
'align-items: center',
|
|
||||||
'justify-content: center',
|
|
||||||
'background: rgba(0, 0, 0, 0.6)',
|
|
||||||
'backdrop-filter: blur(10px)',
|
|
||||||
'-webkit-backdrop-filter: blur(10px)',
|
|
||||||
'border-radius: 8px',
|
|
||||||
'pointer-events: all',
|
|
||||||
'cursor: not-allowed',
|
|
||||||
].join(';');
|
|
||||||
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.textContent = 'Stories blocked';
|
|
||||||
label.style.cssText = [
|
|
||||||
'color: rgba(255, 255, 255, 0.8)',
|
|
||||||
'font-size: 12px',
|
|
||||||
'font-weight: 600',
|
|
||||||
'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
||||||
'text-align: center',
|
|
||||||
'padding: 8px 16px',
|
|
||||||
'background: rgba(0, 0, 0, 0.5)',
|
|
||||||
'border-radius: 20px',
|
|
||||||
].join(';');
|
|
||||||
|
|
||||||
div.appendChild(label);
|
|
||||||
|
|
||||||
// Swallow all interaction
|
|
||||||
['click', 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'mouseenter'].forEach(function(evt) {
|
|
||||||
div.addEventListener(evt, function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopImmediatePropagation();
|
|
||||||
}, { capture: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
return div;
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlayStoriesContainer(container) {
|
|
||||||
if (!container) return;
|
|
||||||
if (container.querySelector('[' + BLOCKED_ATTR + ']')) return;
|
|
||||||
|
|
||||||
// Check if this looks like a stories container
|
|
||||||
const hasStories = container.querySelector('canvas, [style*="border-radius: 50%"], [aria-label*="story"], [role="list"]');
|
|
||||||
if (!hasStories) return;
|
|
||||||
|
|
||||||
container.style.position = 'relative';
|
|
||||||
container.style.overflow = 'hidden';
|
|
||||||
container.appendChild(buildOverlay(container));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findAndOverlayStories() {
|
|
||||||
try {
|
|
||||||
// Method 1: Find by role="list" with story-related aria-label
|
|
||||||
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
|
|
||||||
try {
|
|
||||||
const label = (el.getAttribute('aria-label') || '').toLowerCase();
|
|
||||||
if (label.includes('stori')) {
|
|
||||||
overlayStoriesContainer(el.parentElement);
|
|
||||||
}
|
|
||||||
} catch(_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Method 2: Find horizontal scroll containers at top of feed
|
|
||||||
document.querySelectorAll('header + div > div, main > div > div > div').forEach(function(el) {
|
|
||||||
try {
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
if ((style.overflowX === 'scroll' || style.overflowX === 'auto') &&
|
|
||||||
(style.display === 'flex' || style.display === '')) {
|
|
||||||
const children = el.children;
|
|
||||||
let hasAvatar = false;
|
|
||||||
for (let i = 0; i < Math.min(children.length, 10); i++) {
|
|
||||||
const child = children[i];
|
|
||||||
const childStyle = window.getComputedStyle(child);
|
|
||||||
if (childStyle.width === '60px' || childStyle.width === '66px' ||
|
|
||||||
child.querySelector('canvas, [style*="border-radius: 50%"]')) {
|
|
||||||
hasAvatar = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasAvatar) {
|
|
||||||
overlayStoriesContainer(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch(_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Method 3: Find story avatars directly
|
|
||||||
document.querySelectorAll('[href*="/stories/"], [aria-label*="Your Story"]').forEach(function(el) {
|
|
||||||
try {
|
|
||||||
let container = el.parentElement;
|
|
||||||
for (let i = 0; i < 5 && container; i++) {
|
|
||||||
const style = window.getComputedStyle(container);
|
|
||||||
if (style.position !== 'static' && container.children.length < 20) {
|
|
||||||
overlayStoriesContainer(container);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
container = container.parentElement;
|
|
||||||
}
|
|
||||||
} catch(_) {}
|
|
||||||
});
|
|
||||||
} catch(_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial run
|
|
||||||
findAndOverlayStories();
|
|
||||||
|
|
||||||
// Watch for dynamic changes
|
|
||||||
let _overlayTimer = null;
|
|
||||||
new MutationObserver(function() {
|
|
||||||
clearTimeout(_overlayTimer);
|
|
||||||
_overlayTimer = setTimeout(findAndOverlayStories, 500);
|
|
||||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
// Also run on scroll
|
|
||||||
let _scrollTimer = null;
|
|
||||||
window.addEventListener('scroll', function() {
|
|
||||||
clearTimeout(_scrollTimer);
|
|
||||||
_scrollTimer = setTimeout(findAndOverlayStories, 300);
|
|
||||||
}, { passive: true });
|
|
||||||
})();
|
|
||||||
''';
|
|
||||||
|
|
||||||
const String kHideExploreTabCSS = """
|
const String kHideExploreTabCSS = """
|
||||||
a[href="/explore/"],
|
a[href="/explore/"],
|
||||||
a[href="/explore"] {
|
a[href="/explore"] {
|
||||||
@@ -451,54 +313,16 @@ const String kHideSuggestedPostsJS = r'''
|
|||||||
(function() {
|
(function() {
|
||||||
function hideSuggestedPosts() {
|
function hideSuggestedPosts() {
|
||||||
try {
|
try {
|
||||||
// Target text patterns that indicate suggested content
|
document.querySelectorAll('span, h3, h4').forEach(function(el) {
|
||||||
const suggestedPatterns = [
|
|
||||||
'Suggested for you',
|
|
||||||
'Suggested posts',
|
|
||||||
"You're all caught up",
|
|
||||||
'Suggested',
|
|
||||||
'Recommendations',
|
|
||||||
'Discover more',
|
|
||||||
'Suggested Accounts',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Find and hide all elements with suggested content text
|
|
||||||
document.querySelectorAll('span, h3, h4, h2, a').forEach(function(el) {
|
|
||||||
try {
|
try {
|
||||||
const text = el.textContent.trim();
|
const text = el.textContent.trim();
|
||||||
const matched = suggestedPatterns.some(pattern =>
|
|
||||||
text === pattern || text.includes(pattern)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matched) {
|
|
||||||
let parent = el.parentElement;
|
|
||||||
// Traverse up to find the container section/article
|
|
||||||
for (let i = 0; i < 12 && parent; i++) {
|
|
||||||
const tag = parent.tagName.toLowerCase();
|
|
||||||
const classList = parent.className || '';
|
|
||||||
|
|
||||||
// Hide articles, sections, lists, and common suggestion containers
|
|
||||||
if (
|
if (
|
||||||
tag === 'article' ||
|
text === 'Suggested for you' ||
|
||||||
tag === 'section' ||
|
text === 'Suggested posts' ||
|
||||||
tag === 'li' ||
|
text === "You're all caught up"
|
||||||
classList.includes('xjx87jv0') || // Instagram suggestion container
|
|
||||||
classList.includes('x1a8lsjc') // Reel suggestion container
|
|
||||||
) {
|
) {
|
||||||
parent.style.setProperty('display', 'none', 'important');
|
let parent = el.parentElement;
|
||||||
break;
|
for (let i = 0; i < 8 && parent; i++) {
|
||||||
}
|
|
||||||
parent = parent.parentElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch(_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also hide by attribute patterns
|
|
||||||
document.querySelectorAll('[aria-label*="Suggested"], [data-testid*="suggested"]').forEach(function(el) {
|
|
||||||
try {
|
|
||||||
let parent = el;
|
|
||||||
for (let i = 0; i < 12 && parent; i++) {
|
|
||||||
const tag = parent.tagName.toLowerCase();
|
const tag = parent.tagName.toLowerCase();
|
||||||
if (tag === 'article' || tag === 'section' || tag === 'li') {
|
if (tag === 'article' || tag === 'section' || tag === 'li') {
|
||||||
parent.style.setProperty('display', 'none', 'important');
|
parent.style.setProperty('display', 'none', 'important');
|
||||||
@@ -506,6 +330,7 @@ const String kHideSuggestedPostsJS = r'''
|
|||||||
}
|
}
|
||||||
parent = parent.parentElement;
|
parent = parent.parentElement;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
});
|
});
|
||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
|
|||||||
@@ -39,12 +39,6 @@ const String kBlurHomeFeedAndExploreCSS = '''
|
|||||||
filter: blur(20px) !important;
|
filter: blur(20px) !important;
|
||||||
transition: filter 0.15s ease !important;
|
transition: filter 0.15s ease !important;
|
||||||
}
|
}
|
||||||
/* Per-post unblur override (set by kTapToUnblurJS) */
|
|
||||||
[data-fg-unblurred="1"] img,
|
|
||||||
[data-fg-unblurred="1"] video {
|
|
||||||
filter: none !important;
|
|
||||||
-webkit-filter: none !important;
|
|
||||||
}
|
|
||||||
body[path="/"] article img:hover,
|
body[path="/"] article img:hover,
|
||||||
body[path="/"] article video:hover,
|
body[path="/"] article video:hover,
|
||||||
body[path^="/explore"] img:hover,
|
body[path^="/explore"] img:hover,
|
||||||
@@ -86,96 +80,6 @@ const String kBlurReelsCSS = '''
|
|||||||
}
|
}
|
||||||
''';
|
''';
|
||||||
|
|
||||||
/// Allows users to unblur blurred media by tapping it.
|
|
||||||
///
|
|
||||||
/// Behaviour:
|
|
||||||
/// - Only active when `window.__fgTapToUnblur === true`.
|
|
||||||
/// - Only applies on Home feed (`/`) and Explore (`/explore*`) where FocusGram blurs.
|
|
||||||
/// - First tap unblurs the post media and swallows the click (prevents opening).
|
|
||||||
/// - Subsequent taps behave normally (Instagram opens the post as usual).
|
|
||||||
const String kTapToUnblurJS = r'''
|
|
||||||
(function fgTapToUnblur() {
|
|
||||||
if (window.__fgTapToUnblurPatched) return;
|
|
||||||
window.__fgTapToUnblurPatched = true;
|
|
||||||
|
|
||||||
function isBlurContext() {
|
|
||||||
try {
|
|
||||||
const p = (document.body && document.body.getAttribute('path')) || window.location.pathname || '';
|
|
||||||
return p === '/' || p.indexOf('/explore') === 0;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMediaFromTarget(t) {
|
|
||||||
try {
|
|
||||||
if (!t) return null;
|
|
||||||
if (t.closest) {
|
|
||||||
const direct = t.closest('img,video');
|
|
||||||
if (direct) return direct;
|
|
||||||
}
|
|
||||||
// Walk up a few levels and look for a media element inside.
|
|
||||||
let n = t;
|
|
||||||
for (let i = 0; i < 6 && n; i++) {
|
|
||||||
if (n.querySelector) {
|
|
||||||
const m = n.querySelector('img,video');
|
|
||||||
if (m) return m;
|
|
||||||
}
|
|
||||||
n = n.parentElement;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHost(media) {
|
|
||||||
try {
|
|
||||||
return media.closest('article') || media.closest('a') || media.parentElement;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markUnblurred(host) {
|
|
||||||
try {
|
|
||||||
host.setAttribute('data-fg-unblurred', '1');
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUnblurred(host) {
|
|
||||||
try {
|
|
||||||
return host && host.getAttribute && host.getAttribute('data-fg-unblurred') === '1';
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unblurMedia(media) {
|
|
||||||
try {
|
|
||||||
media.style.setProperty('filter', 'none', 'important');
|
|
||||||
media.style.setProperty('-webkit-filter', 'none', 'important');
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
try {
|
|
||||||
if (window.__fgTapToUnblur !== true) return;
|
|
||||||
if (!isBlurContext()) return;
|
|
||||||
const media = findMediaFromTarget(e.target);
|
|
||||||
if (!media) return;
|
|
||||||
const host = getHost(media);
|
|
||||||
if (!host) return;
|
|
||||||
if (isUnblurred(host)) return; // allow normal Instagram behaviour
|
|
||||||
|
|
||||||
// First tap: unblur and swallow click so it doesn't open the post.
|
|
||||||
markUnblurred(host);
|
|
||||||
unblurMedia(media);
|
|
||||||
if (e.cancelable) e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
} catch (_) {}
|
|
||||||
}, true);
|
|
||||||
})();
|
|
||||||
''';
|
|
||||||
|
|
||||||
// ── JavaScript helpers ────────────────────────────────────────────────────────
|
// ── JavaScript helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Removes the "Open in App" nag banner.
|
/// Removes the "Open in App" nag banner.
|
||||||
@@ -277,15 +181,11 @@ const String kReelsMutationObserverJS = r'''
|
|||||||
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]';
|
||||||
|
|
||||||
function lockMode() {
|
function lockMode() {
|
||||||
// Lock DM reels to prevent swipe-to-next, and optionally lock the home
|
// Only lock scroll when: DM reel playing OR disableReelsEntirely enabled
|
||||||
// feed as a separate Minimal Mode control.
|
|
||||||
const isDmReel = window.location.pathname.includes('/direct/') &&
|
const isDmReel = window.location.pathname.includes('/direct/') &&
|
||||||
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
!!document.querySelector('[class*="ReelsVideoPlayer"]');
|
||||||
if (isDmReel) return 'dm_reel';
|
if (isDmReel) return 'dm_reel';
|
||||||
if (window.__fgBlockHomeFeedScroll === true &&
|
if (window.__fgDisableReelsEntirely === true) return 'disabled';
|
||||||
(window.location.pathname === '/' || window.location.pathname === '')) {
|
|
||||||
return 'home_feed';
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +240,8 @@ const String kReelsMutationObserverJS = r'''
|
|||||||
try {
|
try {
|
||||||
const mode = lockMode();
|
const mode = lockMode();
|
||||||
const hasReel = !!document.querySelector(REEL_SEL);
|
const hasReel = !!document.querySelector(REEL_SEL);
|
||||||
if ((mode === 'dm_reel' && hasReel) || mode === 'home_feed') {
|
// Apply lock for dm_reel or disabled modes when reel is present
|
||||||
|
if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) {
|
||||||
if (__fgOrigHtmlOverflow === null) {
|
if (__fgOrigHtmlOverflow === null) {
|
||||||
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
__fgOrigHtmlOverflow = document.documentElement.style.overflow || '';
|
||||||
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
__fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : '';
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ const String kDmKeyboardFixJS = r'''
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
import '../focus_settings.dart';
|
|
||||||
|
|
||||||
// Ghost Mode
|
|
||||||
const String ghostModeJS = '''
|
|
||||||
const _WS = window.WebSocket;
|
|
||||||
window.WebSocket = function(url, protocols) {
|
|
||||||
if (url.includes('edge-chat.instagram.com') ||
|
|
||||||
url.includes('gateway.instagram.com')) {
|
|
||||||
return {
|
|
||||||
send: ()=>{}, close: ()=>{},
|
|
||||||
readyState: 1,
|
|
||||||
addEventListener: ()=>{},
|
|
||||||
removeEventListener: ()=>{},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return new _WS(url, protocols);
|
|
||||||
};
|
|
||||||
window.WebSocket.prototype = _WS.prototype;
|
|
||||||
''';
|
|
||||||
|
|
||||||
// No Story Tray
|
|
||||||
const String hideStoryTrayJS = '''
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = '[data-pagelet="story_tray"] { display: none !important; }';
|
|
||||||
document.head.appendChild(style);
|
|
||||||
''';
|
|
||||||
|
|
||||||
// No Autoplay
|
|
||||||
const String noAutoplayJS = '''
|
|
||||||
document.addEventListener('play', function(e) {
|
|
||||||
if (e.target.tagName === 'VIDEO') {
|
|
||||||
e.target.pause();
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
''';
|
|
||||||
|
|
||||||
// No Reels / Explore
|
|
||||||
const String hideReelsJS = '''
|
|
||||||
const hideReels = () => {
|
|
||||||
// nav bar reels icon
|
|
||||||
document.querySelectorAll('a[href="/reels/"]').forEach(el => {
|
|
||||||
el.closest('div')?.style.setProperty('display', 'none', 'important');
|
|
||||||
});
|
|
||||||
// explore page
|
|
||||||
document.querySelectorAll('a[href="/explore/"]').forEach(el => {
|
|
||||||
el.closest('div')?.style.setProperty('display', 'none', 'important');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
new MutationObserver(hideReels).observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
|
|
||||||
hideReels();
|
|
||||||
''';
|
|
||||||
|
|
||||||
// No DMs
|
|
||||||
const String hideDMsJS = '''
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = 'a[href="/direct/inbox/"] { display: none !important; }';
|
|
||||||
document.head.appendChild(style);
|
|
||||||
''';
|
|
||||||
|
|
||||||
List<UserScript> buildUserScripts(FocusSettings settings) {
|
|
||||||
final startScripts = <String>[];
|
|
||||||
final endScripts = <String>[];
|
|
||||||
|
|
||||||
// AT_DOCUMENT_START scripts
|
|
||||||
if (settings.ghostMode) startScripts.add(ghostModeJS);
|
|
||||||
if (settings.noAutoplay) startScripts.add(noAutoplayJS);
|
|
||||||
|
|
||||||
// AT_DOCUMENT_END scripts
|
|
||||||
if (settings.noStories) endScripts.add(hideStoryTrayJS);
|
|
||||||
if (settings.noReels) endScripts.add(hideReelsJS);
|
|
||||||
if (settings.noDMs) endScripts.add(hideDMsJS);
|
|
||||||
|
|
||||||
final scripts = <UserScript>[];
|
|
||||||
if (startScripts.isNotEmpty) {
|
|
||||||
scripts.add(
|
|
||||||
UserScript(
|
|
||||||
source: startScripts.join('\n'),
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
forMainFrameOnly: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (endScripts.isNotEmpty) {
|
|
||||||
scripts.add(
|
|
||||||
UserScript(
|
|
||||||
source: endScripts.join('\n'),
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
forMainFrameOnly: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return scripts;
|
|
||||||
}
|
|
||||||
@@ -9,3 +9,4 @@ const String kHapticBridgeScript = '''
|
|||||||
}, true);
|
}, true);
|
||||||
})();
|
})();
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ const String kScrollSmoothingJS = r'''
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
})();
|
})();
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,4 @@ const String kSpaNavigationMonitorScript = '''
|
|||||||
window.addEventListener('popstate', () => notifyUrlChange());
|
window.addEventListener('popstate', () => notifyUrlChange());
|
||||||
})();
|
})();
|
||||||
''';
|
''';
|
||||||
|
|
||||||
|
|||||||
@@ -1,355 +0,0 @@
|
|||||||
/// Best-effort Instagram media downloader UI.
|
|
||||||
///
|
|
||||||
/// The script only exposes URLs already rendered in the WebView. It cannot
|
|
||||||
/// decrypt or fetch media that Instagram has not loaded, but it covers visible
|
|
||||||
/// feed posts, reels, profile avatars, and DM visual/video messages.
|
|
||||||
const String kVideoDownloadJS = r'''
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (window.__fgMediaDownloadRunning) return;
|
|
||||||
window.__fgMediaDownloadRunning = true;
|
|
||||||
|
|
||||||
const BTN_ATTR = 'data-fg-download-btn';
|
|
||||||
const URL_ATTR = 'data-fg-download-url';
|
|
||||||
const TYPE_ATTR = 'data-fg-download-type';
|
|
||||||
const MAX_PER_PASS = 60;
|
|
||||||
|
|
||||||
function text(value) {
|
|
||||||
try { return (value || '').toString(); } catch (_) { return ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHttp(value) {
|
|
||||||
const s = text(value);
|
|
||||||
return s.indexOf('https://') === 0 || s.indexOf('http://') === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanUrl(value) {
|
|
||||||
const s = text(value).trim();
|
|
||||||
if (!isHttp(s)) return null;
|
|
||||||
return s.replace(/&/g, '&');
|
|
||||||
}
|
|
||||||
|
|
||||||
function bestFromSrcset(srcset) {
|
|
||||||
const raw = text(srcset);
|
|
||||||
if (!raw) return null;
|
|
||||||
let best = null;
|
|
||||||
let bestScore = -1;
|
|
||||||
raw.split(',').forEach(function(part) {
|
|
||||||
const bits = part.trim().split(/\s+/);
|
|
||||||
const url = cleanUrl(bits[0]);
|
|
||||||
if (!url) return;
|
|
||||||
const score = parseFloat(text(bits[1]).replace(/[^\d.]/g, '')) || 1;
|
|
||||||
if (score >= bestScore) {
|
|
||||||
bestScore = score;
|
|
||||||
best = url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
function backgroundUrl(el) {
|
|
||||||
try {
|
|
||||||
const bg = window.getComputedStyle(el).backgroundImage || '';
|
|
||||||
const match = bg.match(/url\(["']?(.*?)["']?\)/);
|
|
||||||
return match ? cleanUrl(match[1]) : null;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlFromJsonishAttribute(el) {
|
|
||||||
const attrs = ['data-store', 'data-props', 'data-visualcompletion'];
|
|
||||||
for (let i = 0; i < attrs.length; i++) {
|
|
||||||
const value = text(el.getAttribute && el.getAttribute(attrs[i]));
|
|
||||||
const match = value.match(/https?:\\?\/\\?\/[^"'\s\\]+/);
|
|
||||||
if (match) return cleanUrl(match[0].replace(/\\\//g, '/'));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mediaUrl(el) {
|
|
||||||
if (!el) return null;
|
|
||||||
const tag = text(el.tagName).toLowerCase();
|
|
||||||
if (tag === 'video') {
|
|
||||||
return cleanUrl(el.currentSrc || el.src) ||
|
|
||||||
cleanUrl(el.getAttribute('src')) ||
|
|
||||||
cleanUrl(el.getAttribute('poster')) ||
|
|
||||||
firstSource(el);
|
|
||||||
}
|
|
||||||
if (tag === 'img') {
|
|
||||||
return cleanUrl(el.currentSrc || el.src) ||
|
|
||||||
bestFromSrcset(el.getAttribute('srcset')) ||
|
|
||||||
cleanUrl(el.getAttribute('src'));
|
|
||||||
}
|
|
||||||
return backgroundUrl(el) || urlFromJsonishAttribute(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstSource(video) {
|
|
||||||
try {
|
|
||||||
const sources = video.querySelectorAll('source');
|
|
||||||
for (let i = 0; i < sources.length; i++) {
|
|
||||||
const url = cleanUrl(sources[i].src || sources[i].getAttribute('src'));
|
|
||||||
if (url) return url;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeFrom(el, url) {
|
|
||||||
const tag = text(el && el.tagName).toLowerCase();
|
|
||||||
const u = text(url).toLowerCase();
|
|
||||||
if (tag === 'video' || u.indexOf('.mp4') >= 0 || u.indexOf('.m3u8') >= 0) {
|
|
||||||
return 'video';
|
|
||||||
}
|
|
||||||
return 'photo';
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeAvatar(el) {
|
|
||||||
try {
|
|
||||||
const img = el && el.tagName && el.tagName.toLowerCase() === 'img' ? el : null;
|
|
||||||
if (!img) return false;
|
|
||||||
const alt = text(img.getAttribute('alt')).toLowerCase();
|
|
||||||
const r = img.getBoundingClientRect();
|
|
||||||
const rounded =
|
|
||||||
window.getComputedStyle(img).borderRadius.indexOf('%') >= 0 ||
|
|
||||||
parseFloat(window.getComputedStyle(img).borderRadius) >= Math.min(r.width, r.height) / 3;
|
|
||||||
return r.width <= 72 && r.height <= 72 && (rounded || alt.indexOf('profile') >= 0 || alt.indexOf('avatar') >= 0);
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mediaScore(item) {
|
|
||||||
try {
|
|
||||||
const r = item.el.getBoundingClientRect();
|
|
||||||
let score = Math.max(0, r.width) * Math.max(0, r.height);
|
|
||||||
if (item.type === 'video') score += 10000000;
|
|
||||||
if (looksLikeAvatar(item.el)) score -= 10000000;
|
|
||||||
if (text(item.url).toLowerCase().indexOf('s150x150') >= 0) score -= 5000000;
|
|
||||||
return score;
|
|
||||||
} catch (_) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filename(type) {
|
|
||||||
const ext = type === 'video' ? 'mp4' : 'jpg';
|
|
||||||
return 'focusgram_' + type + '_' + Date.now() + '.' + ext;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inView(el) {
|
|
||||||
try {
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return r.width > 24 && r.height > 24 && r.bottom > 0 && r.top < window.innerHeight;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function icon() {
|
|
||||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendDownload(url, type) {
|
|
||||||
try {
|
|
||||||
if (!url || !window.flutter_inappwebview || !window.flutter_inappwebview.callHandler) return;
|
|
||||||
window.flutter_inappwebview.callHandler('FocusGramMediaDownload', JSON.stringify({
|
|
||||||
type: type,
|
|
||||||
url: url,
|
|
||||||
filename: filename(type),
|
|
||||||
}));
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeButton(url, type, mode) {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.setAttribute(BTN_ATTR, '1');
|
|
||||||
btn.setAttribute(URL_ATTR, url);
|
|
||||||
btn.setAttribute(TYPE_ATTR, type);
|
|
||||||
btn.setAttribute('aria-label', 'Download media');
|
|
||||||
btn.innerHTML = icon();
|
|
||||||
btn.style.cssText = [
|
|
||||||
'position:absolute',
|
|
||||||
'z-index:999',
|
|
||||||
'width:34px',
|
|
||||||
'height:34px',
|
|
||||||
'border-radius:10px',
|
|
||||||
'border:1px solid rgba(255,255,255,.18)',
|
|
||||||
'background:' + (mode === 'inline' ? 'transparent' : 'rgba(0,0,0,.58)'),
|
|
||||||
'color:rgba(255,255,255,.94)',
|
|
||||||
'display:flex',
|
|
||||||
'align-items:center',
|
|
||||||
'justify-content:center',
|
|
||||||
'padding:0',
|
|
||||||
'cursor:pointer',
|
|
||||||
'pointer-events:auto',
|
|
||||||
'backdrop-filter:blur(8px)',
|
|
||||||
'-webkit-backdrop-filter:blur(8px)',
|
|
||||||
].join(';');
|
|
||||||
btn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
sendDownload(btn.getAttribute(URL_ATTR), btn.getAttribute(TYPE_ATTR) || type);
|
|
||||||
}, true);
|
|
||||||
return btn;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureRelative(container) {
|
|
||||||
try {
|
|
||||||
const pos = window.getComputedStyle(container).position;
|
|
||||||
if (!pos || pos === 'static') container.style.position = 'relative';
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeNearSave(article, url, type) {
|
|
||||||
const ref = article.querySelector([
|
|
||||||
'button[aria-label*="Save" i]',
|
|
||||||
'button[aria-label*="Bookmark" i]',
|
|
||||||
'svg[aria-label*="Save" i]',
|
|
||||||
'svg[aria-label*="Bookmark" i]',
|
|
||||||
'a[href*="/save"]',
|
|
||||||
].join(','));
|
|
||||||
if (!ref) return false;
|
|
||||||
|
|
||||||
const target = ref.closest('button,a,div') || ref;
|
|
||||||
const bar = target.parentElement || article;
|
|
||||||
if (bar.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
|
|
||||||
|
|
||||||
const btn = makeButton(url, type, 'inline');
|
|
||||||
btn.style.position = 'relative';
|
|
||||||
btn.style.inset = 'auto';
|
|
||||||
btn.style.marginLeft = '8px';
|
|
||||||
btn.style.color = 'currentColor';
|
|
||||||
btn.style.border = '0';
|
|
||||||
btn.style.backdropFilter = 'none';
|
|
||||||
btn.style.webkitBackdropFilter = 'none';
|
|
||||||
try {
|
|
||||||
target.insertAdjacentElement('afterend', btn);
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeOverlay(container, url, type, where) {
|
|
||||||
if (!container || container.querySelector(':scope > [' + BTN_ATTR + '="1"]')) return true;
|
|
||||||
ensureRelative(container);
|
|
||||||
const btn = makeButton(url, type, 'overlay');
|
|
||||||
if (where === 'reel') {
|
|
||||||
btn.style.top = '12px';
|
|
||||||
btn.style.right = '12px';
|
|
||||||
} else if (where === 'profile') {
|
|
||||||
btn.style.top = '8px';
|
|
||||||
btn.style.right = '8px';
|
|
||||||
} else {
|
|
||||||
btn.style.right = '10px';
|
|
||||||
btn.style.bottom = '10px';
|
|
||||||
}
|
|
||||||
container.appendChild(btn);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function visibleMedia(root) {
|
|
||||||
return Array.prototype.slice.call(root.querySelectorAll('video,img,[style*="background-image"]'))
|
|
||||||
.filter(inView)
|
|
||||||
.map(function(el) {
|
|
||||||
const url = mediaUrl(el);
|
|
||||||
return url ? { el: el, url: url, type: typeFrom(el, url) } : null;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFeed() {
|
|
||||||
let added = 0;
|
|
||||||
document.querySelectorAll('article').forEach(function(article) {
|
|
||||||
if (added >= MAX_PER_PASS || article.querySelector('[' + BTN_ATTR + '="1"]')) return;
|
|
||||||
const media = visibleMedia(article)
|
|
||||||
.filter(function(item) { return !looksLikeAvatar(item.el); })
|
|
||||||
.sort(function(a, b) { return mediaScore(b) - mediaScore(a); })[0];
|
|
||||||
if (!media) return;
|
|
||||||
if (placeNearSave(article, media.url, media.type) ||
|
|
||||||
placeOverlay(article, media.url, media.type, 'feed')) {
|
|
||||||
added++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return added;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReels() {
|
|
||||||
let added = 0;
|
|
||||||
visibleMedia(document).forEach(function(media) {
|
|
||||||
if (added >= MAX_PER_PASS) return;
|
|
||||||
const container =
|
|
||||||
media.el.closest('[class*="ReelsVideoPlayer"]') ||
|
|
||||||
media.el.closest('article') ||
|
|
||||||
media.el.closest('[role="presentation"]') ||
|
|
||||||
media.el.parentElement;
|
|
||||||
if (placeOverlay(container, media.url, media.type, 'reel')) added++;
|
|
||||||
});
|
|
||||||
return added;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDirect() {
|
|
||||||
let added = 0;
|
|
||||||
visibleMedia(document).forEach(function(media) {
|
|
||||||
if (added >= MAX_PER_PASS) return;
|
|
||||||
const bubble =
|
|
||||||
media.el.closest('[role="button"]') ||
|
|
||||||
media.el.closest('div[style*="max-width"]') ||
|
|
||||||
media.el.closest('article') ||
|
|
||||||
media.el.parentElement;
|
|
||||||
if (placeOverlay(bubble, media.url, media.type, 'dm')) added++;
|
|
||||||
});
|
|
||||||
return added;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleProfile() {
|
|
||||||
let added = 0;
|
|
||||||
const path = window.location.pathname || '/';
|
|
||||||
if (path === '/' || path.indexOf('/explore') === 0 || path.indexOf('/direct') === 0) return 0;
|
|
||||||
document.querySelectorAll('header img,img[alt*="profile" i],img[alt*="avatar" i]').forEach(function(img) {
|
|
||||||
if (added >= 4 || !inView(img)) return;
|
|
||||||
const url = mediaUrl(img);
|
|
||||||
if (!url) return;
|
|
||||||
const r = img.getBoundingClientRect();
|
|
||||||
if (r.width < 56 && r.height < 56) return;
|
|
||||||
const container = img.closest('div') || img.parentElement;
|
|
||||||
if (placeOverlay(container, url, 'photo', 'profile')) added++;
|
|
||||||
});
|
|
||||||
return added;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pass() {
|
|
||||||
try {
|
|
||||||
const path = window.location.pathname || '/';
|
|
||||||
if (path.indexOf('/direct') === 0) {
|
|
||||||
handleDirect();
|
|
||||||
} else if (path.indexOf('/reels') === 0 || path.indexOf('/reel/') >= 0) {
|
|
||||||
handleReels();
|
|
||||||
} else {
|
|
||||||
handleFeed();
|
|
||||||
handleProfile();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timer = null;
|
|
||||||
function schedule() {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(pass, 220);
|
|
||||||
}
|
|
||||||
|
|
||||||
new MutationObserver(schedule).observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['src', 'srcset', 'style'],
|
|
||||||
});
|
|
||||||
window.addEventListener('scroll', schedule, { passive: true });
|
|
||||||
window.addEventListener('resize', schedule, { passive: true });
|
|
||||||
window.addEventListener('focus', schedule, { passive: true });
|
|
||||||
pass();
|
|
||||||
})();
|
|
||||||
''';
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
class AdblockContentBlockerData {
|
|
||||||
final List<ContentBlocker> contentBlockers;
|
|
||||||
final Set<String> blockedHosts;
|
|
||||||
final String sourceTag;
|
|
||||||
|
|
||||||
const AdblockContentBlockerData({
|
|
||||||
required this.contentBlockers,
|
|
||||||
required this.blockedHosts,
|
|
||||||
required this.sourceTag,
|
|
||||||
});
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'sourceTag': sourceTag,
|
|
||||||
'hosts': blockedHosts.toList(),
|
|
||||||
// We can’t safely serialize ContentBlocker objects; rebuild from hosts.
|
|
||||||
// contentBlockers will always be regenerated from hosts when restoring.
|
|
||||||
};
|
|
||||||
|
|
||||||
static AdblockContentBlockerData fromJson(Map<String, dynamic> json) {
|
|
||||||
final hosts =
|
|
||||||
(json['hosts'] as List?)?.whereType<String>().toSet() ?? <String>{};
|
|
||||||
return AdblockContentBlockerData(
|
|
||||||
contentBlockers: hosts
|
|
||||||
.map(
|
|
||||||
(h) => ContentBlocker(
|
|
||||||
trigger: ContentBlockerTrigger(
|
|
||||||
urlFilter: AdblockContentBlockerLoader._urlFilterForHost(h),
|
|
||||||
),
|
|
||||||
action: ContentBlockerAction(
|
|
||||||
type: ContentBlockerActionType.BLOCK,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(growable: false),
|
|
||||||
blockedHosts: hosts,
|
|
||||||
sourceTag: (json['sourceTag'] as String?) ?? 'cached',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AdblockContentBlockerLoader {
|
|
||||||
// Cache keys
|
|
||||||
static const _keyCache = 'adblock_cb_cache_v2';
|
|
||||||
static const _keyCacheUpdatedAt = 'adblock_cb_cache_updated_at_v1';
|
|
||||||
static const _keySourceCache = 'adblock_source_cache_v1';
|
|
||||||
|
|
||||||
static const _maxContentBlockerRules = 5000;
|
|
||||||
|
|
||||||
// Raw GitHub sources, intentionally split by repository sections so the app
|
|
||||||
// follows upstream changes without depending on third-party packaged mirrors.
|
|
||||||
static const _sources = <_SourceSpec>[
|
|
||||||
// uBlock Origin built-in Annoyances family:
|
|
||||||
// https://github.com/uBlockOrigin/uAssets/tree/master/filters
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'ublock_annoyances',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'ublock_annoyances_cookies',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-cookies.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'ublock_annoyances_others',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances-others.txt',
|
|
||||||
),
|
|
||||||
|
|
||||||
// EasyList network-blocking sections:
|
|
||||||
// https://github.com/easylist/easylist/tree/master/easylist
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'easylist_adservers',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_adservers.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'easylist_general_block',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_block.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'easylist_specific_block',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_specific_block.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'easylist_thirdparty',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_thirdparty.txt',
|
|
||||||
),
|
|
||||||
|
|
||||||
// AdGuard BaseFilter network-blocking sections:
|
|
||||||
// https://github.com/AdguardTeam/AdguardFilters/tree/master/BaseFilter/sections
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'adguard_base_adservers',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'adguard_base_adservers_firstparty',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/adservers_firstparty.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'adguard_base_antiadblock',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/antiadblock.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'adguard_base_cryptominers',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/cryptominers.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'adguard_base_general_url',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/general_url.txt',
|
|
||||||
),
|
|
||||||
_SourceSpec(
|
|
||||||
tag: 'adguard_base_specific',
|
|
||||||
url:
|
|
||||||
'https://raw.githubusercontent.com/AdguardTeam/AdguardFilters/master/BaseFilter/sections/specific.txt',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
Future<AdblockContentBlockerData> loadOrUpdateIfNeeded({
|
|
||||||
required bool enabled,
|
|
||||||
required SharedPreferences prefs,
|
|
||||||
int timeoutMs = 8000,
|
|
||||||
}) async {
|
|
||||||
if (!enabled) {
|
|
||||||
return const AdblockContentBlockerData(
|
|
||||||
contentBlockers: [],
|
|
||||||
blockedHosts: {},
|
|
||||||
sourceTag: 'disabled',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final cachedData = _readCachedData(prefs);
|
|
||||||
final sourceCache = _readSourceCache(prefs);
|
|
||||||
|
|
||||||
final fetchResults = await _fetchAllSources(
|
|
||||||
cache: sourceCache,
|
|
||||||
timeoutMs: timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fetchResults.isEmpty && cachedData != null) {
|
|
||||||
return cachedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
final sourceEntries = <String, _CachedSource>{...sourceCache};
|
|
||||||
for (final result in fetchResults) {
|
|
||||||
sourceEntries[result.tag] = result.source;
|
|
||||||
}
|
|
||||||
|
|
||||||
final hosts = sourceEntries.values
|
|
||||||
.expand((source) => source.hosts)
|
|
||||||
.where(_isValidHostname)
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
if (hosts.isEmpty && cachedData != null) {
|
|
||||||
return cachedData;
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = _buildData(
|
|
||||||
hosts: hosts,
|
|
||||||
sourceTag: fetchResults.any((r) => r.changed)
|
|
||||||
? 'updated-github'
|
|
||||||
: 'validated-github-cache',
|
|
||||||
);
|
|
||||||
|
|
||||||
await prefs.setString(_keyCache, jsonEncode(data.toJson()));
|
|
||||||
await prefs.setString(
|
|
||||||
_keySourceCache,
|
|
||||||
jsonEncode({
|
|
||||||
for (final entry in sourceEntries.entries) entry.key: entry.value,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await prefs.setInt(
|
|
||||||
_keyCacheUpdatedAt,
|
|
||||||
DateTime.now().millisecondsSinceEpoch,
|
|
||||||
);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
AdblockContentBlockerData? _readCachedData(SharedPreferences prefs) {
|
|
||||||
final cached = prefs.getString(_keyCache);
|
|
||||||
if (cached == null) return null;
|
|
||||||
try {
|
|
||||||
final decoded = jsonDecode(cached) as Map<String, dynamic>;
|
|
||||||
return AdblockContentBlockerData.fromJson(decoded);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, _CachedSource> _readSourceCache(SharedPreferences prefs) {
|
|
||||||
final cached = prefs.getString(_keySourceCache);
|
|
||||||
if (cached == null) return {};
|
|
||||||
try {
|
|
||||||
final decoded = jsonDecode(cached) as Map<String, dynamic>;
|
|
||||||
return decoded.map((tag, value) {
|
|
||||||
return MapEntry(
|
|
||||||
tag,
|
|
||||||
_CachedSource.fromJson(value as Map<String, dynamic>),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (_) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AdblockContentBlockerData _buildData({
|
|
||||||
required Set<String> hosts,
|
|
||||||
required String sourceTag,
|
|
||||||
}) {
|
|
||||||
final sortedHosts = hosts.toList(growable: false)..sort();
|
|
||||||
final cappedHosts = sortedHosts.take(_maxContentBlockerRules).toSet();
|
|
||||||
|
|
||||||
return AdblockContentBlockerData(
|
|
||||||
contentBlockers: cappedHosts
|
|
||||||
.map(
|
|
||||||
(h) => ContentBlocker(
|
|
||||||
trigger: ContentBlockerTrigger(urlFilter: _urlFilterForHost(h)),
|
|
||||||
action: ContentBlockerAction(
|
|
||||||
type: ContentBlockerActionType.BLOCK,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(growable: false),
|
|
||||||
blockedHosts: cappedHosts,
|
|
||||||
sourceTag: sourceTag,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<_FetchedSource>> _fetchAllSources({
|
|
||||||
required Map<String, _CachedSource> cache,
|
|
||||||
required int timeoutMs,
|
|
||||||
}) async {
|
|
||||||
final client = http.Client();
|
|
||||||
try {
|
|
||||||
final timeout = Duration(milliseconds: timeoutMs);
|
|
||||||
return Future.wait(
|
|
||||||
_sources.map(
|
|
||||||
(source) => _fetchSource(
|
|
||||||
client: client,
|
|
||||||
source: source,
|
|
||||||
cached: cache[source.tag],
|
|
||||||
timeout: timeout,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).then((results) => results.whereType<_FetchedSource>().toList());
|
|
||||||
} finally {
|
|
||||||
client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<_FetchedSource?> _fetchSource({
|
|
||||||
required http.Client client,
|
|
||||||
required _SourceSpec source,
|
|
||||||
required _CachedSource? cached,
|
|
||||||
required Duration timeout,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
final headers = <String, String>{
|
|
||||||
if (cached?.etag != null) 'If-None-Match': cached!.etag!,
|
|
||||||
if (cached?.lastModified != null)
|
|
||||||
'If-Modified-Since': cached!.lastModified!,
|
|
||||||
'User-Agent': 'FocusGram-AdblockListUpdater',
|
|
||||||
};
|
|
||||||
|
|
||||||
final res = await client
|
|
||||||
.get(Uri.parse(source.url), headers: headers)
|
|
||||||
.timeout(timeout);
|
|
||||||
|
|
||||||
if (res.statusCode == 304 && cached != null) {
|
|
||||||
return _FetchedSource(tag: source.tag, source: cached, changed: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode != 200 || res.body.isEmpty) return null;
|
|
||||||
|
|
||||||
return _FetchedSource(
|
|
||||||
tag: source.tag,
|
|
||||||
source: _CachedSource(
|
|
||||||
url: source.url,
|
|
||||||
etag: res.headers['etag'],
|
|
||||||
lastModified: res.headers['last-modified'],
|
|
||||||
hosts: parseHostsFromFilterText(res.body),
|
|
||||||
),
|
|
||||||
changed: true,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strict/strong: we only extract domain-ish entries from common uBlock/EasyList
|
|
||||||
/// syntax forms:
|
|
||||||
/// - ||example.com^
|
|
||||||
/// - ||example.com/
|
|
||||||
/// - ||example.com
|
|
||||||
///
|
|
||||||
/// We ignore all element-hiding/cosmetic rules and $ options.
|
|
||||||
@visibleForTesting
|
|
||||||
static Set<String> parseHostsFromFilterText(String raw) {
|
|
||||||
final hosts = <String>{};
|
|
||||||
|
|
||||||
for (final line in raw.split('\n')) {
|
|
||||||
final l = line.trim();
|
|
||||||
if (l.isEmpty) continue;
|
|
||||||
if (l.startsWith('!')) continue;
|
|
||||||
if (l.startsWith('@@')) continue;
|
|
||||||
|
|
||||||
// Skip comments / metadata
|
|
||||||
if (l.startsWith('[')) continue;
|
|
||||||
|
|
||||||
// Skip cosmetic element-hiding rules
|
|
||||||
if (l.contains('##') || l.contains('#@#') || l.contains(r'#$#')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// uBlock-style host anchors
|
|
||||||
if (l.startsWith('||')) {
|
|
||||||
final body = l.substring(2);
|
|
||||||
|
|
||||||
// Drop anything after a separator like '^', '/', '?', ' ' (conservative)
|
|
||||||
// e.g. "example.com^" -> "example.com"
|
|
||||||
// e.g. "example.com/" -> "example.com"
|
|
||||||
// e.g. "example.com^$third-party" -> "example.com"
|
|
||||||
final stopChars = ['^', '/', '?', '\\', '|', '\t', ' ', r'$'];
|
|
||||||
|
|
||||||
String host = body;
|
|
||||||
for (final sc in stopChars) {
|
|
||||||
final idx = host.indexOf(sc);
|
|
||||||
if (idx >= 0) host = host.substring(0, idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
host = host.trim();
|
|
||||||
|
|
||||||
// Remove leading/trailing dots
|
|
||||||
host = host
|
|
||||||
.replaceAll(RegExp(r'^\.+'), '')
|
|
||||||
.replaceAll(RegExp(r'\.+$'), '');
|
|
||||||
|
|
||||||
if (host.isEmpty) continue;
|
|
||||||
if (host.contains('*') || host.contains(',')) continue;
|
|
||||||
|
|
||||||
final normalized = host.toLowerCase();
|
|
||||||
if (!_isValidHostname(normalized)) continue;
|
|
||||||
|
|
||||||
hosts.add(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return hosts;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _urlFilterForHost(String host) {
|
|
||||||
final escaped = RegExp.escape(host);
|
|
||||||
return r'^https?://([^/?#]+\.)?'
|
|
||||||
'$escaped'
|
|
||||||
r'([/?#:].*)?$';
|
|
||||||
}
|
|
||||||
|
|
||||||
static bool _isValidHostname(String host) {
|
|
||||||
if (!host.contains('.')) return false;
|
|
||||||
if (host.length > 255) return false;
|
|
||||||
if (host.startsWith('.') || host.endsWith('.')) return false;
|
|
||||||
if (host.contains('..')) return false;
|
|
||||||
return RegExp(r'^[a-z0-9][a-z0-9.-]*[a-z0-9]$').hasMatch(host);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SourceSpec {
|
|
||||||
final String tag;
|
|
||||||
final String url;
|
|
||||||
|
|
||||||
const _SourceSpec({required this.tag, required this.url});
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FetchedSource {
|
|
||||||
final String tag;
|
|
||||||
final _CachedSource source;
|
|
||||||
final bool changed;
|
|
||||||
|
|
||||||
_FetchedSource({
|
|
||||||
required this.tag,
|
|
||||||
required this.source,
|
|
||||||
required this.changed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CachedSource {
|
|
||||||
final String url;
|
|
||||||
final String? etag;
|
|
||||||
final String? lastModified;
|
|
||||||
final Set<String> hosts;
|
|
||||||
|
|
||||||
const _CachedSource({
|
|
||||||
required this.url,
|
|
||||||
required this.etag,
|
|
||||||
required this.lastModified,
|
|
||||||
required this.hosts,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory _CachedSource.fromJson(Map<String, dynamic> json) {
|
|
||||||
return _CachedSource(
|
|
||||||
url: (json['url'] as String?) ?? '',
|
|
||||||
etag: json['etag'] as String?,
|
|
||||||
lastModified: json['lastModified'] as String?,
|
|
||||||
hosts: (json['hosts'] as List?)?.whereType<String>().toSet() ?? {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'url': url,
|
|
||||||
'etag': etag,
|
|
||||||
'lastModified': lastModified,
|
|
||||||
'hosts': hosts.toList(growable: false)..sort(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -55,17 +55,16 @@ class InjectionController {
|
|||||||
required bool sessionActive,
|
required bool sessionActive,
|
||||||
required bool blurExplore,
|
required bool blurExplore,
|
||||||
required bool blurReels,
|
required bool blurReels,
|
||||||
required bool tapToUnblur,
|
|
||||||
required bool enableTextSelection,
|
required bool enableTextSelection,
|
||||||
required bool hideSuggestedPosts,
|
required bool hideSuggestedPosts, // JS-only, handled by InjectionManager
|
||||||
required bool hideSponsoredPosts,
|
required bool hideSponsoredPosts, // JS-only, handled by InjectionManager
|
||||||
required bool hideLikeCounts,
|
required bool hideLikeCounts,
|
||||||
required bool hideFollowerCounts,
|
required bool hideFollowerCounts,
|
||||||
|
required bool hideStoriesBar,
|
||||||
required bool hideExploreTab,
|
required bool hideExploreTab,
|
||||||
required bool hideReelsTab,
|
required bool hideReelsTab,
|
||||||
required bool hideShopTab,
|
required bool hideShopTab,
|
||||||
required bool disableReelsEntirely,
|
required bool disableReelsEntirely,
|
||||||
required bool blockHomeFeedScroll,
|
|
||||||
}) {
|
}) {
|
||||||
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
final css = StringBuffer()..writeln(scripts.kGlobalUIFixesCSS);
|
||||||
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
if (!enableTextSelection) css.writeln(scripts.kDisableSelectionCSS);
|
||||||
@@ -75,12 +74,18 @@ class InjectionController {
|
|||||||
css.writeln(scripts.kHideReelsFeedContentCSS);
|
css.writeln(scripts.kHideReelsFeedContentCSS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIX: blurReels moved OUTSIDE `if (!sessionActive)`.
|
||||||
|
// Previously it was inside that block alongside display:none on the parent —
|
||||||
|
// you cannot blur children of a display:none element, making it dead code.
|
||||||
|
// Now: when sessionActive=true, reel thumbnails are blurred as friction.
|
||||||
|
// when sessionActive=false, reels are hidden anyway (blur harmless).
|
||||||
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
|
if (blurReels) css.writeln(scripts.kBlurReelsCSS);
|
||||||
|
|
||||||
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
if (blurExplore) css.writeln(scripts.kBlurHomeFeedAndExploreCSS);
|
||||||
|
|
||||||
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
if (hideLikeCounts) css.writeln(ui_hider.kHideLikeCountsCSS);
|
||||||
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
if (hideFollowerCounts) css.writeln(ui_hider.kHideFollowerCountsCSS);
|
||||||
|
if (hideStoriesBar) css.writeln(ui_hider.kHideStoriesBarCSS);
|
||||||
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
if (hideExploreTab) css.writeln(ui_hider.kHideExploreTabCSS);
|
||||||
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
if (hideReelsTab) css.writeln(ui_hider.kHideReelsTabCSS);
|
||||||
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
if (hideShopTab) css.writeln(ui_hider.kHideShopTabCSS);
|
||||||
@@ -88,14 +93,11 @@ class InjectionController {
|
|||||||
return '''
|
return '''
|
||||||
${buildSessionStateJS(sessionActive)}
|
${buildSessionStateJS(sessionActive)}
|
||||||
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
window.__fgDisableReelsEntirely = $disableReelsEntirely;
|
||||||
window.__fgBlockHomeFeedScroll = $blockHomeFeedScroll;
|
|
||||||
window.__fgTapToUnblur = $tapToUnblur;
|
|
||||||
${scripts.kTrackPathJS}
|
${scripts.kTrackPathJS}
|
||||||
${_buildMutationObserver(css.toString())}
|
${_buildMutationObserver(css.toString())}
|
||||||
${scripts.kDismissAppBannerJS}
|
${scripts.kDismissAppBannerJS}
|
||||||
${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
|
${!sessionActive ? scripts.kStrictReelsBlockJS : ''}
|
||||||
${scripts.kReelsMutationObserverJS}
|
${scripts.kReelsMutationObserverJS}
|
||||||
${tapToUnblur ? scripts.kTapToUnblurJS : ''}
|
|
||||||
${scripts.kLinkSanitizationJS}
|
${scripts.kLinkSanitizationJS}
|
||||||
${scripts.kThemeDetectorJS}
|
${scripts.kThemeDetectorJS}
|
||||||
${scripts.kBadgeMonitorJS}
|
${scripts.kBadgeMonitorJS}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'injection_controller.dart';
|
|||||||
import '../scripts/grayscale.dart' as grayscale;
|
import '../scripts/grayscale.dart' as grayscale;
|
||||||
import '../scripts/ui_hider.dart' as ui_hider;
|
import '../scripts/ui_hider.dart' as ui_hider;
|
||||||
import '../scripts/content_disabling.dart' as content_disabling;
|
import '../scripts/content_disabling.dart' as content_disabling;
|
||||||
import '../scripts/video_downloader.dart' as video_downloader;
|
|
||||||
|
|
||||||
// Core JS and CSS payloads injected into the Instagram WebView.
|
// Core JS and CSS payloads injected into the Instagram WebView.
|
||||||
//
|
//
|
||||||
@@ -387,39 +386,18 @@ const String kLinkSanitizationJS = r'''
|
|||||||
|
|
||||||
// ── InjectionManager class ─────────────────────────────────────────────────
|
// ── InjectionManager class ─────────────────────────────────────────────────
|
||||||
|
|
||||||
abstract class JsEvaluator {
|
|
||||||
Future<void> evaluateJavascript({required String source});
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WebViewJsEvaluator implements JsEvaluator {
|
|
||||||
final InAppWebViewController controller;
|
|
||||||
_WebViewJsEvaluator(this.controller);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> evaluateJavascript({required String source}) {
|
|
||||||
return controller.evaluateJavascript(source: source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class InjectionManager {
|
class InjectionManager {
|
||||||
final JsEvaluator _jsEvaluator;
|
final InAppWebViewController controller;
|
||||||
final SharedPreferences prefs;
|
final SharedPreferences prefs;
|
||||||
final SessionManager sessionManager;
|
final SessionManager sessionManager;
|
||||||
|
|
||||||
SettingsService? _settingsService;
|
SettingsService? _settingsService;
|
||||||
|
|
||||||
InjectionManager({
|
InjectionManager({
|
||||||
required InAppWebViewController controller,
|
required this.controller,
|
||||||
required this.prefs,
|
required this.prefs,
|
||||||
required this.sessionManager,
|
required this.sessionManager,
|
||||||
JsEvaluator? jsEvaluator,
|
});
|
||||||
}) : _jsEvaluator = jsEvaluator ?? _WebViewJsEvaluator(controller);
|
|
||||||
|
|
||||||
InjectionManager.forTest({
|
|
||||||
required JsEvaluator jsEvaluator,
|
|
||||||
required this.prefs,
|
|
||||||
required this.sessionManager,
|
|
||||||
}) : _jsEvaluator = jsEvaluator;
|
|
||||||
|
|
||||||
void setSettingsService(SettingsService settingsService) {
|
void setSettingsService(SettingsService settingsService) {
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
@@ -433,59 +411,50 @@ class InjectionManager {
|
|||||||
final sessionActive = sessionManager.isSessionActive;
|
final sessionActive = sessionManager.isSessionActive;
|
||||||
|
|
||||||
// Get settings values
|
// Get settings values
|
||||||
// Minimal mode controls all blocking - when enabled, it forces blur and disables
|
final blurExplore = settings.blurExplore;
|
||||||
final blurExplore = settings.blurExplore || settings.minimalModeEnabled;
|
|
||||||
final tapToUnblur = settings.tapToUnblur;
|
|
||||||
final enableTextSelection = settings.enableTextSelection;
|
final enableTextSelection = settings.enableTextSelection;
|
||||||
|
final hideSuggestedPosts = settings.hideSuggestedPosts;
|
||||||
// Per request: remove ALL “Hide Suggested Posts” behavior/UI/JS injection.
|
final hideSponsoredPosts = settings.hideSponsoredPosts;
|
||||||
final hideSuggestedPosts = false;
|
|
||||||
final hideLikeCounts = settings.hideLikeCounts;
|
final hideLikeCounts = settings.hideLikeCounts;
|
||||||
final hideFollowerCounts = settings.hideFollowerCounts;
|
final hideFollowerCounts = settings.hideFollowerCounts;
|
||||||
// Tab hiding is controlled by disableExploreEntirely and disableReelsEntirely
|
final hideExploreTab = settings.hideExploreTab;
|
||||||
// These are now only controllable via minimal mode submenu
|
final hideReelsTab = settings.hideReelsTab;
|
||||||
final disableExploreEntirely = settings.disableExploreEntirely;
|
|
||||||
final disableReelsEntirely = settings.disableReelsEntirely;
|
|
||||||
final blockHomeFeedScroll = settings.blockHomeFeedScroll;
|
|
||||||
final hideExploreTab = disableExploreEntirely;
|
|
||||||
final hideReelsTab = disableReelsEntirely;
|
|
||||||
final hideShopTab = settings.hideShopTab;
|
final hideShopTab = settings.hideShopTab;
|
||||||
|
final disableReelsEntirely = settings.disableReelsEntirely;
|
||||||
|
final isGrayscaleActive = settings.isGrayscaleActiveNow;
|
||||||
|
|
||||||
final injectionJS = InjectionController.buildInjectionJS(
|
final injectionJS = InjectionController.buildInjectionJS(
|
||||||
sessionActive: sessionActive,
|
sessionActive: sessionActive,
|
||||||
blurExplore: blurExplore,
|
blurExplore: blurExplore,
|
||||||
blurReels: false, // Blur reels feature removed
|
blurReels: false, // Blur reels feature removed
|
||||||
tapToUnblur: blurExplore && tapToUnblur,
|
|
||||||
enableTextSelection: enableTextSelection,
|
enableTextSelection: enableTextSelection,
|
||||||
hideSuggestedPosts: hideSuggestedPosts,
|
hideSuggestedPosts: hideSuggestedPosts,
|
||||||
hideSponsoredPosts: false, // Removed - using V2 DOM Ad Blocker instead
|
hideSponsoredPosts: hideSponsoredPosts,
|
||||||
hideLikeCounts: hideLikeCounts,
|
hideLikeCounts: hideLikeCounts,
|
||||||
hideFollowerCounts: hideFollowerCounts,
|
hideFollowerCounts: hideFollowerCounts,
|
||||||
|
hideStoriesBar: false, // Story blocking removed
|
||||||
hideExploreTab: hideExploreTab,
|
hideExploreTab: hideExploreTab,
|
||||||
hideReelsTab: hideReelsTab,
|
hideReelsTab: hideReelsTab,
|
||||||
hideShopTab: hideShopTab,
|
hideShopTab: hideShopTab,
|
||||||
disableReelsEntirely: disableReelsEntirely,
|
disableReelsEntirely: disableReelsEntirely,
|
||||||
blockHomeFeedScroll: blockHomeFeedScroll,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _jsEvaluator.evaluateJavascript(source: injectionJS);
|
await controller.evaluateJavascript(source: injectionJS);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject grayscale when active, remove when not active
|
// Inject grayscale when active, remove when not active
|
||||||
if (settings.isGrayscaleActiveNow) {
|
if (isGrayscaleActive) {
|
||||||
try {
|
try {
|
||||||
await _jsEvaluator.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
await controller.evaluateJavascript(source: grayscale.kGrayscaleJS);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await _jsEvaluator.evaluateJavascript(
|
await controller.evaluateJavascript(source: grayscale.kGrayscaleOffJS);
|
||||||
source: grayscale.kGrayscaleOffJS,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
}
|
}
|
||||||
@@ -494,22 +463,28 @@ class InjectionManager {
|
|||||||
// Inject hide like counts JS when enabled
|
// Inject hide like counts JS when enabled
|
||||||
if (hideLikeCounts) {
|
if (hideLikeCounts) {
|
||||||
try {
|
try {
|
||||||
await _jsEvaluator.evaluateJavascript(
|
await controller.evaluateJavascript(source: ui_hider.kHideLikeCountsJS);
|
||||||
source: ui_hider.kHideLikeCountsJS,
|
} catch (e) {
|
||||||
|
// Silently handle injection errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject hide suggested posts JS when enabled
|
||||||
|
if (hideSuggestedPosts) {
|
||||||
|
try {
|
||||||
|
await controller.evaluateJavascript(
|
||||||
|
source: ui_hider.kHideSuggestedPostsJS,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stories hiding functionality removed per user request
|
// Inject hide sponsored posts JS when enabled
|
||||||
// No stories overlay injection needed
|
if (hideSponsoredPosts) {
|
||||||
|
|
||||||
// Inject video downloader UI when enabled
|
|
||||||
if (settings.videoDownloadEnabled) {
|
|
||||||
try {
|
try {
|
||||||
await _jsEvaluator.evaluateJavascript(
|
await controller.evaluateJavascript(
|
||||||
source: video_downloader.kVideoDownloadJS,
|
source: ui_hider.kHideSponsoredPostsJS,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently handle injection errors
|
// Silently handle injection errors
|
||||||
@@ -519,7 +494,7 @@ class InjectionManager {
|
|||||||
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
// Inject DM Reel blocker when disableReelsEntirely is enabled
|
||||||
if (disableReelsEntirely) {
|
if (disableReelsEntirely) {
|
||||||
try {
|
try {
|
||||||
await _jsEvaluator.evaluateJavascript(
|
await controller.evaluateJavascript(
|
||||||
source: content_disabling.kDmReelBlockerJS,
|
source: content_disabling.kDmReelBlockerJS,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ class NotificationService {
|
|||||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
Future<void> init({bool requestPermissions = false}) async {
|
Future<void> init() async {
|
||||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
const AndroidInitializationSettings initializationSettingsAndroid =
|
||||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||||
|
|
||||||
// Request permissions for iOS
|
// Request permissions for iOS
|
||||||
final DarwinInitializationSettings initializationSettingsIOS =
|
final DarwinInitializationSettings initializationSettingsIOS =
|
||||||
DarwinInitializationSettings(
|
DarwinInitializationSettings(
|
||||||
requestAlertPermission: requestPermissions,
|
requestAlertPermission: true,
|
||||||
requestBadgePermission: requestPermissions,
|
requestBadgePermission: true,
|
||||||
requestSoundPermission: requestPermissions,
|
requestSoundPermission: true,
|
||||||
defaultPresentAlert: true,
|
defaultPresentAlert: true,
|
||||||
defaultPresentBadge: true,
|
defaultPresentBadge: true,
|
||||||
defaultPresentSound: true,
|
defaultPresentSound: true,
|
||||||
@@ -37,12 +37,7 @@ class NotificationService {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (requestPermissions) {
|
// Request permissions after initialization
|
||||||
await requestPermissionsNow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> requestPermissionsNow() async {
|
|
||||||
await _requestIOSPermissions();
|
await _requestIOSPermissions();
|
||||||
await _requestAndroidPermissions();
|
await _requestAndroidPermissions();
|
||||||
}
|
}
|
||||||
@@ -108,66 +103,4 @@ class NotificationService {
|
|||||||
debugPrint('Notification error: $e');
|
debugPrint('Notification error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a persistent (ongoing) notification that cannot be dismissed by the user
|
|
||||||
Future<void> showPersistentNotification({
|
|
||||||
required int id,
|
|
||||||
required String title,
|
|
||||||
required String body,
|
|
||||||
}) async {
|
|
||||||
const AndroidNotificationDetails androidDetails =
|
|
||||||
AndroidNotificationDetails(
|
|
||||||
'focusgram_persistent_channel',
|
|
||||||
'FocusGram Persistent',
|
|
||||||
channelDescription: 'Persistent notification while using FocusGram',
|
|
||||||
importance: Importance.max,
|
|
||||||
priority: Priority.high,
|
|
||||||
ongoing: true,
|
|
||||||
autoCancel: false,
|
|
||||||
showWhen: true,
|
|
||||||
playSound: false,
|
|
||||||
enableVibration: false,
|
|
||||||
category: AndroidNotificationCategory.service,
|
|
||||||
);
|
|
||||||
|
|
||||||
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
|
|
||||||
presentAlert: false,
|
|
||||||
presentBadge: false,
|
|
||||||
presentSound: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
const NotificationDetails platformDetails = NotificationDetails(
|
|
||||||
android: androidDetails,
|
|
||||||
iOS: iosDetails,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _notificationsPlugin.show(
|
|
||||||
id: id,
|
|
||||||
title: title,
|
|
||||||
body: body,
|
|
||||||
notificationDetails: platformDetails,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Persistent notification error: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancels a persistent notification
|
|
||||||
Future<void> cancelPersistentNotification({required int id}) async {
|
|
||||||
try {
|
|
||||||
await _notificationsPlugin.cancel(id: id);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Cancel persistent notification error: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancels all notifications
|
|
||||||
Future<void> cancelAllNotifications() async {
|
|
||||||
try {
|
|
||||||
await _notificationsPlugin.cancelAll();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Cancel all notifications error: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
class RemotePopupData {
|
|
||||||
final bool show;
|
|
||||||
final String id;
|
|
||||||
final String title;
|
|
||||||
final String body;
|
|
||||||
final int maxShows;
|
|
||||||
final String buttonText;
|
|
||||||
|
|
||||||
RemotePopupData({
|
|
||||||
required this.show,
|
|
||||||
required this.id,
|
|
||||||
required this.title,
|
|
||||||
required this.body,
|
|
||||||
required this.maxShows,
|
|
||||||
required this.buttonText,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory RemotePopupData.fromJson(Map<String, dynamic> json) {
|
|
||||||
return RemotePopupData(
|
|
||||||
show: json['show'] ?? false,
|
|
||||||
id: json['id']?.toString() ?? '',
|
|
||||||
title: json['header']?.toString() ?? 'Notice',
|
|
||||||
body: json['body']?.toString() ?? '',
|
|
||||||
maxShows: json['max_shows'] ?? 1,
|
|
||||||
buttonText: json['button_text']?.toString() ?? 'OK',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RemotePopupService {
|
|
||||||
// Keep placeholder value until you replace it.
|
|
||||||
static const String popupUrl =
|
|
||||||
'https://raw.githubusercontent.com/Ujwal223/FocusGram/refs/heads/main/android/popup.json';
|
|
||||||
|
|
||||||
static Future<RemotePopupData?> fetchPopup() async {
|
|
||||||
try {
|
|
||||||
// Cache-busting to avoid stale popup configs from GitHub raw URLs.
|
|
||||||
final uri = Uri.parse(
|
|
||||||
'$popupUrl?t=${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
);
|
|
||||||
|
|
||||||
final response = await http.get(
|
|
||||||
uri,
|
|
||||||
headers: const {
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.statusCode != 200) return null;
|
|
||||||
|
|
||||||
final decoded = jsonDecode(response.body);
|
|
||||||
if (decoded is! Map<String, dynamic>) return null;
|
|
||||||
|
|
||||||
return RemotePopupData.fromJson(decoded);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<bool> shouldShow(RemotePopupData data) async {
|
|
||||||
if (!data.show) return false;
|
|
||||||
if (data.id.isEmpty) return false;
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final key = 'popup_count_${data.id}';
|
|
||||||
final shownCount = prefs.getInt(key) ?? 0;
|
|
||||||
|
|
||||||
return shownCount < data.maxShows;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> markShown(RemotePopupData data) async {
|
|
||||||
if (data.id.isEmpty) return;
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final key = 'popup_count_${data.id}';
|
|
||||||
final current = prefs.getInt(key) ?? 0;
|
|
||||||
await prefs.setInt(key, current + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
///
|
///
|
||||||
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
/// Storage format (in SharedPreferences, key `screen_time_data`):
|
||||||
/// {
|
/// {
|
||||||
/// "2026-05-26": 3420, // seconds
|
/// "2026-02-26": 3420, // seconds
|
||||||
/// "2026-05-25": 1800
|
/// "2026-02-25": 1800
|
||||||
/// }
|
/// }
|
||||||
///
|
///
|
||||||
/// All data stays on-device only.
|
/// All data stays on-device only.
|
||||||
@@ -22,8 +22,6 @@ class ScreenTimeService extends ChangeNotifier {
|
|||||||
bool _tracking = false;
|
bool _tracking = false;
|
||||||
|
|
||||||
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
Map<String, int> get secondsByDate => Map.unmodifiable(_secondsByDate);
|
||||||
int get totalSeconds =>
|
|
||||||
_secondsByDate.values.fold<int>(0, (total, seconds) => total + seconds);
|
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
_prefs = await SharedPreferences.getInstance();
|
_prefs = await SharedPreferences.getInstance();
|
||||||
@@ -39,7 +37,9 @@ class ScreenTimeService extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(raw);
|
final decoded = jsonDecode(raw);
|
||||||
if (decoded is Map<String, dynamic>) {
|
if (decoded is Map<String, dynamic>) {
|
||||||
_secondsByDate = decoded.map((k, v) => MapEntry(k, (v as num).toInt()));
|
_secondsByDate = decoded.map(
|
||||||
|
(k, v) => MapEntry(k, (v as num).toInt()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_secondsByDate = {};
|
_secondsByDate = {};
|
||||||
@@ -104,3 +104,4 @@ class ScreenTimeService extends ChangeNotifier {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ class SessionManager extends ChangeNotifier {
|
|||||||
static const _keyAppSessionEnd = 'app_sess_end_ts';
|
static const _keyAppSessionEnd = 'app_sess_end_ts';
|
||||||
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
|
static const _keyAppSessionExtUsed = 'app_sess_ext_used';
|
||||||
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
|
static const _keyLastAppSessEnd = 'app_sess_last_end_ts';
|
||||||
static const _keyLastAppSessionMinutes = 'app_sess_last_minutes';
|
|
||||||
static const _keyDailyOpenCount = 'app_open_count';
|
static const _keyDailyOpenCount = 'app_open_count';
|
||||||
static const _keyScheduleEnabled = 'sched_enabled';
|
static const _keyScheduleEnabled = 'sched_enabled';
|
||||||
static const _keyScheduleStartHour = 'sched_start_h';
|
static const _keyScheduleStartHour = 'sched_start_h';
|
||||||
@@ -82,7 +81,6 @@ class SessionManager extends ChangeNotifier {
|
|||||||
bool _appSessionExpiredFlag =
|
bool _appSessionExpiredFlag =
|
||||||
false; // set when time runs out, waiting for user action
|
false; // set when time runs out, waiting for user action
|
||||||
int _dailyOpenCount = 0;
|
int _dailyOpenCount = 0;
|
||||||
int _lastAppSessionMinutes = 5;
|
|
||||||
|
|
||||||
// ── Scheduled Blocking runtime ─────────────────────────────
|
// ── Scheduled Blocking runtime ─────────────────────────────
|
||||||
bool _scheduleEnabled = false;
|
bool _scheduleEnabled = false;
|
||||||
@@ -92,10 +90,6 @@ class SessionManager extends ChangeNotifier {
|
|||||||
int _schedEndMin = 0;
|
int _schedEndMin = 0;
|
||||||
List<FocusSchedule> _schedules = [];
|
List<FocusSchedule> _schedules = [];
|
||||||
bool _lastScheduleState = false;
|
bool _lastScheduleState = false;
|
||||||
bool _scheduleNotificationShown =
|
|
||||||
false; // Track if schedule notification was shown
|
|
||||||
bool _sessionEndNotificationShown =
|
|
||||||
true; // Default to true to prevent notification on app startup (will be reset when new session starts)
|
|
||||||
|
|
||||||
bool _isInForeground = true; // Tracking app lifecycle state
|
bool _isInForeground = true; // Tracking app lifecycle state
|
||||||
int _cachedRemainingSessionSeconds = 0;
|
int _cachedRemainingSessionSeconds = 0;
|
||||||
@@ -179,7 +173,6 @@ class SessionManager extends ChangeNotifier {
|
|||||||
|
|
||||||
/// How many times the user has opened the app today.
|
/// How many times the user has opened the app today.
|
||||||
int get dailyOpenCount => _dailyOpenCount;
|
int get dailyOpenCount => _dailyOpenCount;
|
||||||
int get lastAppSessionMinutes => _lastAppSessionMinutes;
|
|
||||||
|
|
||||||
// ── Scheduled Blocking Getters ─────────────────────────────
|
// ── Scheduled Blocking Getters ─────────────────────────────
|
||||||
bool get scheduleEnabled => _scheduleEnabled;
|
bool get scheduleEnabled => _scheduleEnabled;
|
||||||
@@ -299,8 +292,7 @@ class SessionManager extends ChangeNotifier {
|
|||||||
_sessionExpiry = expiry;
|
_sessionExpiry = expiry;
|
||||||
_isSessionActive = true;
|
_isSessionActive = true;
|
||||||
} else {
|
} else {
|
||||||
// Don't show notification for expired sessions from previous app session
|
_cleanupExpiredReelSession();
|
||||||
_cleanupExpiredReelSession(showNotification: false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final lastEndMs = _prefs!.getInt(_keyLastSessionEnd) ?? 0;
|
final lastEndMs = _prefs!.getInt(_keyLastSessionEnd) ?? 0;
|
||||||
@@ -314,7 +306,6 @@ class SessionManager extends ChangeNotifier {
|
|||||||
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
|
_appSessionEnd = DateTime.fromMillisecondsSinceEpoch(appEndMs);
|
||||||
}
|
}
|
||||||
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
|
_appExtensionUsed = _prefs!.getBool(_keyAppSessionExtUsed) ?? false;
|
||||||
_lastAppSessionMinutes = _prefs!.getInt(_keyLastAppSessionMinutes) ?? 5;
|
|
||||||
|
|
||||||
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
|
final lastAppEndMs = _prefs!.getInt(_keyLastAppSessEnd) ?? 0;
|
||||||
if (lastAppEndMs > 0) {
|
if (lastAppEndMs > 0) {
|
||||||
@@ -368,26 +359,23 @@ class SessionManager extends ChangeNotifier {
|
|||||||
// and update expiry ONLY when in foreground.
|
// and update expiry ONLY when in foreground.
|
||||||
|
|
||||||
if (remainingSessionSeconds <= 0) {
|
if (remainingSessionSeconds <= 0) {
|
||||||
// Only cleanup if session was actually active and has expired naturally
|
_cleanupExpiredReelSession();
|
||||||
_cleanupExpiredReelSession(showNotification: true);
|
|
||||||
changed = true;
|
changed = true;
|
||||||
} else {
|
} else {
|
||||||
_dailyUsedSeconds++;
|
_dailyUsedSeconds++;
|
||||||
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
|
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
|
||||||
if (isDailyLimitExhausted) {
|
if (isDailyLimitExhausted) _cleanupExpiredReelSession();
|
||||||
_cleanupExpiredReelSession(showNotification: true);
|
|
||||||
}
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// App session countdown / expiry check
|
// App session expiry check
|
||||||
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
|
if (_appSessionEnd != null && !_appSessionExpiredFlag) {
|
||||||
if (DateTime.now().isAfter(_appSessionEnd!)) {
|
if (DateTime.now().isAfter(_appSessionEnd!)) {
|
||||||
_appSessionExpiredFlag = true;
|
_appSessionExpiredFlag = true;
|
||||||
}
|
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isCooldownActive) {
|
if (isCooldownActive) {
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -402,49 +390,24 @@ class SessionManager extends ChangeNotifier {
|
|||||||
if (sched != _lastScheduleState) {
|
if (sched != _lastScheduleState) {
|
||||||
_lastScheduleState = sched;
|
_lastScheduleState = sched;
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|
||||||
// Show notification when schedule becomes active
|
|
||||||
if (sched && !_scheduleNotificationShown) {
|
|
||||||
_scheduleNotificationShown = true;
|
|
||||||
NotificationService().showNotification(
|
|
||||||
id: 1001,
|
|
||||||
title: 'FocusGram Schedule Active',
|
|
||||||
body: 'Instagram is blocked according to your schedule.',
|
|
||||||
);
|
|
||||||
} else if (!sched) {
|
|
||||||
_scheduleNotificationShown = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changed) notifyListeners();
|
if (changed) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cleanupExpiredReelSession({bool showNotification = true}) {
|
void _cleanupExpiredReelSession() {
|
||||||
// Only show notification if we haven't already shown one for this session
|
|
||||||
// and the user has enabled session end notifications
|
|
||||||
// The showNotification parameter should be false when cleaning up on app startup
|
|
||||||
// (i.e., when loading an expired session from a previous app session)
|
|
||||||
if (showNotification && !_sessionEndNotificationShown) {
|
|
||||||
_sessionEndNotificationShown = true;
|
|
||||||
|
|
||||||
// Check if user wants session end notifications
|
|
||||||
final notifySessionEnd =
|
|
||||||
_prefs?.getBool('set_notify_session_end') ?? false;
|
|
||||||
|
|
||||||
if (notifySessionEnd) {
|
|
||||||
NotificationService().showNotification(
|
|
||||||
id: 999,
|
|
||||||
title: 'Session Ended',
|
|
||||||
body: 'Your Reel session has expired. Time to focus!',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_isSessionActive = false;
|
_isSessionActive = false;
|
||||||
_sessionExpiry = null;
|
_sessionExpiry = null;
|
||||||
_lastSessionEnd = DateTime.now();
|
_lastSessionEnd = DateTime.now();
|
||||||
_prefs?.setInt(_keySessionExpiry, 0);
|
_prefs?.setInt(_keySessionExpiry, 0);
|
||||||
_prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch);
|
_prefs?.setInt(_keyLastSessionEnd, _lastSessionEnd!.millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
// Alert User
|
||||||
|
NotificationService().showNotification(
|
||||||
|
id: 999,
|
||||||
|
title: 'Session Ended',
|
||||||
|
body: 'Your Reel session has expired. Time to focus!',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reel session API ───────────────────────────────────────
|
// ── Reel session API ───────────────────────────────────────
|
||||||
@@ -455,8 +418,6 @@ class SessionManager extends ChangeNotifier {
|
|||||||
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
|
final allowed = (minutes * 60).clamp(0, dailyRemainingSeconds);
|
||||||
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
|
_sessionExpiry = DateTime.now().add(Duration(seconds: allowed));
|
||||||
_isSessionActive = true;
|
_isSessionActive = true;
|
||||||
_sessionEndNotificationShown =
|
|
||||||
false; // Reset notification flag for new session
|
|
||||||
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
|
_prefs?.setInt(_keySessionExpiry, _sessionExpiry!.millisecondsSinceEpoch);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
@@ -464,8 +425,7 @@ class SessionManager extends ChangeNotifier {
|
|||||||
|
|
||||||
void endSession() {
|
void endSession() {
|
||||||
if (!_isSessionActive) return;
|
if (!_isSessionActive) return;
|
||||||
// Don't show notification when user manually ends the session
|
_cleanupExpiredReelSession();
|
||||||
_cleanupExpiredReelSession(showNotification: false);
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,10 +435,7 @@ class SessionManager extends ChangeNotifier {
|
|||||||
_dailyLimitSeconds,
|
_dailyLimitSeconds,
|
||||||
);
|
);
|
||||||
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
|
_prefs?.setInt(_keyDailyUsedSeconds, _dailyUsedSeconds);
|
||||||
if (isDailyLimitExhausted && _isSessionActive) {
|
if (isDailyLimitExhausted && _isSessionActive) _cleanupExpiredReelSession();
|
||||||
// Daily limit exhausted - show notification
|
|
||||||
_cleanupExpiredReelSession(showNotification: true);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,10 +447,8 @@ class SessionManager extends ChangeNotifier {
|
|||||||
_appSessionEnd = end;
|
_appSessionEnd = end;
|
||||||
_appSessionExpiredFlag = false;
|
_appSessionExpiredFlag = false;
|
||||||
_appExtensionUsed = false;
|
_appExtensionUsed = false;
|
||||||
_lastAppSessionMinutes = minutes;
|
|
||||||
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
|
_prefs?.setInt(_keyAppSessionEnd, end.millisecondsSinceEpoch);
|
||||||
_prefs?.setBool(_keyAppSessionExtUsed, false);
|
_prefs?.setBool(_keyAppSessionExtUsed, false);
|
||||||
_prefs?.setInt(_keyLastAppSessionMinutes, minutes);
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+100
-534
@@ -1,20 +1,13 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'notification_service.dart';
|
|
||||||
|
|
||||||
/// Stores and retrieves all user-configurable app settings.
|
/// Stores and retrieves all user-configurable app settings.
|
||||||
class SettingsService extends ChangeNotifier {
|
class SettingsService extends ChangeNotifier {
|
||||||
static const _keyBlurExplore = 'set_blur_explore';
|
static const _keyBlurExplore = 'set_blur_explore';
|
||||||
static const _keyBlurReels = 'set_blur_reels';
|
static const _keyBlurReels = 'set_blur_reels';
|
||||||
static const _keyTapToUnblur = 'set_tap_to_unblur';
|
static const _keyRequireLongPress = 'set_require_long_press';
|
||||||
static const _keyShowBreathGate = 'set_show_breath_gate';
|
static const _keyShowBreathGate = 'set_show_breath_gate';
|
||||||
static const _keyRequireWordChallenge = 'set_require_word_challenge';
|
static const _keyRequireWordChallenge = 'set_require_word_challenge';
|
||||||
static const _keyRequireLongPress = 'set_require_long_press';
|
|
||||||
static const _keyBreathGateSeconds = 'breath_gate_seconds';
|
|
||||||
static const _keyWordChallengeCount = 'word_challenge_count';
|
|
||||||
static const _keyEnableTextSelection = 'set_enable_text_selection';
|
static const _keyEnableTextSelection = 'set_enable_text_selection';
|
||||||
static const _keyEnabledTabs = 'set_enabled_tabs';
|
static const _keyEnabledTabs = 'set_enabled_tabs';
|
||||||
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
static const _keyShowInstaSettings = 'set_show_insta_settings';
|
||||||
@@ -23,43 +16,26 @@ class SettingsService extends ChangeNotifier {
|
|||||||
// Focus / playback
|
// Focus / playback
|
||||||
static const _keyBlockAutoplay = 'block_autoplay';
|
static const _keyBlockAutoplay = 'block_autoplay';
|
||||||
|
|
||||||
// Extras (Phase 2)
|
// Grayscale mode
|
||||||
static const _keyVideoDownloadEnabled = 'video_download_enabled';
|
|
||||||
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
|
|
||||||
|
|
||||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
|
||||||
static const _keyV2GhostModeEnabled = 'v2_ghost_mode_enabled';
|
|
||||||
static const _keyV2AdBlockerDomEnabled = 'v2_adblock_dom_enabled';
|
|
||||||
static const _keyV2ContentHiderEnabled = 'v2_content_hider_enabled';
|
|
||||||
|
|
||||||
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
|
|
||||||
static const _keyContentStories = 'content_stories';
|
|
||||||
static const _keyContentPosts = 'content_posts';
|
|
||||||
static const _keyContentReels = 'content_reels';
|
|
||||||
static const _keyContentSuggested = 'content_suggested';
|
|
||||||
|
|
||||||
// Grayscale mode - now supports multiple schedules
|
|
||||||
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
static const _keyGrayscaleEnabled = 'grayscale_enabled';
|
||||||
static const _keyGrayscaleSchedules = 'grayscale_schedules';
|
static const _keyGrayscaleScheduleEnabled = 'grayscale_schedule_enabled';
|
||||||
|
static const _keyGrayscaleScheduleTime = 'grayscale_schedule_time';
|
||||||
|
|
||||||
// Content filtering / UI hiding
|
// Content filtering / UI hiding
|
||||||
|
static const _keyHideSuggestedPosts = 'hide_suggested_posts';
|
||||||
|
static const _keyHideSponsoredPosts = 'hide_sponsored_posts';
|
||||||
static const _keyHideLikeCounts = 'hide_like_counts';
|
static const _keyHideLikeCounts = 'hide_like_counts';
|
||||||
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
static const _keyHideFollowerCounts = 'hide_follower_counts';
|
||||||
|
static const _keyHideStoriesBar = 'hide_stories_bar';
|
||||||
|
static const _keyHideExploreTab = 'hide_explore_tab';
|
||||||
|
static const _keyHideReelsTab = 'hide_reels_tab';
|
||||||
static const _keyHideShopTab = 'hide_shop_tab';
|
static const _keyHideShopTab = 'hide_shop_tab';
|
||||||
|
|
||||||
// Minimal mode
|
// Complete section disabling / Minimal mode
|
||||||
|
static const _keyDisableReelsEntirely = 'disable_reels_entirely';
|
||||||
|
static const _keyDisableExploreEntirely = 'disable_explore_entirely';
|
||||||
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
static const _keyMinimalModeEnabled = 'minimal_mode_enabled';
|
||||||
|
|
||||||
// Minimal mode state tracking for smart restore
|
|
||||||
static const _keyMinimalModePrevDisableReels =
|
|
||||||
'minimal_mode_prev_disable_reels';
|
|
||||||
static const _keyMinimalModePrevDisableExplore =
|
|
||||||
'minimal_mode_prev_disable_explore';
|
|
||||||
static const _keyMinimalModePrevBlurExplore =
|
|
||||||
'minimal_mode_prev_blur_explore';
|
|
||||||
static const _keyMinimalModePrevBlockHomeFeedScroll =
|
|
||||||
'minimal_mode_prev_block_home_feed_scroll';
|
|
||||||
|
|
||||||
// Reels History
|
// Reels History
|
||||||
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
static const _keyReelsHistoryEnabled = 'reels_history_enabled';
|
||||||
|
|
||||||
@@ -68,67 +44,37 @@ class SettingsService extends ChangeNotifier {
|
|||||||
static const _keyNotifyDMs = 'set_notify_dms';
|
static const _keyNotifyDMs = 'set_notify_dms';
|
||||||
static const _keyNotifyActivity = 'set_notify_activity';
|
static const _keyNotifyActivity = 'set_notify_activity';
|
||||||
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
static const _keyNotifySessionEnd = 'set_notify_session_end';
|
||||||
static const _keyNotifyPersistent = 'set_notify_persistent';
|
|
||||||
|
|
||||||
// Focus mode settings
|
|
||||||
static const _keyGhostMode = 'ghost_mode';
|
|
||||||
static const _keyNoAds = 'no_ads';
|
|
||||||
static const _keyNoStories = 'no_stories';
|
|
||||||
static const _keyNoReels = 'no_reels';
|
|
||||||
static const _keyNoAutoplay = 'no_autoplay';
|
|
||||||
static const _keyNoDMs = 'no_dms';
|
|
||||||
|
|
||||||
SharedPreferences? _prefs;
|
SharedPreferences? _prefs;
|
||||||
|
|
||||||
bool _blurExplore = true;
|
bool _blurExplore = true;
|
||||||
bool _blurReels = false;
|
bool _blurReels = false;
|
||||||
bool _tapToUnblur = true;
|
|
||||||
bool _requireLongPress = true;
|
bool _requireLongPress = true;
|
||||||
bool _showBreathGate = true;
|
bool _showBreathGate = true;
|
||||||
bool _requireWordChallenge = true;
|
bool _requireWordChallenge = true;
|
||||||
int _breathGateSeconds = 10;
|
|
||||||
int _wordChallengeCount = 30;
|
|
||||||
bool _enableTextSelection = false;
|
bool _enableTextSelection = false;
|
||||||
bool _showInstaSettings = true;
|
bool _showInstaSettings = true;
|
||||||
bool _isDarkMode = true; // Default to dark as per existing app theme
|
bool _isDarkMode = true; // Default to dark as per existing app theme
|
||||||
|
|
||||||
bool _blockAutoplay = true;
|
bool _blockAutoplay = true;
|
||||||
|
|
||||||
bool _videoDownloadEnabled = false;
|
|
||||||
bool _hideSuggestedPosts = false;
|
|
||||||
|
|
||||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
|
||||||
bool _v2GhostModeEnabled = false;
|
|
||||||
bool _v2AdBlockerDomEnabled = false;
|
|
||||||
bool _v2ContentHiderEnabled = false;
|
|
||||||
|
|
||||||
// Content hider flags (consumed by v2/content_hider.js via prefs keys)
|
|
||||||
bool _contentStories = false;
|
|
||||||
bool _contentPosts = false;
|
|
||||||
bool _contentReels = false;
|
|
||||||
bool _contentSuggested = false;
|
|
||||||
|
|
||||||
// Grayscale mode - now supports multiple schedules
|
|
||||||
bool _grayscaleEnabled = false;
|
bool _grayscaleEnabled = false;
|
||||||
List<Map<String, dynamic>> _grayscaleSchedules = [];
|
bool _grayscaleScheduleEnabled = false;
|
||||||
|
String _grayscaleScheduleTime = '21:00'; // 9:00 PM default
|
||||||
|
|
||||||
// Content filtering / UI hiding
|
bool _hideSuggestedPosts = false;
|
||||||
|
bool _hideSponsoredPosts = false;
|
||||||
bool _hideLikeCounts = false;
|
bool _hideLikeCounts = false;
|
||||||
bool _hideFollowerCounts = false;
|
bool _hideFollowerCounts = false;
|
||||||
|
bool _hideStoriesBar = false;
|
||||||
|
bool _hideExploreTab = false;
|
||||||
|
bool _hideReelsTab = false;
|
||||||
bool _hideShopTab = false;
|
bool _hideShopTab = false;
|
||||||
|
|
||||||
// These are now controlled internally by minimal mode
|
|
||||||
bool _disableReelsEntirely = false;
|
bool _disableReelsEntirely = false;
|
||||||
bool _disableExploreEntirely = false;
|
bool _disableExploreEntirely = false;
|
||||||
bool _blockHomeFeedScroll = false;
|
|
||||||
bool _minimalModeEnabled = false;
|
bool _minimalModeEnabled = false;
|
||||||
|
|
||||||
// Tracking for smart restore
|
|
||||||
bool _prevDisableReels = false;
|
|
||||||
bool _prevDisableExplore = false;
|
|
||||||
bool _prevBlurExplore = false;
|
|
||||||
bool _prevBlockHomeFeedScroll = false;
|
|
||||||
|
|
||||||
bool _reelsHistoryEnabled = true;
|
bool _reelsHistoryEnabled = true;
|
||||||
|
|
||||||
// Privacy defaults - notifications OFF by default
|
// Privacy defaults - notifications OFF by default
|
||||||
@@ -136,15 +82,6 @@ class SettingsService extends ChangeNotifier {
|
|||||||
bool _notifyDMs = false;
|
bool _notifyDMs = false;
|
||||||
bool _notifyActivity = false;
|
bool _notifyActivity = false;
|
||||||
bool _notifySessionEnd = false;
|
bool _notifySessionEnd = false;
|
||||||
bool _notifyPersistent = false;
|
|
||||||
|
|
||||||
// Focus mode settings
|
|
||||||
bool _ghostMode = false;
|
|
||||||
bool _noAds = false;
|
|
||||||
bool _noStories = false;
|
|
||||||
bool _noReels = false;
|
|
||||||
bool _noAutoplay = false;
|
|
||||||
bool _noDMs = false;
|
|
||||||
|
|
||||||
List<String> _enabledTabs = [
|
List<String> _enabledTabs = [
|
||||||
'Home',
|
'Home',
|
||||||
@@ -157,100 +94,57 @@ class SettingsService extends ChangeNotifier {
|
|||||||
|
|
||||||
bool get blurExplore => _blurExplore;
|
bool get blurExplore => _blurExplore;
|
||||||
bool get blurReels => _blurReels;
|
bool get blurReels => _blurReels;
|
||||||
bool get tapToUnblur => _tapToUnblur;
|
|
||||||
bool get requireLongPress => _requireLongPress;
|
bool get requireLongPress => _requireLongPress;
|
||||||
bool get showBreathGate => _showBreathGate;
|
bool get showBreathGate => _showBreathGate;
|
||||||
bool get requireWordChallenge => _requireWordChallenge;
|
bool get requireWordChallenge => _requireWordChallenge;
|
||||||
int get breathGateSeconds => _breathGateSeconds;
|
|
||||||
int get wordChallengeCount => _wordChallengeCount;
|
|
||||||
bool get enableTextSelection => _enableTextSelection;
|
bool get enableTextSelection => _enableTextSelection;
|
||||||
bool get showInstaSettings => _showInstaSettings;
|
bool get showInstaSettings => _showInstaSettings;
|
||||||
List<String> get enabledTabs => _enabledTabs;
|
List<String> get enabledTabs => _enabledTabs;
|
||||||
bool get isFirstRun => _isFirstRun;
|
bool get isFirstRun => _isFirstRun;
|
||||||
bool get isDarkMode => _isDarkMode;
|
bool get isDarkMode => _isDarkMode;
|
||||||
bool get blockAutoplay => _blockAutoplay;
|
bool get blockAutoplay => _blockAutoplay;
|
||||||
|
|
||||||
// Extras (Phase 2)
|
|
||||||
bool get videoDownloadEnabled => _videoDownloadEnabled;
|
|
||||||
bool get hideSuggestedPosts => _hideSuggestedPosts;
|
|
||||||
|
|
||||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
|
||||||
bool get v2GhostModeEnabled => _v2GhostModeEnabled;
|
|
||||||
bool get v2AdBlockerDomEnabled => _v2AdBlockerDomEnabled;
|
|
||||||
bool get v2ContentHiderEnabled => _v2ContentHiderEnabled;
|
|
||||||
|
|
||||||
bool get contentStories => _contentStories;
|
|
||||||
bool get contentPosts => _contentPosts;
|
|
||||||
bool get contentReels => _contentReels;
|
|
||||||
bool get contentSuggested => _contentSuggested;
|
|
||||||
bool get notifyDMs => _notifyDMs;
|
bool get notifyDMs => _notifyDMs;
|
||||||
bool get notifyActivity => _notifyActivity;
|
bool get notifyActivity => _notifyActivity;
|
||||||
bool get notifySessionEnd => _notifySessionEnd;
|
bool get notifySessionEnd => _notifySessionEnd;
|
||||||
bool get notifyPersistent => _notifyPersistent;
|
|
||||||
|
|
||||||
bool get grayscaleEnabled => _grayscaleEnabled;
|
bool get grayscaleEnabled => _grayscaleEnabled;
|
||||||
List<Map<String, dynamic>> get grayscaleSchedules => _grayscaleSchedules;
|
bool get grayscaleScheduleEnabled => _grayscaleScheduleEnabled;
|
||||||
|
String get grayscaleScheduleTime => _grayscaleScheduleTime;
|
||||||
|
|
||||||
|
bool get hideSuggestedPosts => _hideSuggestedPosts;
|
||||||
|
bool get hideSponsoredPosts => _hideSponsoredPosts;
|
||||||
bool get hideLikeCounts => _hideLikeCounts;
|
bool get hideLikeCounts => _hideLikeCounts;
|
||||||
bool get hideFollowerCounts => _hideFollowerCounts;
|
bool get hideFollowerCounts => _hideFollowerCounts;
|
||||||
|
bool get hideStoriesBar => _hideStoriesBar;
|
||||||
|
bool get hideExploreTab => _hideExploreTab;
|
||||||
|
bool get hideReelsTab => _hideReelsTab;
|
||||||
bool get hideShopTab => _hideShopTab;
|
bool get hideShopTab => _hideShopTab;
|
||||||
|
|
||||||
// Focus mode settings
|
|
||||||
bool get ghostMode => _ghostMode;
|
|
||||||
bool get noAds => _noAds;
|
|
||||||
bool get noStories => _noStories;
|
|
||||||
bool get noReels => _noReels;
|
|
||||||
bool get noAutoplay => _noAutoplay;
|
|
||||||
bool get noDMs => _noDMs;
|
|
||||||
|
|
||||||
// These are now controlled by minimal mode only
|
|
||||||
bool get disableReelsEntirely => _disableReelsEntirely;
|
bool get disableReelsEntirely => _disableReelsEntirely;
|
||||||
bool get disableExploreEntirely => _disableExploreEntirely;
|
bool get disableExploreEntirely => _disableExploreEntirely;
|
||||||
bool get blockHomeFeedScroll => _blockHomeFeedScroll;
|
|
||||||
bool get minimalModeEnabled => _minimalModeEnabled;
|
bool get minimalModeEnabled => _minimalModeEnabled;
|
||||||
|
|
||||||
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
bool get reelsHistoryEnabled => _reelsHistoryEnabled;
|
||||||
|
|
||||||
/// True if grayscale should currently be applied, considering the manual
|
/// True if grayscale should currently be applied, considering the manual
|
||||||
/// toggle and the optional schedules.
|
/// toggle and the optional schedule.
|
||||||
bool get isGrayscaleActiveNow {
|
bool get isGrayscaleActiveNow {
|
||||||
if (_grayscaleEnabled) return true;
|
if (_grayscaleEnabled) return true;
|
||||||
if (_grayscaleSchedules.isEmpty) return false;
|
if (!_grayscaleScheduleEnabled) return false;
|
||||||
|
try {
|
||||||
|
final parts = _grayscaleScheduleTime.split(':');
|
||||||
|
if (parts.length != 2) return false;
|
||||||
|
final h = int.parse(parts[0]);
|
||||||
|
final m = int.parse(parts[1]);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final currentMinutes = now.hour * 60 + now.minute;
|
final currentMinutes = now.hour * 60 + now.minute;
|
||||||
|
final startMinutes = h * 60 + m;
|
||||||
for (final schedule in _grayscaleSchedules) {
|
// Active from the configured time until midnight.
|
||||||
if (schedule['enabled'] != true) continue;
|
return currentMinutes >= startMinutes;
|
||||||
|
|
||||||
try {
|
|
||||||
final startParts = (schedule['startTime'] as String).split(':');
|
|
||||||
final endParts = (schedule['endTime'] as String).split(':');
|
|
||||||
|
|
||||||
if (startParts.length != 2 || endParts.length != 2) continue;
|
|
||||||
|
|
||||||
final startMinutes =
|
|
||||||
int.parse(startParts[0]) * 60 + int.parse(startParts[1]);
|
|
||||||
final endMinutes = int.parse(endParts[0]) * 60 + int.parse(endParts[1]);
|
|
||||||
|
|
||||||
// Handle overnight schedules (e.g., 21:00 to 06:00)
|
|
||||||
if (endMinutes < startMinutes) {
|
|
||||||
// Overnight: active if current time is >= start OR < end
|
|
||||||
if (currentMinutes >= startMinutes || currentMinutes < endMinutes) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Same day: active if current time is between start and end
|
|
||||||
if (currentMinutes >= startMinutes && currentMinutes < endMinutes) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Privacy getters
|
// Privacy getters
|
||||||
bool get sanitizeLinks => _sanitizeLinks;
|
bool get sanitizeLinks => _sanitizeLinks;
|
||||||
@@ -259,89 +153,39 @@ class SettingsService extends ChangeNotifier {
|
|||||||
_prefs = await SharedPreferences.getInstance();
|
_prefs = await SharedPreferences.getInstance();
|
||||||
_blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true;
|
_blurExplore = _prefs!.getBool(_keyBlurExplore) ?? true;
|
||||||
_blurReels = _prefs!.getBool(_keyBlurReels) ?? false;
|
_blurReels = _prefs!.getBool(_keyBlurReels) ?? false;
|
||||||
_tapToUnblur = _prefs!.getBool(_keyTapToUnblur) ?? true;
|
|
||||||
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
_requireLongPress = _prefs!.getBool(_keyRequireLongPress) ?? true;
|
||||||
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
|
_showBreathGate = _prefs!.getBool(_keyShowBreathGate) ?? true;
|
||||||
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
_requireWordChallenge = _prefs!.getBool(_keyRequireWordChallenge) ?? true;
|
||||||
_breathGateSeconds = (_prefs!.getInt(_keyBreathGateSeconds) ?? 10)
|
|
||||||
.clamp(3, 60)
|
|
||||||
.toInt();
|
|
||||||
_wordChallengeCount = _normaliseWordChallengeCount(
|
|
||||||
_prefs!.getInt(_keyWordChallengeCount) ?? 30,
|
|
||||||
);
|
|
||||||
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
_enableTextSelection = _prefs!.getBool(_keyEnableTextSelection) ?? false;
|
||||||
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
|
_showInstaSettings = _prefs!.getBool(_keyShowInstaSettings) ?? true;
|
||||||
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
|
_blockAutoplay = _prefs!.getBool(_keyBlockAutoplay) ?? true;
|
||||||
|
|
||||||
// Extras (Phase 2) - defaults OFF for safety/non-invasive behavior
|
_grayscaleEnabled = _prefs!.getBool(_keyGrayscaleEnabled) ?? false;
|
||||||
_videoDownloadEnabled = _prefs!.getBool(_keyVideoDownloadEnabled) ?? false;
|
_grayscaleScheduleEnabled =
|
||||||
|
_prefs!.getBool(_keyGrayscaleScheduleEnabled) ?? false;
|
||||||
|
_grayscaleScheduleTime =
|
||||||
|
_prefs!.getString(_keyGrayscaleScheduleTime) ?? '21:00';
|
||||||
|
|
||||||
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
||||||
|
_hideSponsoredPosts = _prefs!.getBool(_keyHideSponsoredPosts) ?? false;
|
||||||
// ── FocusGram v2 overlay toggles ─────────────────────────────────────────
|
|
||||||
_v2GhostModeEnabled = _prefs!.getBool(_keyV2GhostModeEnabled) ?? false;
|
|
||||||
_v2AdBlockerDomEnabled =
|
|
||||||
_prefs!.getBool(_keyV2AdBlockerDomEnabled) ?? false;
|
|
||||||
_v2ContentHiderEnabled =
|
|
||||||
_prefs!.getBool(_keyV2ContentHiderEnabled) ?? false;
|
|
||||||
|
|
||||||
_contentStories = _prefs!.getBool(_keyContentStories) ?? false;
|
|
||||||
_contentPosts = _prefs!.getBool(_keyContentPosts) ?? false;
|
|
||||||
_contentReels = _prefs!.getBool(_keyContentReels) ?? false;
|
|
||||||
_contentSuggested = _prefs!.getBool(_keyContentSuggested) ?? false;
|
|
||||||
_hideSuggestedPosts = _prefs!.getBool(_keyHideSuggestedPosts) ?? false;
|
|
||||||
|
|
||||||
// Load grayscale schedules
|
|
||||||
final schedulesJson = _prefs!.getString(_keyGrayscaleSchedules);
|
|
||||||
if (schedulesJson != null) {
|
|
||||||
try {
|
|
||||||
_grayscaleSchedules = List<Map<String, dynamic>>.from(
|
|
||||||
(jsonDecode(schedulesJson) as List).map(
|
|
||||||
(e) => Map<String, dynamic>.from(e),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
_grayscaleSchedules = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
_hideLikeCounts = _prefs!.getBool(_keyHideLikeCounts) ?? false;
|
||||||
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
_hideFollowerCounts = _prefs!.getBool(_keyHideFollowerCounts) ?? false;
|
||||||
|
_hideStoriesBar = _prefs!.getBool(_keyHideStoriesBar) ?? false;
|
||||||
|
_hideExploreTab = _prefs!.getBool(_keyHideExploreTab) ?? false;
|
||||||
|
_hideReelsTab = _prefs!.getBool(_keyHideReelsTab) ?? false;
|
||||||
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
_hideShopTab = _prefs!.getBool(_keyHideShopTab) ?? false;
|
||||||
|
|
||||||
// Load minimal mode
|
_disableReelsEntirely = _prefs!.getBool(_keyDisableReelsEntirely) ?? false;
|
||||||
|
_disableExploreEntirely =
|
||||||
|
_prefs!.getBool(_keyDisableExploreEntirely) ?? false;
|
||||||
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
_minimalModeEnabled = _prefs!.getBool(_keyMinimalModeEnabled) ?? false;
|
||||||
|
|
||||||
// Load previous states for smart restore
|
|
||||||
_prevDisableReels =
|
|
||||||
_prefs!.getBool(_keyMinimalModePrevDisableReels) ?? false;
|
|
||||||
_prevDisableExplore =
|
|
||||||
_prefs!.getBool(_keyMinimalModePrevDisableExplore) ?? false;
|
|
||||||
_prevBlurExplore = _prefs!.getBool(_keyMinimalModePrevBlurExplore) ?? false;
|
|
||||||
_prevBlockHomeFeedScroll =
|
|
||||||
_prefs!.getBool(_keyMinimalModePrevBlockHomeFeedScroll) ?? false;
|
|
||||||
|
|
||||||
// These are now internal states, not user-facing settings
|
|
||||||
_disableReelsEntirely =
|
|
||||||
_prefs!.getBool('internal_disable_reels_entirely') ?? false;
|
|
||||||
_disableExploreEntirely =
|
|
||||||
_prefs!.getBool('internal_disable_explore_entirely') ?? false;
|
|
||||||
_blockHomeFeedScroll =
|
|
||||||
_prefs!.getBool('internal_block_home_feed_scroll') ?? false;
|
|
||||||
|
|
||||||
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
|
_reelsHistoryEnabled = _prefs!.getBool(_keyReelsHistoryEnabled) ?? true;
|
||||||
|
|
||||||
// Focus mode settings
|
|
||||||
_ghostMode = _prefs!.getBool(_keyGhostMode) ?? false;
|
|
||||||
_noAds = _prefs!.getBool(_keyNoAds) ?? false;
|
|
||||||
_noStories = _prefs!.getBool(_keyNoStories) ?? false;
|
|
||||||
_noReels = _prefs!.getBool(_keyNoReels) ?? false;
|
|
||||||
_noAutoplay = _prefs!.getBool(_keyNoAutoplay) ?? false;
|
|
||||||
_noDMs = _prefs!.getBool(_keyNoDMs) ?? false;
|
|
||||||
|
|
||||||
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
|
_sanitizeLinks = _prefs!.getBool(_keySanitizeLinks) ?? true;
|
||||||
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
_notifyDMs = _prefs!.getBool(_keyNotifyDMs) ?? false;
|
||||||
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
_notifyActivity = _prefs!.getBool(_keyNotifyActivity) ?? false;
|
||||||
_notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false;
|
_notifySessionEnd = _prefs!.getBool(_keyNotifySessionEnd) ?? false;
|
||||||
_notifyPersistent = _prefs!.getBool(_keyNotifyPersistent) ?? false;
|
|
||||||
|
|
||||||
_enabledTabs =
|
_enabledTabs =
|
||||||
(_prefs!.getStringList(_keyEnabledTabs) ??
|
(_prefs!.getStringList(_keyEnabledTabs) ??
|
||||||
@@ -364,31 +208,15 @@ class SettingsService extends ChangeNotifier {
|
|||||||
Future<void> setBlurExplore(bool v) async {
|
Future<void> setBlurExplore(bool v) async {
|
||||||
_blurExplore = v;
|
_blurExplore = v;
|
||||||
await _prefs?.setBool(_keyBlurExplore, v);
|
await _prefs?.setBool(_keyBlurExplore, v);
|
||||||
|
|
||||||
if (_minimalModeEnabled) {
|
|
||||||
await _checkAndAutoDisableMinimalMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setBlurReels(bool v) async {
|
Future<void> setBlurReels(bool v) async {
|
||||||
_blurReels = v;
|
_blurReels = v;
|
||||||
// Sync blur reels with blur explore - enabling one enables the other
|
|
||||||
if (v && !_blurExplore) {
|
|
||||||
_blurExplore = true;
|
|
||||||
await _prefs?.setBool(_keyBlurExplore, true);
|
|
||||||
}
|
|
||||||
await _prefs?.setBool(_keyBlurReels, v);
|
await _prefs?.setBool(_keyBlurReels, v);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setTapToUnblur(bool v) async {
|
|
||||||
_tapToUnblur = v;
|
|
||||||
await _prefs?.setBool(_keyTapToUnblur, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setRequireLongPress(bool v) async {
|
Future<void> setRequireLongPress(bool v) async {
|
||||||
_requireLongPress = v;
|
_requireLongPress = v;
|
||||||
await _prefs?.setBool(_keyRequireLongPress, v);
|
await _prefs?.setBool(_keyRequireLongPress, v);
|
||||||
@@ -407,32 +235,6 @@ class SettingsService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setBreathGateSeconds(int seconds) async {
|
|
||||||
final clamped = seconds.clamp(3, 60);
|
|
||||||
_breathGateSeconds = clamped.toInt();
|
|
||||||
await _prefs?.setInt(_keyBreathGateSeconds, _breathGateSeconds);
|
|
||||||
// Defer notifyListeners to next microtask to avoid rebuild conflicts
|
|
||||||
Future.microtask(notifyListeners);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setWordChallengeCount(int count) async {
|
|
||||||
_wordChallengeCount = _normaliseWordChallengeCount(count);
|
|
||||||
await _prefs?.setInt(_keyWordChallengeCount, _wordChallengeCount);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
int resolvedWordChallengeCount() {
|
|
||||||
if (_wordChallengeCount != 0) return _wordChallengeCount;
|
|
||||||
final now = DateTime.now().microsecondsSinceEpoch;
|
|
||||||
return 10 + (now % 26);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int _normaliseWordChallengeCount(int count) {
|
|
||||||
if (count == 0) return 0;
|
|
||||||
const allowed = [20, 25, 30, 35];
|
|
||||||
return allowed.contains(count) ? count : 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setEnableTextSelection(bool v) async {
|
Future<void> setEnableTextSelection(bool v) async {
|
||||||
_enableTextSelection = v;
|
_enableTextSelection = v;
|
||||||
await _prefs?.setBool(_keyEnableTextSelection, v);
|
await _prefs?.setBool(_keyEnableTextSelection, v);
|
||||||
@@ -451,11 +253,21 @@ class SettingsService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extras (Phase 2) ──────────────────────────────────────────────────────
|
Future<void> setGrayscaleEnabled(bool v) async {
|
||||||
|
_grayscaleEnabled = v;
|
||||||
|
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setVideoDownloadEnabled(bool v) async {
|
Future<void> setGrayscaleScheduleEnabled(bool v) async {
|
||||||
_videoDownloadEnabled = v;
|
_grayscaleScheduleEnabled = v;
|
||||||
await _prefs?.setBool(_keyVideoDownloadEnabled, v);
|
await _prefs?.setBool(_keyGrayscaleScheduleEnabled, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setGrayscaleScheduleTime(String hhmm) async {
|
||||||
|
_grayscaleScheduleTime = hhmm;
|
||||||
|
await _prefs?.setString(_keyGrayscaleScheduleTime, hhmm);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,116 +277,15 @@ class SettingsService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setGrayscaleEnabled(bool v) async {
|
Future<void> setHideSponsoredPosts(bool v) async {
|
||||||
_grayscaleEnabled = v;
|
_hideSponsoredPosts = v;
|
||||||
await _prefs?.setBool(_keyGrayscaleEnabled, v);
|
await _prefs?.setBool(_keyHideSponsoredPosts, v);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setGrayscaleSchedules(
|
Future<void> setHideLikeCounts(bool v) async {
|
||||||
List<Map<String, dynamic>> schedules,
|
_hideLikeCounts = v;
|
||||||
) async {
|
await _prefs?.setBool(_keyHideLikeCounts, v);
|
||||||
_grayscaleSchedules = schedules;
|
|
||||||
await _prefs?.setString(_keyGrayscaleSchedules, jsonEncode(schedules));
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addGrayscaleSchedule(Map<String, dynamic> schedule) async {
|
|
||||||
_grayscaleSchedules.add(schedule);
|
|
||||||
await _prefs?.setString(
|
|
||||||
_keyGrayscaleSchedules,
|
|
||||||
jsonEncode(_grayscaleSchedules),
|
|
||||||
);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateGrayscaleSchedule(
|
|
||||||
int index,
|
|
||||||
Map<String, dynamic> schedule,
|
|
||||||
) async {
|
|
||||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
|
||||||
_grayscaleSchedules[index] = schedule;
|
|
||||||
await _prefs?.setString(
|
|
||||||
_keyGrayscaleSchedules,
|
|
||||||
jsonEncode(_grayscaleSchedules),
|
|
||||||
);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> removeGrayscaleSchedule(int index) async {
|
|
||||||
if (index >= 0 && index < _grayscaleSchedules.length) {
|
|
||||||
_grayscaleSchedules.removeAt(index);
|
|
||||||
await _prefs?.setString(
|
|
||||||
_keyGrayscaleSchedules,
|
|
||||||
jsonEncode(_grayscaleSchedules),
|
|
||||||
);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setHideShopTab(bool v) async {
|
|
||||||
_hideShopTab = v;
|
|
||||||
await _prefs?.setBool(_keyHideShopTab, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── FocusGram v2 overlay setters ──────────────────────────────────────────
|
|
||||||
Future<void> setV2GhostModeEnabled(bool v) async {
|
|
||||||
_v2GhostModeEnabled = v;
|
|
||||||
await _prefs?.setBool(_keyV2GhostModeEnabled, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setV2AdBlockerDomEnabled(bool v) async {
|
|
||||||
_v2AdBlockerDomEnabled = v;
|
|
||||||
await _prefs?.setBool(_keyV2AdBlockerDomEnabled, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setV2ContentHiderEnabled(bool v) async {
|
|
||||||
_v2ContentHiderEnabled = v;
|
|
||||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setContentStoriesEnabled(bool v) async {
|
|
||||||
if (v && !_v2ContentHiderEnabled) {
|
|
||||||
_v2ContentHiderEnabled = true;
|
|
||||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
|
||||||
}
|
|
||||||
_contentStories = v;
|
|
||||||
await _prefs?.setBool(_keyContentStories, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setContentPostsEnabled(bool v) async {
|
|
||||||
if (v && !_v2ContentHiderEnabled) {
|
|
||||||
_v2ContentHiderEnabled = true;
|
|
||||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
|
||||||
}
|
|
||||||
_contentPosts = v;
|
|
||||||
await _prefs?.setBool(_keyContentPosts, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setContentReelsEnabled(bool v) async {
|
|
||||||
if (v && !_v2ContentHiderEnabled) {
|
|
||||||
_v2ContentHiderEnabled = true;
|
|
||||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
|
||||||
}
|
|
||||||
_contentReels = v;
|
|
||||||
await _prefs?.setBool(_keyContentReels, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setContentSuggestedEnabled(bool v) async {
|
|
||||||
if (v && !_v2ContentHiderEnabled) {
|
|
||||||
_v2ContentHiderEnabled = true;
|
|
||||||
await _prefs?.setBool(_keyV2ContentHiderEnabled, true);
|
|
||||||
}
|
|
||||||
_contentSuggested = v;
|
|
||||||
await _prefs?.setBool(_keyContentSuggested, v);
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,139 +295,45 @@ class SettingsService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setter for internal disable reels state (used by minimal mode submenu)
|
Future<void> setHideStoriesBar(bool v) async {
|
||||||
/// Auto-disables minimal mode if all features are turned off
|
_hideStoriesBar = v;
|
||||||
Future<void> setDisableReelsEntirelyInternal(bool v) async {
|
await _prefs?.setBool(_keyHideStoriesBar, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setHideExploreTab(bool v) async {
|
||||||
|
_hideExploreTab = v;
|
||||||
|
await _prefs?.setBool(_keyHideExploreTab, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setHideReelsTab(bool v) async {
|
||||||
|
_hideReelsTab = v;
|
||||||
|
await _prefs?.setBool(_keyHideReelsTab, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setHideShopTab(bool v) async {
|
||||||
|
_hideShopTab = v;
|
||||||
|
await _prefs?.setBool(_keyHideShopTab, v);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setDisableReelsEntirely(bool v) async {
|
||||||
_disableReelsEntirely = v;
|
_disableReelsEntirely = v;
|
||||||
await _prefs?.setBool('internal_disable_reels_entirely', v);
|
await _prefs?.setBool(_keyDisableReelsEntirely, v);
|
||||||
|
|
||||||
// Check if minimal mode should auto-disable
|
|
||||||
await _checkAndAutoDisableMinimalMode();
|
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setter for internal disable explore state (used by minimal mode submenu)
|
Future<void> setDisableExploreEntirely(bool v) async {
|
||||||
/// Auto-disables minimal mode if all features are turned off
|
|
||||||
Future<void> setDisableExploreEntirelyInternal(bool v) async {
|
|
||||||
_disableExploreEntirely = v;
|
_disableExploreEntirely = v;
|
||||||
await _prefs?.setBool('internal_disable_explore_entirely', v);
|
await _prefs?.setBool(_keyDisableExploreEntirely, v);
|
||||||
|
|
||||||
// Check if minimal mode should auto-disable
|
|
||||||
await _checkAndAutoDisableMinimalMode();
|
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setter for home feed scroll blocking state (used by minimal mode submenu).
|
|
||||||
Future<void> setBlockHomeFeedScrollInternal(bool v) async {
|
|
||||||
_blockHomeFeedScroll = v;
|
|
||||||
await _prefs?.setBool('internal_block_home_feed_scroll', v);
|
|
||||||
|
|
||||||
await _checkAndAutoDisableMinimalMode();
|
|
||||||
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: Auto-disable minimal mode if all its features are disabled
|
|
||||||
/// This ensures minimal mode auto-turns-off when user disables all sub-features
|
|
||||||
///
|
|
||||||
/// NOTE: We must check the RAW state variables here, NOT the public getters
|
|
||||||
/// (disableReelsEntirely/disableExploreEntirely), because those getters
|
|
||||||
/// unconditionally return true when _minimalModeEnabled is true, which would
|
|
||||||
/// make the "all disabled" condition impossible to reach.
|
|
||||||
Future<void> _checkAndAutoDisableMinimalMode() async {
|
|
||||||
if (!_minimalModeEnabled) return;
|
|
||||||
|
|
||||||
// Check the RAW saved state, not the getters
|
|
||||||
final rawReels =
|
|
||||||
_prefs?.getBool('internal_disable_reels_entirely') ??
|
|
||||||
_disableReelsEntirely;
|
|
||||||
final rawExplore =
|
|
||||||
_prefs?.getBool('internal_disable_explore_entirely') ??
|
|
||||||
_disableExploreEntirely;
|
|
||||||
|
|
||||||
final rawHomeFeedScroll =
|
|
||||||
_prefs?.getBool('internal_block_home_feed_scroll') ??
|
|
||||||
_blockHomeFeedScroll;
|
|
||||||
|
|
||||||
final allDisabled =
|
|
||||||
!rawReels && !rawExplore && !rawHomeFeedScroll && !_blurExplore;
|
|
||||||
|
|
||||||
if (allDisabled) {
|
|
||||||
_minimalModeEnabled = false;
|
|
||||||
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Smart minimal mode toggle with state preservation
|
|
||||||
Future<void> setMinimalModeEnabled(bool v) async {
|
Future<void> setMinimalModeEnabled(bool v) async {
|
||||||
if (v) {
|
_minimalModeEnabled = v;
|
||||||
// ── Turning ON ──────────────────────────────────────────────────────────
|
await _prefs?.setBool(_keyMinimalModeEnabled, v);
|
||||||
// Save current pre-minimal-mode states so we can restore them later
|
|
||||||
_prevDisableReels = _disableReelsEntirely;
|
|
||||||
_prevDisableExplore = _disableExploreEntirely;
|
|
||||||
_prevBlurExplore = _blurExplore;
|
|
||||||
_prevBlockHomeFeedScroll = _blockHomeFeedScroll;
|
|
||||||
|
|
||||||
await _prefs?.setBool(_keyMinimalModePrevDisableReels, _prevDisableReels);
|
|
||||||
await _prefs?.setBool(
|
|
||||||
_keyMinimalModePrevDisableExplore,
|
|
||||||
_prevDisableExplore,
|
|
||||||
);
|
|
||||||
await _prefs?.setBool(_keyMinimalModePrevBlurExplore, _prevBlurExplore);
|
|
||||||
await _prefs?.setBool(
|
|
||||||
_keyMinimalModePrevBlockHomeFeedScroll,
|
|
||||||
_prevBlockHomeFeedScroll,
|
|
||||||
);
|
|
||||||
|
|
||||||
_minimalModeEnabled = true;
|
|
||||||
_disableReelsEntirely = true;
|
|
||||||
_disableExploreEntirely = true;
|
|
||||||
_blockHomeFeedScroll = true;
|
|
||||||
_blurExplore = true; // blurExplore is controlled by minimal mode while ON
|
|
||||||
|
|
||||||
await _prefs?.setBool(_keyMinimalModeEnabled, true);
|
|
||||||
await _prefs?.setBool('internal_disable_reels_entirely', true);
|
|
||||||
await _prefs?.setBool('internal_disable_explore_entirely', true);
|
|
||||||
await _prefs?.setBool('internal_block_home_feed_scroll', true);
|
|
||||||
await _prefs?.setBool(_keyBlurExplore, true);
|
|
||||||
} else {
|
|
||||||
// ── Turning OFF ─────────────────────────────────────────────────────────
|
|
||||||
// Restore states that were saved BEFORE minimal mode was enabled.
|
|
||||||
// _prevDisableReels/Explore were saved at the moment minimal mode turned ON.
|
|
||||||
_minimalModeEnabled = false;
|
|
||||||
_disableReelsEntirely = _prevDisableReels;
|
|
||||||
_disableExploreEntirely = _prevDisableExplore;
|
|
||||||
_blockHomeFeedScroll = _prevBlockHomeFeedScroll;
|
|
||||||
// For blurExplore: use _prevBlurExplore if it was saved, otherwise fall back
|
|
||||||
// to the saved prefs value (covers the case where no prev was saved).
|
|
||||||
_blurExplore = _prevBlurExplore;
|
|
||||||
|
|
||||||
await _prefs?.setBool(_keyMinimalModeEnabled, false);
|
|
||||||
await _prefs?.setBool(
|
|
||||||
'internal_disable_reels_entirely',
|
|
||||||
_disableReelsEntirely,
|
|
||||||
);
|
|
||||||
await _prefs?.setBool(
|
|
||||||
'internal_disable_explore_entirely',
|
|
||||||
_disableExploreEntirely,
|
|
||||||
);
|
|
||||||
await _prefs?.setBool(
|
|
||||||
'internal_block_home_feed_scroll',
|
|
||||||
_blockHomeFeedScroll,
|
|
||||||
);
|
|
||||||
await _prefs?.setBool(_keyBlurExplore, _blurExplore);
|
|
||||||
|
|
||||||
// After restoring, check whether the user had ALL minimal features OFF
|
|
||||||
// already — if so, minimal mode should stay off (no-op).
|
|
||||||
if (!_disableReelsEntirely &&
|
|
||||||
!_disableExploreEntirely &&
|
|
||||||
!_blockHomeFeedScroll &&
|
|
||||||
!_blurExplore) {
|
|
||||||
// All features are off — minimal mode correctly stays off. No action needed.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,69 +359,18 @@ class SettingsService extends ChangeNotifier {
|
|||||||
Future<void> setNotifyDMs(bool v) async {
|
Future<void> setNotifyDMs(bool v) async {
|
||||||
_notifyDMs = v;
|
_notifyDMs = v;
|
||||||
await _prefs?.setBool(_keyNotifyDMs, v);
|
await _prefs?.setBool(_keyNotifyDMs, v);
|
||||||
if (v) await NotificationService().requestPermissionsNow();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setNotifyActivity(bool v) async {
|
Future<void> setNotifyActivity(bool v) async {
|
||||||
_notifyActivity = v;
|
_notifyActivity = v;
|
||||||
await _prefs?.setBool(_keyNotifyActivity, v);
|
await _prefs?.setBool(_keyNotifyActivity, v);
|
||||||
if (v) await NotificationService().requestPermissionsNow();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setNotifySessionEnd(bool v) async {
|
Future<void> setNotifySessionEnd(bool v) async {
|
||||||
_notifySessionEnd = v;
|
_notifySessionEnd = v;
|
||||||
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
await _prefs?.setBool(_keyNotifySessionEnd, v);
|
||||||
if (v) await NotificationService().requestPermissionsNow();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setNotifyPersistent(bool v) async {
|
|
||||||
_notifyPersistent = v;
|
|
||||||
await _prefs?.setBool(_keyNotifyPersistent, v);
|
|
||||||
if (v) {
|
|
||||||
await NotificationService().requestPermissionsNow();
|
|
||||||
} else {
|
|
||||||
await NotificationService().cancelPersistentNotification(id: 5001);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Focus mode settings ──────────────────────────────────────────────────────
|
|
||||||
Future<void> setGhostMode(bool v) async {
|
|
||||||
_ghostMode = v;
|
|
||||||
await _prefs?.setBool(_keyGhostMode, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setNoAds(bool v) async {
|
|
||||||
_noAds = v;
|
|
||||||
await _prefs?.setBool(_keyNoAds, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setNoStories(bool v) async {
|
|
||||||
_noStories = v;
|
|
||||||
await _prefs?.setBool(_keyNoStories, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setNoReels(bool v) async {
|
|
||||||
_noReels = v;
|
|
||||||
await _prefs?.setBool(_keyNoReels, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setNoAutoplay(bool v) async {
|
|
||||||
_noAutoplay = v;
|
|
||||||
await _prefs?.setBool(_keyNoAutoplay, v);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setNoDMs(bool v) async {
|
|
||||||
_noDMs = v;
|
|
||||||
await _prefs?.setBool(_keyNoDMs, v);
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -517,7 +517,7 @@ class DisciplineChallenge {
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Shows the word challenge dialog. Returns true if successful.
|
/// Shows the word challenge dialog. Returns true if successful.
|
||||||
static Future<bool> show(BuildContext context, {int count = 30}) async {
|
static Future<bool> show(BuildContext context, {int count = 15}) async {
|
||||||
final list = List<String>.from(_words)..shuffle();
|
final list = List<String>.from(_words)..shuffle();
|
||||||
final challenge = list.take(count).join(' ');
|
final challenge = list.take(count).join(' ');
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import 'script_registry_v2_overlay.dart';
|
|
||||||
|
|
||||||
class ScriptEngineV2Overlay {
|
|
||||||
final InAppWebViewController controller;
|
|
||||||
final SharedPreferences prefs;
|
|
||||||
|
|
||||||
final Map<String, String> _cache = {};
|
|
||||||
|
|
||||||
ScriptEngineV2Overlay({required this.controller, required this.prefs});
|
|
||||||
|
|
||||||
Future<void> initDocumentStartScripts() async {
|
|
||||||
for (final s in V2OverlayScriptRegistry.all) {
|
|
||||||
final enabled = _getEnabled(s.id);
|
|
||||||
s.enabled = enabled;
|
|
||||||
|
|
||||||
if (!enabled) continue;
|
|
||||||
|
|
||||||
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
|
|
||||||
final code = await _load(s.assetPath);
|
|
||||||
if (code == null) continue;
|
|
||||||
|
|
||||||
await controller.addUserScript(
|
|
||||||
userScript: UserScript(
|
|
||||||
source: code,
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
allowedOriginRules: {'https://www.instagram.com'},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> injectDocumentEndScripts() async {
|
|
||||||
for (final s in V2OverlayScriptRegistry.all) {
|
|
||||||
final enabled = _getEnabled(s.id);
|
|
||||||
s.enabled = enabled;
|
|
||||||
if (!enabled) continue;
|
|
||||||
|
|
||||||
if (s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END) {
|
|
||||||
final code = await _load(s.assetPath);
|
|
||||||
if (code == null) continue;
|
|
||||||
try {
|
|
||||||
await controller.evaluateJavascript(source: code);
|
|
||||||
} catch (_) {
|
|
||||||
// Best-effort injection; never crash UI.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _pushContentFlagsIfNeeded();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> toggle(V2OverlayScriptId id, bool enabled) async {
|
|
||||||
await prefs.setBool(_enabledKey(id), enabled);
|
|
||||||
|
|
||||||
// For DOCUMENT_START scripts, require reload for clean removal.
|
|
||||||
if (V2OverlayScriptRegistry.byId(id).injectionTime ==
|
|
||||||
UserScriptInjectionTime.AT_DOCUMENT_START) {
|
|
||||||
await controller.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For DOCUMENT_END scripts: just reload too to ensure DOM effects stop.
|
|
||||||
await controller.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _getEnabled(V2OverlayScriptId id) {
|
|
||||||
return prefs.getBool(_enabledKey(id)) ??
|
|
||||||
(id == V2OverlayScriptId.themeDetector);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _enabledKey(V2OverlayScriptId id) => 'fg_v2_${id.name}_enabled';
|
|
||||||
|
|
||||||
Future<void> _pushContentFlagsIfNeeded() async {
|
|
||||||
final contentScriptEnabled = _getEnabled(V2OverlayScriptId.contentHider);
|
|
||||||
|
|
||||||
final contentFlags = <String, bool>{
|
|
||||||
'stories': prefs.getBool('content_stories') ?? false,
|
|
||||||
'posts': prefs.getBool('content_posts') ?? false,
|
|
||||||
'reels': prefs.getBool('content_reels') ?? false,
|
|
||||||
'suggested': prefs.getBool('content_suggested') ?? false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply DOM content hider flags
|
|
||||||
if (contentScriptEnabled) {
|
|
||||||
await controller.evaluateJavascript(
|
|
||||||
source: 'window.__fgContent?.applyAll(${jsonEncode(contentFlags)});',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also push network filter flags used by fetch_interceptor.js
|
|
||||||
// so toggles actually affect request/response behavior.
|
|
||||||
final noAds =
|
|
||||||
(prefs.getBool('no_ads') ?? false) ||
|
|
||||||
(prefs.getBool(_enabledKey(V2OverlayScriptId.adBlockerDom)) ?? false);
|
|
||||||
final blockFeedPosts = contentFlags['posts'] ?? false;
|
|
||||||
final blockSuggested = contentFlags['suggested'] ?? false;
|
|
||||||
final blockReels = contentFlags['reels'] ?? false;
|
|
||||||
final blockAutoplay =
|
|
||||||
prefs.getBool(_enabledKey(V2OverlayScriptId.autoplayBlocker)) ?? false;
|
|
||||||
|
|
||||||
await controller.evaluateJavascript(
|
|
||||||
source:
|
|
||||||
'window.__fgSetFilterConfig?.(${jsonEncode({
|
|
||||||
// Strictly requested: when Hide Feed Posts is ON, block ALL graphql/query.
|
|
||||||
'blockGraphQLQueryWhenFeedPosts': blockFeedPosts,
|
|
||||||
|
|
||||||
// Ads blocker: use existing FocusGram "noAds" toggle (wired elsewhere in prefs).
|
|
||||||
'blockAds': noAds,
|
|
||||||
'blockSponsored': noAds,
|
|
||||||
|
|
||||||
'blockSuggested': blockSuggested,
|
|
||||||
|
|
||||||
// Keep video blocking controlled by existing toggles if desired.
|
|
||||||
'blockVideos': blockReels,
|
|
||||||
'blockAutoplay': blockAutoplay,
|
|
||||||
})});',
|
|
||||||
);
|
|
||||||
|
|
||||||
await controller.evaluateJavascript(
|
|
||||||
source: 'window.__fgSetBlockAutoplay?.($blockAutoplay);',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> _load(String assetPath) async {
|
|
||||||
if (_cache.containsKey(assetPath)) return _cache[assetPath];
|
|
||||||
try {
|
|
||||||
final code = await rootBundle.loadString(assetPath);
|
|
||||||
_cache[assetPath] = code;
|
|
||||||
return code;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
|
|
||||||
enum V2OverlayScriptId {
|
|
||||||
ghostMode,
|
|
||||||
themeDetector,
|
|
||||||
adBlockerDom,
|
|
||||||
contentHider,
|
|
||||||
fetchInterceptor,
|
|
||||||
autoplayBlocker,
|
|
||||||
}
|
|
||||||
|
|
||||||
class V2OverlayInstaScript {
|
|
||||||
final V2OverlayScriptId id;
|
|
||||||
final String name;
|
|
||||||
final String assetPath;
|
|
||||||
final UserScriptInjectionTime injectionTime;
|
|
||||||
bool enabled;
|
|
||||||
|
|
||||||
V2OverlayInstaScript({
|
|
||||||
required this.id,
|
|
||||||
required this.name,
|
|
||||||
required this.assetPath,
|
|
||||||
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
this.enabled = false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class V2OverlayScriptRegistry {
|
|
||||||
static final List<V2OverlayInstaScript> all = [
|
|
||||||
V2OverlayInstaScript(
|
|
||||||
id: V2OverlayScriptId.ghostMode,
|
|
||||||
name: 'ghost_mode',
|
|
||||||
assetPath: 'assets/scripts/ghost_mode.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
V2OverlayInstaScript(
|
|
||||||
id: V2OverlayScriptId.themeDetector,
|
|
||||||
name: 'theme_detector',
|
|
||||||
assetPath: 'assets/scripts/theme_detector.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
enabled: true,
|
|
||||||
),
|
|
||||||
V2OverlayInstaScript(
|
|
||||||
id: V2OverlayScriptId.adBlockerDom,
|
|
||||||
name: 'ad_blocker_dom',
|
|
||||||
assetPath: 'assets/scripts/ad_blocker_dom.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
V2OverlayInstaScript(
|
|
||||||
id: V2OverlayScriptId.contentHider,
|
|
||||||
name: 'content_hider',
|
|
||||||
assetPath: 'assets/scripts/content_hider.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
V2OverlayInstaScript(
|
|
||||||
id: V2OverlayScriptId.fetchInterceptor,
|
|
||||||
name: 'fetch_interceptor',
|
|
||||||
assetPath: 'assets/scripts/fetch_interceptor.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
V2OverlayInstaScript(
|
|
||||||
id: V2OverlayScriptId.autoplayBlocker,
|
|
||||||
name: 'autoplay_blocker',
|
|
||||||
assetPath: 'assets/scripts/autoplay_blocker.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
static V2OverlayInstaScript byId(V2OverlayScriptId id) {
|
|
||||||
return all.firstWhere((s) => s.id == id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../services/remote_popup_service.dart';
|
|
||||||
|
|
||||||
class RemotePopupHandler {
|
|
||||||
static Future<void> checkAndShow(BuildContext context) async {
|
|
||||||
final popup = await RemotePopupService.fetchPopup();
|
|
||||||
if (popup == null) return;
|
|
||||||
|
|
||||||
final shouldShow = await RemotePopupService.shouldShow(popup);
|
|
||||||
if (!shouldShow) return;
|
|
||||||
|
|
||||||
await RemotePopupService.markShown(popup);
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: true,
|
|
||||||
builder: (_) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text(popup.title),
|
|
||||||
content: Text(popup.body),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text(popup.buttonText),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
Categories:
|
||||||
|
- Connectivity
|
||||||
|
- Social Network
|
||||||
|
License: AGPL-3.0-only
|
||||||
|
AuthorName: Ujwal Chapagain
|
||||||
|
AuthorEmail: notujwal@proton.me
|
||||||
|
SourceCode: https://github.com/Ujwal223/FocusGram
|
||||||
|
IssueTracker: https://github.com/Ujwal223/FocusGram/issues
|
||||||
|
Changelog: https://github.com/Ujwal223/FocusGram/blob/main/CHANGELOG.md
|
||||||
|
|
||||||
|
AutoName: FocusGram
|
||||||
|
|
||||||
|
RepoType: git
|
||||||
|
Repo: https://github.com/Ujwal223/FocusGram
|
||||||
|
|
||||||
|
Builds:
|
||||||
|
- versionName: 1.0.0
|
||||||
|
versionCode: 3
|
||||||
|
commit: v1.0.0
|
||||||
|
output: build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
srclibs:
|
||||||
|
- flutter@stable
|
||||||
|
prebuild:
|
||||||
|
- flutterVersion=$(sed -n -E "s/.*flutter-version:\ '(.*)'/\1/p" .github/workflows/release.yml)
|
||||||
|
- '[[ $flutterVersion ]]'
|
||||||
|
- git -C $$flutter$$ checkout -f $flutterVersion
|
||||||
|
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||||
|
- .flutter/bin/flutter config --no-analytics
|
||||||
|
- .flutter/bin/flutter pub get
|
||||||
|
scanignore:
|
||||||
|
- .flutter/bin/cache
|
||||||
|
scandelete:
|
||||||
|
- .flutter
|
||||||
|
- .pub-cache
|
||||||
|
build:
|
||||||
|
- export PUB_CACHE=$(pwd)/.pub-cache
|
||||||
|
- .flutter/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"
|
||||||
|
|
||||||
|
AutoUpdateMode: Version
|
||||||
|
UpdateCheckMode: Tags
|
||||||
|
VercodeOperation:
|
||||||
|
- '%c * 10 + 1'
|
||||||
|
UpdateCheckData: pubspec.yaml|version:\s.+\+(\d+)|.|version:\s(.+)\+
|
||||||
|
CurrentVersion: 1.0.0
|
||||||
|
CurrentVersionCode: 3
|
||||||
+24
-24
@@ -37,10 +37,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: app_settings
|
name: app_settings
|
||||||
sha256: "64d50e666fd96ae90301bf71205f05019286f940ad6f5fed3d1be19c6af7546a"
|
sha256: "3e46c561441e5820d3a25339bf8b51b9e45a5f686873851a20c257a530917795"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.0"
|
version: "6.1.1"
|
||||||
archive:
|
archive:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -77,10 +77,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -213,10 +213,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: fl_chart
|
name: fl_chart
|
||||||
sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0
|
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.71.0"
|
version: "0.69.2"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -290,10 +290,10 @@ packages:
|
|||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: flutter_launcher_icons
|
name: flutter_launcher_icons
|
||||||
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
|
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.4"
|
version: "0.13.1"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -372,10 +372,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: google_fonts
|
name: google_fonts
|
||||||
sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d"
|
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.0"
|
version: "8.0.2"
|
||||||
gtk:
|
gtk:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -540,18 +540,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.17"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.13.0"
|
version: "0.11.1"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -596,10 +596,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.0.1"
|
version: "8.3.1"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -668,18 +668,18 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: permission_handler
|
name: permission_handler
|
||||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.4.0"
|
version: "12.0.1"
|
||||||
permission_handler_android:
|
permission_handler_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: permission_handler_android
|
name: permission_handler_android
|
||||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.1.0"
|
version: "13.0.1"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -764,10 +764,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.5"
|
version: "2.5.4"
|
||||||
shared_preferences_android:
|
shared_preferences_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -865,10 +865,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.7"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+13
-19
@@ -2,7 +2,7 @@ name: focusgram
|
|||||||
description: "FocusGram is a free, open-source digital wellness tool for Android. Blocks distracting content, sets time limits, and helps you use social media intentionally."
|
description: "FocusGram is a free, open-source digital wellness tool for Android. Blocks distracting content, sets time limits, and helps you use social media intentionally."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 2.0.0
|
version: 1.0.0+3
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.7
|
sdk: ^3.10.7
|
||||||
@@ -11,11 +11,11 @@ dependencies:
|
|||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# WebView engine
|
# WebView engine — Apache 2.0, F-Droid safe, replaces webview_flutter entirely
|
||||||
flutter_inappwebview: ^6.1.5
|
flutter_inappwebview: ^6.1.5
|
||||||
|
|
||||||
# Local key-value persistence — latest stable
|
# Local key-value persistence — latest stable
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.4
|
||||||
|
|
||||||
# Date/time formatting for daily resets — latest stable
|
# Date/time formatting for daily resets — latest stable
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
@@ -28,26 +28,26 @@ dependencies:
|
|||||||
|
|
||||||
# URL launcher for About page links — latest stable
|
# URL launcher for About page links — latest stable
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
package_info_plus: ^9.0.0
|
package_info_plus: ^8.1.2
|
||||||
# Handling Instagram deep links — latest stable
|
# Handling Instagram deep links — latest stable
|
||||||
app_links: ^6.4.1
|
app_links: ^6.3.2
|
||||||
# Open system settings — latest stable
|
# Open system settings — latest stable
|
||||||
app_settings: ^7.0.0
|
app_settings: ^6.1.1
|
||||||
google_fonts: ^8.1.0
|
google_fonts: ^8.0.2
|
||||||
http: ^1.6.0
|
http: ^1.3.0
|
||||||
permission_handler: ^11.4.0
|
permission_handler: ^12.0.1
|
||||||
# Image/file picker for story uploads on Android
|
# Image/file picker for story uploads on Android
|
||||||
image_picker: ^1.2.0
|
image_picker: ^1.1.2
|
||||||
flutter_windowmanager_plus: ^1.0.1
|
flutter_windowmanager_plus: ^1.0.1
|
||||||
|
|
||||||
# Charts for on-device screen time dashboard (MIT)
|
# Charts for on-device screen time dashboard (MIT)
|
||||||
fl_chart: ^0.71.0
|
fl_chart: ^0.69.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.13.1
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
@@ -55,12 +55,6 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/images/focusgram.png
|
- assets/images/focusgram.png
|
||||||
- assets/images/focusgram.ico
|
- assets/images/focusgram.ico
|
||||||
- assets/scripts/ghost_mode.js
|
|
||||||
- assets/scripts/ad_blocker_dom.js
|
|
||||||
- assets/scripts/content_hider.js
|
|
||||||
- assets/scripts/theme_detector.js
|
|
||||||
- assets/scripts/fetch_interceptor.js
|
|
||||||
- assets/scripts/autoplay_blocker.js
|
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
@@ -68,6 +62,6 @@ flutter_launcher_icons:
|
|||||||
image_path: "assets/images/focusgram.png"
|
image_path: "assets/images/focusgram.png"
|
||||||
adaptive_icon_background: "#000000"
|
adaptive_icon_background: "#000000"
|
||||||
adaptive_icon_foreground: "assets/images/focusgram.png"
|
adaptive_icon_foreground: "assets/images/focusgram.png"
|
||||||
min_sdk_android: 24
|
min_sdk_android: 21
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:focusgram/screens/main_webview_page.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('handleFocusGramMediaDownload', () {
|
|
||||||
test('rejects non-http(s) schemes', () async {
|
|
||||||
final launched = <Uri>[];
|
|
||||||
final ok = await handleFocusGramMediaDownload(
|
|
||||||
raw: '{"type":"video","url":"file:///etc/passwd","filename":"x"}',
|
|
||||||
launch: (uri) async => launched.add(uri),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ok, isFalse);
|
|
||||||
expect(launched, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts http(s) instagram-like hosts and calls launcher', () async {
|
|
||||||
final launched = <Uri>[];
|
|
||||||
final ok = await handleFocusGramMediaDownload(
|
|
||||||
raw:
|
|
||||||
'{"type":"video","url":"https://cdninstagram.com/v/1.mp4","filename":"x"}',
|
|
||||||
launch: (uri) async => launched.add(uri),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ok, isTrue);
|
|
||||||
expect(launched, hasLength(1));
|
|
||||||
expect(launched.first.scheme, 'https');
|
|
||||||
expect(launched.first.host.toLowerCase(), contains('cdninstagram.com'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects non-instagram hosts even if http(s)', () async {
|
|
||||||
final launched = <Uri>[];
|
|
||||||
final ok = await handleFocusGramMediaDownload(
|
|
||||||
raw:
|
|
||||||
'{"type":"video","url":"https://example.com/video.mp4","filename":"x"}',
|
|
||||||
launch: (uri) async => launched.add(uri),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ok, isFalse);
|
|
||||||
expect(launched, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects malformed JSON safely', () async {
|
|
||||||
final launched = <Uri>[];
|
|
||||||
final ok = await handleFocusGramMediaDownload(
|
|
||||||
raw: '{not json',
|
|
||||||
launch: (uri) async => launched.add(uri),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ok, isFalse);
|
|
||||||
expect(launched, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects missing url field', () async {
|
|
||||||
final launched = <Uri>[];
|
|
||||||
final ok = await handleFocusGramMediaDownload(
|
|
||||||
raw: '{"type":"video","filename":"x"}',
|
|
||||||
launch: (uri) async => launched.add(uri),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ok, isFalse);
|
|
||||||
expect(launched, isEmpty);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import 'package:focusgram/services/injection_manager.dart';
|
|
||||||
import 'package:focusgram/services/adblock/adblock_content_blocker_loader.dart';
|
|
||||||
import 'package:focusgram/services/session_manager.dart';
|
|
||||||
import 'package:focusgram/services/settings_service.dart';
|
|
||||||
|
|
||||||
class _FakeJsEvaluator implements JsEvaluator {
|
|
||||||
final List<String> sources = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> evaluateJavascript({required String source}) async {
|
|
||||||
sources.add(source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
SharedPreferences.setMockInitialValues({});
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'v2AdBlockerDomEnabled(true) does NOT trigger sponsored-post JS injection (handled by V2 engine)',
|
|
||||||
() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final sm = SessionManager();
|
|
||||||
final fakeEval = _FakeJsEvaluator();
|
|
||||||
|
|
||||||
final mgr = InjectionManager.forTest(
|
|
||||||
jsEvaluator: fakeEval,
|
|
||||||
prefs: prefs,
|
|
||||||
sessionManager: sm,
|
|
||||||
);
|
|
||||||
|
|
||||||
final settings = SettingsService();
|
|
||||||
await settings.init();
|
|
||||||
await settings.setV2AdBlockerDomEnabled(true);
|
|
||||||
|
|
||||||
expect(settings.v2AdBlockerDomEnabled, isTrue);
|
|
||||||
|
|
||||||
mgr.setSettingsService(settings);
|
|
||||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
|
||||||
|
|
||||||
// Verify that sponsored posts JS injection is NOT triggered by InjectionManager
|
|
||||||
// (it's handled by the V2 DOM Ad Blocker engine instead)
|
|
||||||
final sponsoredPostsInjected = fakeEval.sources.any(
|
|
||||||
(s) => s.contains('hideSponsoredPosts') || s.contains('Sponsored'),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
sponsoredPostsInjected,
|
|
||||||
isFalse,
|
|
||||||
reason:
|
|
||||||
'Sponsored posts blocking is now handled by V2 DOM Ad Blocker, not JS injection',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
'adblock parser extracts strict host rules and ignores allow/cosmetic rules',
|
|
||||||
() {
|
|
||||||
final hosts = AdblockContentBlockerLoader.parseHostsFromFilterText('''
|
|
||||||
! comment
|
|
||||||
[Adblock Plus 2.0]
|
|
||||||
||ads.example.com^
|
|
||||||
||tracker.example.net/path.js\$third-party
|
|
||||||
@@||allowed.example.com^
|
|
||||||
example.com##.sponsored
|
|
||||||
||wild*.example.com^
|
|
||||||
||bad,domain.example^
|
|
||||||
||sub.adguard.example.org^\$script,third-party
|
|
||||||
''');
|
|
||||||
|
|
||||||
expect(
|
|
||||||
hosts,
|
|
||||||
containsAll({
|
|
||||||
'ads.example.com',
|
|
||||||
'tracker.example.net',
|
|
||||||
'sub.adguard.example.org',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(hosts, isNot(contains('allowed.example.com')));
|
|
||||||
expect(hosts, isNot(contains('wild*.example.com')));
|
|
||||||
expect(hosts, isNot(contains('bad,domain.example')));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:focusgram/services/injection_manager.dart';
|
|
||||||
import 'package:focusgram/services/session_manager.dart';
|
|
||||||
import 'package:focusgram/services/settings_service.dart';
|
|
||||||
|
|
||||||
class _FakeJsEvaluator implements JsEvaluator {
|
|
||||||
final List<String> sources = [];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> evaluateJavascript({required String source}) async {
|
|
||||||
sources.add(source);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
SharedPreferences.setMockInitialValues({});
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'does NOT inject hideSuggestedPosts JS even when legacy setting is true',
|
|
||||||
() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final sm = SessionManager();
|
|
||||||
final fakeEval = _FakeJsEvaluator();
|
|
||||||
|
|
||||||
final mgr = InjectionManager.forTest(
|
|
||||||
jsEvaluator: fakeEval,
|
|
||||||
prefs: prefs,
|
|
||||||
sessionManager: sm,
|
|
||||||
);
|
|
||||||
|
|
||||||
final settings = SettingsService();
|
|
||||||
await settings.init();
|
|
||||||
await settings.setHideSuggestedPosts(true);
|
|
||||||
|
|
||||||
mgr.setSettingsService(settings);
|
|
||||||
|
|
||||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
|
||||||
|
|
||||||
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
|
|
||||||
expect(any, isFalse);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
'does NOT inject hideSuggestedPosts JS when settings.hideSuggestedPosts=false',
|
|
||||||
() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final sm = SessionManager();
|
|
||||||
final fakeEval = _FakeJsEvaluator();
|
|
||||||
|
|
||||||
final mgr = InjectionManager.forTest(
|
|
||||||
jsEvaluator: fakeEval,
|
|
||||||
prefs: prefs,
|
|
||||||
sessionManager: sm,
|
|
||||||
);
|
|
||||||
|
|
||||||
final settings = SettingsService();
|
|
||||||
await settings.init();
|
|
||||||
await settings.setHideSuggestedPosts(false);
|
|
||||||
|
|
||||||
mgr.setSettingsService(settings);
|
|
||||||
|
|
||||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
|
||||||
|
|
||||||
final any = fakeEval.sources.any((s) => s.contains('hideSuggestedPosts'));
|
|
||||||
expect(any, isFalse);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
'injects video downloader JS only when settings.videoDownloadEnabled=true',
|
|
||||||
() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final sm = SessionManager();
|
|
||||||
final fakeEval = _FakeJsEvaluator();
|
|
||||||
|
|
||||||
final mgr = InjectionManager.forTest(
|
|
||||||
jsEvaluator: fakeEval,
|
|
||||||
prefs: prefs,
|
|
||||||
sessionManager: sm,
|
|
||||||
);
|
|
||||||
|
|
||||||
final settings = SettingsService();
|
|
||||||
await settings.init();
|
|
||||||
await settings.setVideoDownloadEnabled(true);
|
|
||||||
|
|
||||||
mgr.setSettingsService(settings);
|
|
||||||
|
|
||||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
|
||||||
|
|
||||||
final any = fakeEval.sources.any(
|
|
||||||
(s) => s.contains('__fgMediaDownloadRunning'),
|
|
||||||
);
|
|
||||||
expect(any, isTrue);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
'does NOT inject video downloader JS when settings.videoDownloadEnabled=false',
|
|
||||||
() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final sm = SessionManager();
|
|
||||||
final fakeEval = _FakeJsEvaluator();
|
|
||||||
|
|
||||||
final mgr = InjectionManager.forTest(
|
|
||||||
jsEvaluator: fakeEval,
|
|
||||||
prefs: prefs,
|
|
||||||
sessionManager: sm,
|
|
||||||
);
|
|
||||||
|
|
||||||
final settings = SettingsService();
|
|
||||||
await settings.init();
|
|
||||||
await settings.setVideoDownloadEnabled(false);
|
|
||||||
|
|
||||||
mgr.setSettingsService(settings);
|
|
||||||
|
|
||||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
|
||||||
|
|
||||||
final any = fakeEval.sources.any(
|
|
||||||
(s) => s.contains('__fgMediaDownloadRunning'),
|
|
||||||
);
|
|
||||||
expect(any, isFalse);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('injects home feed scroll lock flag when enabled', () async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final sm = SessionManager();
|
|
||||||
final fakeEval = _FakeJsEvaluator();
|
|
||||||
|
|
||||||
final mgr = InjectionManager.forTest(
|
|
||||||
jsEvaluator: fakeEval,
|
|
||||||
prefs: prefs,
|
|
||||||
sessionManager: sm,
|
|
||||||
);
|
|
||||||
|
|
||||||
final settings = SettingsService();
|
|
||||||
await settings.init();
|
|
||||||
await settings.setBlockHomeFeedScrollInternal(true);
|
|
||||||
|
|
||||||
mgr.setSettingsService(settings);
|
|
||||||
|
|
||||||
await mgr.runAllPostLoadInjections('https://www.instagram.com/');
|
|
||||||
|
|
||||||
final any = fakeEval.sources.any(
|
|
||||||
(s) => s.contains('window.__fgBlockHomeFeedScroll = true;'),
|
|
||||||
);
|
|
||||||
expect(any, isTrue);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:focusgram/services/injection_controller.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('InjectionController reels blocker', () {
|
|
||||||
test('includes strict reels blocker JS when sessionActive=false', () {
|
|
||||||
final js = InjectionController.buildInjectionJS(
|
|
||||||
sessionActive: false,
|
|
||||||
blurExplore: false,
|
|
||||||
blurReels: false,
|
|
||||||
tapToUnblur: false,
|
|
||||||
enableTextSelection: false,
|
|
||||||
hideSuggestedPosts: false,
|
|
||||||
hideSponsoredPosts: false,
|
|
||||||
hideLikeCounts: false,
|
|
||||||
hideFollowerCounts: false,
|
|
||||||
hideExploreTab: false,
|
|
||||||
hideReelsTab: false,
|
|
||||||
hideShopTab: false,
|
|
||||||
disableReelsEntirely: false,
|
|
||||||
blockHomeFeedScroll: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(js, contains('window.__fgReelsBlockPatched'));
|
|
||||||
expect(js, contains("window.location.href = '/reels/?fg=blocked';"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'does NOT include strict reels blocker JS when sessionActive=true',
|
|
||||||
() {
|
|
||||||
final js = InjectionController.buildInjectionJS(
|
|
||||||
sessionActive: true,
|
|
||||||
blurExplore: false,
|
|
||||||
blurReels: false,
|
|
||||||
tapToUnblur: false,
|
|
||||||
enableTextSelection: false,
|
|
||||||
hideSuggestedPosts: false,
|
|
||||||
hideSponsoredPosts: false,
|
|
||||||
hideLikeCounts: false,
|
|
||||||
hideFollowerCounts: false,
|
|
||||||
hideExploreTab: false,
|
|
||||||
hideReelsTab: false,
|
|
||||||
hideShopTab: false,
|
|
||||||
disableReelsEntirely: false,
|
|
||||||
blockHomeFeedScroll: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(js, isNot(contains('window.__fgReelsBlockPatched')));
|
|
||||||
expect(
|
|
||||||
js,
|
|
||||||
isNot(contains("window.location.href = '/reels/?fg=blocked';")),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:focusgram/services/screen_time_service.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
SharedPreferences.setMockInitialValues({});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('init loads persisted secondsByDate', () async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(
|
|
||||||
ScreenTimeService.prefKey,
|
|
||||||
'{"2026-01-01": 42, "2026-01-02": 7}',
|
|
||||||
);
|
|
||||||
|
|
||||||
final s = ScreenTimeService();
|
|
||||||
await s.init();
|
|
||||||
|
|
||||||
expect(s.secondsByDate['2026-01-01'], 42);
|
|
||||||
expect(s.secondsByDate['2026-01-02'], 7);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resetAll clears stored data and in-memory map', () async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(ScreenTimeService.prefKey, '{"2026-01-01": 42}');
|
|
||||||
|
|
||||||
final s = ScreenTimeService();
|
|
||||||
await s.init();
|
|
||||||
expect(s.secondsByDate.isNotEmpty, isTrue);
|
|
||||||
|
|
||||||
await s.resetAll();
|
|
||||||
expect(s.secondsByDate, isEmpty);
|
|
||||||
|
|
||||||
final raw = prefs.getString(ScreenTimeService.prefKey);
|
|
||||||
expect(raw, isNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'startTracking increments today seconds and stopTracking persists',
|
|
||||||
() async {
|
|
||||||
final s = ScreenTimeService();
|
|
||||||
await s.init();
|
|
||||||
|
|
||||||
final beforeTodayKey = DateTime.now();
|
|
||||||
final todayKey =
|
|
||||||
'${beforeTodayKey.year.toString().padLeft(4, '0')}-'
|
|
||||||
'${beforeTodayKey.month.toString().padLeft(2, '0')}-'
|
|
||||||
'${beforeTodayKey.day.toString().padLeft(2, '0')}';
|
|
||||||
|
|
||||||
s.startTracking();
|
|
||||||
|
|
||||||
// Wait ~2 seconds (test is unit-ish; still acceptable).
|
|
||||||
await Future<void>.delayed(const Duration(seconds: 2));
|
|
||||||
|
|
||||||
s.stopTracking();
|
|
||||||
|
|
||||||
expect(s.secondsByDate[todayKey], isNotNull);
|
|
||||||
expect(s.secondsByDate[todayKey]!, greaterThanOrEqualTo(2));
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final stored = prefs.getString(ScreenTimeService.prefKey);
|
|
||||||
expect(stored, isNotNull);
|
|
||||||
expect(stored, contains(todayKey));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:focusgram/services/settings_service.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
SharedPreferences.setMockInitialValues({});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('SettingsService — Phase 2 Extras', () {
|
|
||||||
test('defaults are OFF for video download/hide suggested', () async {
|
|
||||||
final s = SettingsService();
|
|
||||||
await s.init();
|
|
||||||
|
|
||||||
expect(s.videoDownloadEnabled, isFalse);
|
|
||||||
expect(s.hideSuggestedPosts, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setVideoDownloadEnabled persists', () async {
|
|
||||||
final s = SettingsService();
|
|
||||||
await s.init();
|
|
||||||
|
|
||||||
await s.setVideoDownloadEnabled(true);
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
expect(s.videoDownloadEnabled, isTrue);
|
|
||||||
expect(prefs.getBool('video_download_enabled'), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('setHideSuggestedPosts persists', () async {
|
|
||||||
final s = SettingsService();
|
|
||||||
await s.init();
|
|
||||||
|
|
||||||
await s.setHideSuggestedPosts(true);
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
expect(s.hideSuggestedPosts, isTrue);
|
|
||||||
expect(prefs.getBool('hide_suggested_posts'), isTrue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('SettingsService — minimal mode', () {
|
|
||||||
test(
|
|
||||||
'home feed scroll can be disabled while minimal mode stays on',
|
|
||||||
() async {
|
|
||||||
final s = SettingsService();
|
|
||||||
await s.init();
|
|
||||||
|
|
||||||
await s.setMinimalModeEnabled(true);
|
|
||||||
await s.setBlockHomeFeedScrollInternal(false);
|
|
||||||
|
|
||||||
expect(s.minimalModeEnabled, isTrue);
|
|
||||||
expect(s.blockHomeFeedScroll, isFalse);
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
expect(prefs.getBool('internal_block_home_feed_scroll'), isFalse);
|
|
||||||
expect(prefs.getBool('minimal_mode_enabled'), isTrue);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
'minimal mode turns off when all child features are disabled',
|
|
||||||
() async {
|
|
||||||
final s = SettingsService();
|
|
||||||
await s.init();
|
|
||||||
|
|
||||||
await s.setMinimalModeEnabled(true);
|
|
||||||
await s.setBlurExplore(false);
|
|
||||||
await s.setBlockHomeFeedScrollInternal(false);
|
|
||||||
await s.setDisableReelsEntirelyInternal(false);
|
|
||||||
await s.setDisableExploreEntirelyInternal(false);
|
|
||||||
|
|
||||||
expect(s.minimalModeEnabled, isFalse);
|
|
||||||
expect(s.blurExplore, isFalse);
|
|
||||||
expect(s.blockHomeFeedScroll, isFalse);
|
|
||||||
expect(s.disableReelsEntirely, isFalse);
|
|
||||||
expect(s.disableExploreEntirely, isFalse);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
group('SettingsService — v2 filtering split', () {
|
|
||||||
test(
|
|
||||||
'ad blocker and suggested posts toggles persist independently',
|
|
||||||
() async {
|
|
||||||
final s = SettingsService();
|
|
||||||
await s.init();
|
|
||||||
|
|
||||||
await s.setV2AdBlockerDomEnabled(true);
|
|
||||||
await s.setContentSuggestedEnabled(true);
|
|
||||||
await s.setV2AdBlockerDomEnabled(false);
|
|
||||||
|
|
||||||
expect(s.v2AdBlockerDomEnabled, isFalse);
|
|
||||||
expect(s.contentSuggested, isTrue);
|
|
||||||
expect(s.v2ContentHiderEnabled, isTrue);
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
expect(prefs.getBool('v2_adblock_dom_enabled'), isFalse);
|
|
||||||
expect(prefs.getBool('content_suggested'), isTrue);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
// android/app/src/main/kotlin/com/focusgram/focusgram/MainActivity.kt
|
|
||||||
//
|
|
||||||
// Ghost mode WebView integration notes
|
|
||||||
|
|
||||||
package com.focusgram.focusgram
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
|
||||||
super.configureFlutterEngine(flutterEngine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// WEBVIEW WIDGET INTEGRATION
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// In your WebView widget (wherever InAppWebView is constructed):
|
|
||||||
//
|
|
||||||
// class InstagramWebView extends StatefulWidget { ... }
|
|
||||||
//
|
|
||||||
// class _InstagramWebViewState extends State<InstagramWebView> {
|
|
||||||
// late GhostModeService _ghost;
|
|
||||||
//
|
|
||||||
// @override
|
|
||||||
// void initState() {
|
|
||||||
// super.initState();
|
|
||||||
// _ghost = GhostModeService();
|
|
||||||
// _ghost.load().then((_) {
|
|
||||||
// setState(() {});
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @override
|
|
||||||
// Widget build(BuildContext context) {
|
|
||||||
// return InAppWebView(
|
|
||||||
// initialUrlRequest: URLRequest(
|
|
||||||
// url: WebUri('https://www.instagram.com'),
|
|
||||||
// ),
|
|
||||||
// initialSettings: _ghost.buildWebViewSettings(),
|
|
||||||
// initialUserScripts: UnmodifiableListView(_ghost.buildUserScripts()),
|
|
||||||
// onWebViewCreated: (controller) {
|
|
||||||
// _ghost.onWebViewCreated(controller);
|
|
||||||
// },
|
|
||||||
// onLoadStop: (controller, url) async {
|
|
||||||
// await _ghost.onPageLoaded(url?.uriValue);
|
|
||||||
// },
|
|
||||||
// shouldInterceptRequest: (controller, request) {
|
|
||||||
// return _ghost.shouldInterceptRequest(controller, request);
|
|
||||||
// },
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// PUBSPEC ADDITIONS
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// dependencies:
|
|
||||||
// flutter_inappwebview: ^6.1.5 # already present
|
|
||||||
// shared_preferences: ^2.3.0
|
|
||||||
//
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
// DEBUGGING: HOW TO VERIFY GHOST MODE WORKING
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// 1. Enable WebView remote debugging:
|
|
||||||
// In main.dart: if (kDebugMode) { InAppWebViewController.setWebContentsDebuggingEnabled(true); }
|
|
||||||
//
|
|
||||||
// 2. Open chrome://inspect in desktop Chrome while app runs on USB device.
|
|
||||||
//
|
|
||||||
// 3. In DevTools console, run:
|
|
||||||
// window.fetch('/api/v1/media/seen/test/', {method:'POST'})
|
|
||||||
// .then(r => r.text()).then(console.log)
|
|
||||||
// → Should print: {"status":"ok"} (blocked, not sent)
|
|
||||||
//
|
|
||||||
// 4. Check Network tab — blocked requests should NOT appear (they resolve locally).
|
|
||||||
//
|
|
||||||
// 5. For story view test: open a Story, check Network tab for any request to
|
|
||||||
// /media/seen/ or /viewed_story/ — should be absent.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# ── pubspec.yaml additions for FocusGram Phase 1 ──────────────────────────
|
|
||||||
#
|
|
||||||
# Merge these into your existing pubspec.yaml
|
|
||||||
#
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
flutter:
|
|
||||||
sdk: flutter
|
|
||||||
|
|
||||||
# WebView — already in project
|
|
||||||
flutter_inappwebview: ^6.1.5
|
|
||||||
|
|
||||||
# Persistence
|
|
||||||
shared_preferences: ^2.3.2
|
|
||||||
sqflite: ^2.3.3+1 # Phase 2 history DB — add now, use later
|
|
||||||
path_provider: ^2.1.4
|
|
||||||
|
|
||||||
# Network (Phase 2 download manager — add now)
|
|
||||||
dio: ^5.7.0
|
|
||||||
|
|
||||||
# Gallery save (Phase 2)
|
|
||||||
gal: ^2.3.0
|
|
||||||
|
|
||||||
# Permissions (Phase 2)
|
|
||||||
permission_handler: ^11.3.1
|
|
||||||
|
|
||||||
|
|
||||||
flutter:
|
|
||||||
assets:
|
|
||||||
- assets/scripts/ghost_mode.js
|
|
||||||
- assets/scripts/theme_detector.js
|
|
||||||
- assets/scripts/ad_blocker_dom.js
|
|
||||||
- assets/scripts/content_hider.js
|
|
||||||
- assets/scripts/media_detector.js # empty for now
|
|
||||||
- assets/scripts/history_tracker.js # empty for now
|
|
||||||
- assets/blocklists/easylist_mini.txt # Phase 1.5 — download and bundle
|
|
||||||
|
|
||||||
|
|
||||||
# ── AndroidManifest.xml additions ─────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# In android/app/src/main/AndroidManifest.xml, inside <application>:
|
|
||||||
#
|
|
||||||
# <activity
|
|
||||||
# android:name=".MainActivity"
|
|
||||||
# android:windowSoftInputMode="adjustResize"
|
|
||||||
# android:hardwareAccelerated="true" ← ADD THIS
|
|
||||||
# android:exported="true">
|
|
||||||
#
|
|
||||||
# Also add permissions:
|
|
||||||
# <uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
# <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
||||||
# android:maxSdkVersion="28"/>
|
|
||||||
# <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
|
||||||
# <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
|
||||||
|
|
||||||
|
|
||||||
# ── android/app/src/main/res/values/styles.xml ────────────────────────────
|
|
||||||
#
|
|
||||||
# Add to your launch theme for true edge-to-edge:
|
|
||||||
#
|
|
||||||
# <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
|
||||||
# <item name="android:statusBarColor">@android:color/transparent</item>
|
|
||||||
# <item name="android:navigationBarColor">@android:color/transparent</item>
|
|
||||||
# <item name="android:windowTranslucentStatus">false</item>
|
|
||||||
# <item name="android:windowTranslucentNavigation">false</item>
|
|
||||||
# <item name="android:enforceNavigationBarContrast">false</item> ← Android 10+
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram DOM Ad Blocker
|
|
||||||
* SHould have Removed sponsored posts, "Suggested for you" injections, and ad elements.
|
|
||||||
* Uses structure-based selectors — NOT class names (those change weekly).
|
|
||||||
* Injected at DOCUMENT_END.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ─── Sponsored text signals (Instagram localizes these) ───────────────────
|
|
||||||
// We match the STRUCTURE not just English text.
|
|
||||||
// In IG mobile web, sponsored label appears as a <span> or <div>
|
|
||||||
// that is a direct sibling/child of the article header area.
|
|
||||||
const SPONSORED_TEXTS = new Set([
|
|
||||||
'sponsored', // en
|
|
||||||
'gesponsert', // de
|
|
||||||
'patrocinado', // es/pt
|
|
||||||
'sponsorisé', // fr
|
|
||||||
'sponsorizzato', // it
|
|
||||||
'sponsrad', // sv
|
|
||||||
'sponsoreret', // da
|
|
||||||
'gesponsord', // nl
|
|
||||||
'рекламa', // ru
|
|
||||||
'विज्ञापन', // hi
|
|
||||||
'广告', // zh
|
|
||||||
'ad', // en short
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isSponsoredText = (text) =>
|
|
||||||
SPONSORED_TEXTS.has(text.trim().toLowerCase());
|
|
||||||
|
|
||||||
// ─── Remove a single article element ──────────────────────────────────────
|
|
||||||
const removeArticle = (el) => {
|
|
||||||
// Walk up to find the article or main feed item container
|
|
||||||
const target = el.closest('article') ?? el.closest('div[data-media-id]') ?? el;
|
|
||||||
target.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Core ad scanner ──────────────────────────────────────────────────────
|
|
||||||
const scanAndRemove = () => {
|
|
||||||
// Strategy 1: <a href="/ads/..."> inside feed
|
|
||||||
document.querySelectorAll('a[href*="/ads/"]').forEach((a) => {
|
|
||||||
a.closest('article')?.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 2: Sponsored text in article spans
|
|
||||||
document.querySelectorAll('article').forEach((article) => {
|
|
||||||
const spans = article.querySelectorAll('span, div');
|
|
||||||
for (const span of spans) {
|
|
||||||
if (
|
|
||||||
span.children.length === 0 && // leaf node
|
|
||||||
isSponsoredText(span.textContent)
|
|
||||||
) {
|
|
||||||
article.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 3: "Suggested for you" feed injections
|
|
||||||
document.querySelectorAll('article, section').forEach((el) => {
|
|
||||||
const firstText = el.querySelector('span, div, h4')?.textContent?.trim();
|
|
||||||
if (
|
|
||||||
firstText &&
|
|
||||||
(firstText.toLowerCase().startsWith('suggested') ||
|
|
||||||
firstText.toLowerCase().startsWith('you might') ||
|
|
||||||
firstText.toLowerCase() === 'posts you might like')
|
|
||||||
) {
|
|
||||||
el.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 4: Instagram marks some ad containers with aria-label
|
|
||||||
document
|
|
||||||
.querySelectorAll('[aria-label*="Sponsored"], [aria-label*="Ad"]')
|
|
||||||
.forEach((el) => {
|
|
||||||
el.closest('article')?.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 5: Tracking pixel iframes / hidden images
|
|
||||||
document.querySelectorAll('iframe[width="0"], iframe[height="0"]').forEach((el) => el.remove());
|
|
||||||
document
|
|
||||||
.querySelectorAll('img[width="1"][height="1"], img[width="0"][height="0"]')
|
|
||||||
.forEach((el) => el.remove());
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Run on load + watch for new content ──────────────────────────────────
|
|
||||||
scanAndRemove();
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
// Only scan if nodes were added (skip attribute/text changes)
|
|
||||||
const hasAdditions = mutations.some((m) => m.addedNodes.length > 0);
|
|
||||||
if (hasAdditions) scanAndRemove();
|
|
||||||
});
|
|
||||||
|
|
||||||
const feed = document.querySelector('main') ?? document.body;
|
|
||||||
observer.observe(feed, { childList: true, subtree: true });
|
|
||||||
})();
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram DOM Ad Blocker
|
|
||||||
* Removes sponsored posts, "Suggested for you" injections, and ad elements.
|
|
||||||
* Uses structure-based selectors — NOT class names (those change weekly).
|
|
||||||
* Injected at DOCUMENT_END.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ─── Sponsored text signals (Instagram localizes these) ───────────────────
|
|
||||||
// We match the STRUCTURE not just English text.
|
|
||||||
// In IG mobile web, sponsored label appears as a <span> or <div>
|
|
||||||
// that is a direct sibling/child of the article header area.
|
|
||||||
const SPONSORED_TEXTS = new Set([
|
|
||||||
'sponsored', // en
|
|
||||||
'gesponsert', // de
|
|
||||||
'patrocinado', // es/pt
|
|
||||||
'sponsorisé', // fr
|
|
||||||
'sponsorizzato', // it
|
|
||||||
'sponsrad', // sv
|
|
||||||
'sponsoreret', // da
|
|
||||||
'gesponsord', // nl
|
|
||||||
'рекламa', // ru
|
|
||||||
'विज्ञापन', // hi
|
|
||||||
'广告', // zh
|
|
||||||
'ad', // en short
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isSponsoredText = (text) =>
|
|
||||||
SPONSORED_TEXTS.has(text.trim().toLowerCase());
|
|
||||||
|
|
||||||
// ─── Remove a single article element ──────────────────────────────────────
|
|
||||||
const removeArticle = (el) => {
|
|
||||||
// Walk up to find the article or main feed item container
|
|
||||||
const target = el.closest('article') ?? el.closest('div[data-media-id]') ?? el;
|
|
||||||
target.remove();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Core ad scanner ──────────────────────────────────────────────────────
|
|
||||||
const scanAndRemove = () => {
|
|
||||||
// Strategy 1: <a href="/ads/..."> inside feed
|
|
||||||
document.querySelectorAll('a[href*="/ads/"]').forEach((a) => {
|
|
||||||
a.closest('article')?.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 2: Sponsored text in article spans
|
|
||||||
document.querySelectorAll('article').forEach((article) => {
|
|
||||||
const spans = article.querySelectorAll('span, div');
|
|
||||||
for (const span of spans) {
|
|
||||||
if (
|
|
||||||
span.children.length === 0 && // leaf node
|
|
||||||
isSponsoredText(span.textContent)
|
|
||||||
) {
|
|
||||||
article.remove();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 3: "Suggested for you" feed injections
|
|
||||||
document.querySelectorAll('article, section').forEach((el) => {
|
|
||||||
const firstText = el.querySelector('span, div, h4')?.textContent?.trim();
|
|
||||||
if (
|
|
||||||
firstText &&
|
|
||||||
(firstText.toLowerCase().startsWith('suggested') ||
|
|
||||||
firstText.toLowerCase().startsWith('you might') ||
|
|
||||||
firstText.toLowerCase() === 'posts you might like')
|
|
||||||
) {
|
|
||||||
el.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 4: Instagram marks some ad containers with aria-label
|
|
||||||
document
|
|
||||||
.querySelectorAll('[aria-label*="Sponsored"], [aria-label*="Ad"]')
|
|
||||||
.forEach((el) => {
|
|
||||||
el.closest('article')?.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 5: Tracking pixel iframes / hidden images
|
|
||||||
document.querySelectorAll('iframe[width="0"], iframe[height="0"]').forEach((el) => el.remove());
|
|
||||||
document
|
|
||||||
.querySelectorAll('img[width="1"][height="1"], img[width="0"][height="0"]')
|
|
||||||
.forEach((el) => el.remove());
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Run on load + watch for new content ──────────────────────────────────
|
|
||||||
scanAndRemove();
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
// Only scan if nodes were added (skip attribute/text changes)
|
|
||||||
const hasAdditions = mutations.some((m) => m.addedNodes.length > 0);
|
|
||||||
if (hasAdditions) scanAndRemove();
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Autoplay Blocker
|
|
||||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
|
||||||
* Prevents video autoplay by:
|
|
||||||
* 1. Blocking play() calls on video elements
|
|
||||||
* 2. Disabling autoplay attribute
|
|
||||||
* 3. Removing preload attributes
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
window.__fgBlockAutoplay = false;
|
|
||||||
|
|
||||||
// Override HTMLMediaElement.play() to check our flag
|
|
||||||
const _play = HTMLMediaElement.prototype.play;
|
|
||||||
HTMLMediaElement.prototype.play = function () {
|
|
||||||
if (window.__fgBlockAutoplay) {
|
|
||||||
// Return a resolved promise to avoid breaking Instagram's code
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return _play.call(this);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Override autoplay property setter
|
|
||||||
const _videoDescriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'autoplay') || {};
|
|
||||||
const _originalAutoplaySetter = _videoDescriptor.set;
|
|
||||||
|
|
||||||
Object.defineProperty(HTMLVideoElement.prototype, 'autoplay', {
|
|
||||||
set: function (value) {
|
|
||||||
if (window.__fgBlockAutoplay && value) {
|
|
||||||
// Silently ignore autoplay attempts when blocking is enabled
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_originalAutoplaySetter) {
|
|
||||||
_originalAutoplaySetter.call(this, value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
get: function () {
|
|
||||||
if (_videoDescriptor.get) {
|
|
||||||
return _videoDescriptor.get.call(this);
|
|
||||||
}
|
|
||||||
return this.getAttribute('autoplay') !== null;
|
|
||||||
},
|
|
||||||
enumerable: _videoDescriptor.enumerable,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// On page load and SPA navigation, scan for video elements and remove autoplay
|
|
||||||
const removeAutoplayFromVideos = () => {
|
|
||||||
document.querySelectorAll('video, [role="video"]').forEach(el => {
|
|
||||||
if (window.__fgBlockAutoplay) {
|
|
||||||
el.autoplay = false;
|
|
||||||
el.removeAttribute('autoplay');
|
|
||||||
if (el.paused === false) {
|
|
||||||
el.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run on load and when document changes
|
|
||||||
removeAutoplayFromVideos();
|
|
||||||
|
|
||||||
if (!window.__fgAutoplayObserver) {
|
|
||||||
let _timer = null;
|
|
||||||
window.__fgAutoplayObserver = new MutationObserver(() => {
|
|
||||||
clearTimeout(_timer);
|
|
||||||
_timer = setTimeout(removeAutoplayFromVideos, 500);
|
|
||||||
});
|
|
||||||
window.__fgAutoplayObserver.observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow Flutter to toggle
|
|
||||||
window.__fgSetBlockAutoplay = function (enabled) {
|
|
||||||
window.__fgBlockAutoplay = !!enabled;
|
|
||||||
if (enabled) {
|
|
||||||
removeAutoplayFromVideos();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Content Hider
|
|
||||||
* Toggleable visibility for: stories tray, feed posts, reels, suggested content.
|
|
||||||
* Flutter controls via window.__fgContent.*
|
|
||||||
* Injected at DOCUMENT_END.
|
|
||||||
*
|
|
||||||
* Key fixes applied:
|
|
||||||
* - Blank-feed fix: hideReels uses DOM removal (not display:none) so layout doesn't collapse
|
|
||||||
* - MutationObserver callback now re-applies CSS AND re-runs all hide functions each cycle
|
|
||||||
* - SPA-heartbeat via window event listener re-applies CSS on pushState/replaceState
|
|
||||||
* - Stories tray detection strengthened for fresh SPA navigations
|
|
||||||
* - Suggested posts detection uses multiple text-node matching strategies
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
if (window.__fgContent && window.__fgContent.__focusgramReady) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STYLE_ID = 'fg-content-hider';
|
|
||||||
let hideStories = false;
|
|
||||||
let hidePosts = false;
|
|
||||||
let hideSuggested = false;
|
|
||||||
let hideReels = false;
|
|
||||||
|
|
||||||
// ─── CSS rules ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildCSS() {
|
|
||||||
const selectors = [];
|
|
||||||
|
|
||||||
if (hideStories) {
|
|
||||||
selectors.push(
|
|
||||||
'[role="list"]:has([aria-label*="tory"])',
|
|
||||||
'[role="listbox"]:has([aria-label*="tory"])',
|
|
||||||
'[role="menu"] > ul',
|
|
||||||
'section > div > div:first-child [style*="overflow"]',
|
|
||||||
'[role="list"] [style*="overflow"]',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hidePosts) {
|
|
||||||
selectors.push(
|
|
||||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) article:not([aria-label])',
|
|
||||||
'body:not([path*="direct"]):not([data-fg-path*="direct"]) [data-pressable-container] > article',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// hideReels CSS is intentionally NOT added here.
|
|
||||||
// We use DOM removal instead (see removeReels()) so that room is never left
|
|
||||||
// blank in the feed, and Instagram's infinite-scroll can prove scroll height.
|
|
||||||
|
|
||||||
return selectors.length
|
|
||||||
? selectors.join(',\n') + ' { display: none !important; visibility: hidden !important; }'
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCSS() {
|
|
||||||
if (document.body) {
|
|
||||||
document.body.setAttribute('data-fg-path', window.location.pathname || '/');
|
|
||||||
}
|
|
||||||
let style = document.getElementById(STYLE_ID);
|
|
||||||
if (!style) {
|
|
||||||
style = document.createElement('style');
|
|
||||||
style.id = STYLE_ID;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
style.textContent = buildCSS();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Story tray JS ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function hideStoryTray() {
|
|
||||||
if (!hideStories) return;
|
|
||||||
|
|
||||||
// Strategy 1: <ul> children of a named list or menu
|
|
||||||
document.querySelectorAll('[role="list"] ul, [role="menu"] ul').forEach(function (ul) {
|
|
||||||
try {
|
|
||||||
const items = ul.querySelectorAll('li, button, a');
|
|
||||||
if (items.length < 2) return;
|
|
||||||
ul.style.setProperty('display', 'none', 'important');
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Strategy 2: horizontally scrolling container with circle items
|
|
||||||
document.querySelectorAll('[style*="overflow"], [style*="overflow-x"]').forEach(function (c) {
|
|
||||||
try {
|
|
||||||
if ('' === (window.getComputedStyle(c).overflow + '').replace(/none/g, '')) return;
|
|
||||||
const cands = c.querySelectorAll('li, div[class*="story"], [class*="story"]');
|
|
||||||
if (cands.length < 2) return;
|
|
||||||
const s0 = window.getComputedStyle(cands[0]);
|
|
||||||
if (s0.width && parseFloat(s0.width) <= 90) {
|
|
||||||
c.parentElement && (c.parentElement.style.setProperty('display', 'none', 'important'));
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Suggested posts ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function removeSuggested() {
|
|
||||||
if (!hideSuggested) return;
|
|
||||||
|
|
||||||
var SIGNALS = [
|
|
||||||
'suggested for you',
|
|
||||||
'suggested posts',
|
|
||||||
'suggested reels',
|
|
||||||
'suggested',
|
|
||||||
'because you watched',
|
|
||||||
'because you follow',
|
|
||||||
'you might like',
|
|
||||||
'posts you might like',
|
|
||||||
'accounts you might like',
|
|
||||||
'recommendations',
|
|
||||||
];
|
|
||||||
|
|
||||||
function norm(s) {
|
|
||||||
return (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasSignal(s) {
|
|
||||||
var t = norm(s);
|
|
||||||
if (!t) return false;
|
|
||||||
return SIGNALS.some(function (signal) {
|
|
||||||
if (signal === 'suggested') return t === signal;
|
|
||||||
return t.indexOf(signal) >= 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideContainer(from) {
|
|
||||||
var parent = from;
|
|
||||||
for (var depth = 0; depth < 10 && parent && parent !== document.body; depth++) {
|
|
||||||
var role = parent.getAttribute && parent.getAttribute('role');
|
|
||||||
var tag = parent.tagName;
|
|
||||||
var hasMedia = parent.querySelector && parent.querySelector('img,video,a[href*="/p/"],a[href*="/reel/"]');
|
|
||||||
if (
|
|
||||||
tag === 'ARTICLE' ||
|
|
||||||
tag === 'SECTION' ||
|
|
||||||
role === 'listitem' ||
|
|
||||||
(hasMedia && parent.getBoundingClientRect && parent.getBoundingClientRect().height > 120)
|
|
||||||
) {
|
|
||||||
parent.style.setProperty('display', 'none', 'important');
|
|
||||||
parent.setAttribute('data-fg-hidden-suggested', '1');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
parent = parent.parentElement;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('article, section, [role="listitem"]').forEach(function (node) {
|
|
||||||
try {
|
|
||||||
if (node.getAttribute('data-fg-hidden-suggested') === '1') return;
|
|
||||||
var ownLabel = node.getAttribute('aria-label');
|
|
||||||
if (hasSignal(ownLabel)) { hideContainer(node); return; }
|
|
||||||
var text = norm(node.innerText || node.textContent || '');
|
|
||||||
if (
|
|
||||||
text.indexOf('suggested for you') >= 0 ||
|
|
||||||
text.indexOf('suggested posts') >= 0 ||
|
|
||||||
text.indexOf('suggested reels') >= 0 ||
|
|
||||||
text.indexOf('because you watched') >= 0 ||
|
|
||||||
text.indexOf('because you follow') >= 0
|
|
||||||
) {
|
|
||||||
hideContainer(node);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('span, h1, h2, h3, h4, div[aria-label], a[aria-label]').forEach(function (el) {
|
|
||||||
try {
|
|
||||||
if (hasSignal(el.textContent) || hasSignal(el.getAttribute('aria-label'))) {
|
|
||||||
hideContainer(el);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Reels – DOM REMOVE (not display:none) ─────────────────────────────────
|
|
||||||
// display:none keeps the element in the DOM, so Instagram's virtual-scroll still
|
|
||||||
// reserves the slot → blank gaps. Removing the article from the DOM collapses the
|
|
||||||
// gap cleanly and lets the feed flow naturally.
|
|
||||||
function removeReels() {
|
|
||||||
if (!hideReels) return;
|
|
||||||
|
|
||||||
var toRemove = [];
|
|
||||||
document.querySelectorAll('article').forEach(function (el) {
|
|
||||||
try {
|
|
||||||
// Fast path: check for a reel-signal attribute first
|
|
||||||
var mt = (el.getAttribute('data-media-type') || el.dataset && el.dataset.mediaType || '').trim();
|
|
||||||
if (mt === '2') { toRemove.push(el); return; }
|
|
||||||
|
|
||||||
// Fallback: text-node scan for /reels/ markers
|
|
||||||
var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
||||||
var n;
|
|
||||||
while ((n = walker.nextNode())) {
|
|
||||||
if (n.nodeValue.indexOf('/reels/') >= 0 || n.nodeValue.indexOf('/reel/') >= 0) {
|
|
||||||
toRemove.push(el); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
toRemove.forEach(function (el) { try { el.remove(); } catch (_) {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public API ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
window.__fgContent = {
|
|
||||||
__focusgramReady: true,
|
|
||||||
setHideStories: function (val) { hideStories = !!val; applyCSS(); hideStoryTray(); },
|
|
||||||
setHidePosts: function (val) { hidePosts = !!val; applyCSS(); },
|
|
||||||
setHideSuggested: function (val) {
|
|
||||||
hideSuggested = !!val;
|
|
||||||
applyCSS();
|
|
||||||
if (val) removeSuggested();
|
|
||||||
},
|
|
||||||
setHideReels: function (val) {
|
|
||||||
hideReels = !!val;
|
|
||||||
applyCSS();
|
|
||||||
if (val) removeReels();
|
|
||||||
},
|
|
||||||
applyAll: function (flags) {
|
|
||||||
hideStories = !!flags.stories;
|
|
||||||
hidePosts = !!flags.posts;
|
|
||||||
hideReels = !!flags.reels;
|
|
||||||
hideSuggested = !!flags.suggested;
|
|
||||||
applyCSS();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideStories) hideStoryTray();
|
|
||||||
if (hideReels) removeReels();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── SPA heartbeat ─────────────────────────────────────────────────────────
|
|
||||||
// pushState/replaceState don't fire any DOM event we can listen for.
|
|
||||||
// Hook the methods themselves so we know a navigation happened, then debounce
|
|
||||||
// re-apply. This also catches the case where the MutationObserver was on `body`
|
|
||||||
// and that node got replaced by Instagram's SPA re-render.
|
|
||||||
|
|
||||||
function scheduleReapply() {
|
|
||||||
clearTimeout(window.__fg_applyTimer);
|
|
||||||
window.__fg_applyTimer = setTimeout(function () {
|
|
||||||
applyCSS();
|
|
||||||
if (hideStories) hideStoryTray();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideReels) removeReels();
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
var _origPush = history.pushState;
|
|
||||||
var _origReplace = history.replaceState;
|
|
||||||
|
|
||||||
history.pushState = function () {
|
|
||||||
_origPush.apply(this, arguments);
|
|
||||||
scheduleReapply();
|
|
||||||
};
|
|
||||||
|
|
||||||
history.replaceState = function () {
|
|
||||||
_origReplace.apply(this, arguments);
|
|
||||||
scheduleReapply();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reinforce on popstate too (user hits back/forward)
|
|
||||||
window.addEventListener('popstate', scheduleReapply, { passive: true });
|
|
||||||
// For pushState on the same URL (rare but possible) – poll path briefly
|
|
||||||
window.addEventListener('pageshow', scheduleReapply, { passive: true });
|
|
||||||
window.addEventListener('focus', scheduleReapply, { passive: true });
|
|
||||||
|
|
||||||
// ─── MutationObserver ───────────────────────────────────────────────────────
|
|
||||||
// Monitors for dynamic DOM changes (new rows, lazy-loaded articles) and
|
|
||||||
// re-applies everything on each cycle. Does NOT guard on a per-element timer
|
|
||||||
// that would never re-fire after the body is replaced by SPA re-render.
|
|
||||||
|
|
||||||
if (!window.__fgContentObserver) {
|
|
||||||
window.__fgContentObserver = new MutationObserver(function () {
|
|
||||||
clearTimeout(window.__fg_moTimer);
|
|
||||||
window.__fg_moTimer = setTimeout(function () {
|
|
||||||
applyCSS();
|
|
||||||
if (hideStories) hideStoryTray();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideReels) removeReels();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
// `document.documentElement` survives SPA navigations (body gets replaced
|
|
||||||
// but <html> stays). Observing it catches both subtree mutations and, via
|
|
||||||
// the SPA heartbeat above, re-applies after pushState.
|
|
||||||
window.__fgContentObserver.observe(document.documentElement, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Initial run ────────────────────────────────────────────────────────────
|
|
||||||
applyCSS();
|
|
||||||
if (hideStories) hideStoryTray();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideReels) removeReels();
|
|
||||||
|
|
||||||
// Signal ready — Flutter will call applyAll() with stored prefs
|
|
||||||
if (window.ContentChannel) {
|
|
||||||
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Unified Feed Filter via Fetch Interception
|
|
||||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
|
||||||
*
|
|
||||||
* This script intercepts GraphQL fetch calls and filters feed content based on:
|
|
||||||
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
|
|
||||||
* - Sponsored posts (ad_action_link, ad_header_style)
|
|
||||||
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
|
|
||||||
* - Videos/Reels (is_video, media_type, clips_metadata)
|
|
||||||
* - Autoplay blocking (video autoplay prevention)
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Configuration flags (set by Flutter via prefs)
|
|
||||||
window.__fgFilterConfig = {
|
|
||||||
blockAds: false,
|
|
||||||
blockSponsored: false,
|
|
||||||
blockSuggested: false,
|
|
||||||
blockVideos: false,
|
|
||||||
blockAutoplay: false,
|
|
||||||
blockGraphQLQueryWhenFeedPosts: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is an ad
|
|
||||||
const isAdNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
node.is_ad ||
|
|
||||||
node.ad_action_link ||
|
|
||||||
node.ad_id ||
|
|
||||||
(node.product_type && node.product_type === 'ad') ||
|
|
||||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
|
||||||
(node.__typename && node.__typename === 'GraphAdStory')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is sponsored
|
|
||||||
const isSponsoredNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
(node.ad_action_link && node.ad_action_link.href) ||
|
|
||||||
(node.ad_header_style && node.ad_header_style !== 'none')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is suggested content
|
|
||||||
const isSuggestedNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
const typename = String(node.__typename || '');
|
|
||||||
const reason = JSON.stringify({
|
|
||||||
reason: node.suggested_reason,
|
|
||||||
social_context: node.social_context,
|
|
||||||
title: node.title,
|
|
||||||
header: node.header,
|
|
||||||
label: node.label,
|
|
||||||
}).toLowerCase();
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
node.is_suggested ||
|
|
||||||
node.is_suggested_for_you ||
|
|
||||||
node.is_recommendation ||
|
|
||||||
node.suggested_users ||
|
|
||||||
node.suggested_media ||
|
|
||||||
node.suggested_content ||
|
|
||||||
node.recommendation_source ||
|
|
||||||
typename.includes('Suggested') ||
|
|
||||||
typename.includes('Recommendation') ||
|
|
||||||
reason.includes('suggested') ||
|
|
||||||
reason.includes('recommend')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is a video/reel
|
|
||||||
const isVideoNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
node.is_video ||
|
|
||||||
(node.media_type === 2) ||
|
|
||||||
node.clips_metadata ||
|
|
||||||
(node.__typename && (
|
|
||||||
node.__typename.includes('Clips') ||
|
|
||||||
node.__typename.includes('Video')
|
|
||||||
))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFeedMediaNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
return !!(
|
|
||||||
node.pk ||
|
|
||||||
node.id ||
|
|
||||||
node.code ||
|
|
||||||
node.media_type ||
|
|
||||||
node.image_versions2 ||
|
|
||||||
node.video_versions ||
|
|
||||||
node.carousel_media ||
|
|
||||||
node.__typename?.includes('Media') ||
|
|
||||||
node.__typename?.includes('Timeline')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check for media in carousel
|
|
||||||
const hasVideoInCarousel = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
if (node.media_type === 8) {
|
|
||||||
const edges = node.edge_sidecar_to_children?.edges || [];
|
|
||||||
return edges.some(edge => isVideoNode(edge.node));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main filter function for feed nodes
|
|
||||||
const shouldFilterNode = (node) => {
|
|
||||||
const config = window.__fgFilterConfig;
|
|
||||||
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
if (config.blockGraphQLQueryWhenFeedPosts && isFeedMediaNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check ads
|
|
||||||
if (config.blockAds && isAdNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check sponsored (separate from ads)
|
|
||||||
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check suggested content
|
|
||||||
if (config.blockSuggested && isSuggestedNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check videos/reels
|
|
||||||
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively filter GraphQL response edges
|
|
||||||
const filterEdges = (edges, path = []) => {
|
|
||||||
if (!Array.isArray(edges)) return edges;
|
|
||||||
|
|
||||||
return edges.filter(edge => {
|
|
||||||
if (!edge || !edge.node) return true;
|
|
||||||
const node = edge.node;
|
|
||||||
|
|
||||||
// Keep the edge if it doesn't match any filter
|
|
||||||
if (!shouldFilterNode(node)) return true;
|
|
||||||
|
|
||||||
// Log filtered content for debugging
|
|
||||||
if (window.__fgDebugFilter) {
|
|
||||||
const type = node.__typename || 'Unknown';
|
|
||||||
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively walk GraphQL response and filter edges
|
|
||||||
const walkAndFilter = (obj, visited = new Set()) => {
|
|
||||||
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
|
|
||||||
visited.add(obj);
|
|
||||||
|
|
||||||
// Handle arrays
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
obj.forEach(item => walkAndFilter(item, visited));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for edges array (common GraphQL pattern)
|
|
||||||
if (obj.edges && Array.isArray(obj.edges)) {
|
|
||||||
obj.edges = filterEdges(obj.edges);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse into children
|
|
||||||
for (const key in obj) {
|
|
||||||
if (obj.hasOwnProperty(key) && key !== '__typename') {
|
|
||||||
const val = obj[key];
|
|
||||||
if (val && typeof val === 'object') {
|
|
||||||
walkAndFilter(val, visited);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Override fetch
|
|
||||||
const _fetch = window.fetch.bind(window);
|
|
||||||
|
|
||||||
window.fetch = async function (input, init) {
|
|
||||||
const url = typeof input === 'string'
|
|
||||||
? input
|
|
||||||
: input instanceof URL
|
|
||||||
? input.href
|
|
||||||
: input?.url ?? '';
|
|
||||||
|
|
||||||
// Call original fetch
|
|
||||||
let response = await _fetch(input, init);
|
|
||||||
|
|
||||||
// Only intercept GraphQL feed queries
|
|
||||||
if (!url.includes('/graphql') && !url.includes('/api/v1/feed')) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone response to read body
|
|
||||||
const cloned = response.clone();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
if (!contentType.includes('application/json')) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await cloned.json();
|
|
||||||
|
|
||||||
// Filter the response data
|
|
||||||
walkAndFilter(data);
|
|
||||||
|
|
||||||
// Return modified response
|
|
||||||
return new Response(JSON.stringify(data), {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers: response.headers,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// On error, return original response
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Preserve native function appearance
|
|
||||||
Object.defineProperty(window, 'fetch', {
|
|
||||||
value: window.fetch,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
|
||||||
|
|
||||||
const _xhrOpen = XMLHttpRequest.prototype.open;
|
|
||||||
const _xhrSend = XMLHttpRequest.prototype.send;
|
|
||||||
XMLHttpRequest.prototype.open = function (method, url) {
|
|
||||||
this.__fgUrl = typeof url === 'string' ? url : String(url || '');
|
|
||||||
return _xhrOpen.apply(this, arguments);
|
|
||||||
};
|
|
||||||
XMLHttpRequest.prototype.send = function () {
|
|
||||||
if (
|
|
||||||
window.__fgFilterConfig.blockVideos &&
|
|
||||||
this.__fgUrl &&
|
|
||||||
(this.__fgUrl.includes('/api/v1/clips/') ||
|
|
||||||
this.__fgUrl.includes('/api/v1/discover/'))
|
|
||||||
) {
|
|
||||||
try { this.abort(); } catch (_) {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return _xhrSend.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Allow Flutter to update config flags
|
|
||||||
window.__fgSetFilterConfig = function (config) {
|
|
||||||
if (typeof config === 'object') {
|
|
||||||
Object.assign(window.__fgFilterConfig, config);
|
|
||||||
if (window.__fgDebugFilter) {
|
|
||||||
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enable debug logging
|
|
||||||
window.__fgDebugFilter = false;
|
|
||||||
})();
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Ghost Mode
|
|
||||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
|
||||||
* Blocks story-seen, message-seen, and online-presence signals.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ─── Seen API patterns ────────────────────────────────────────────────────
|
|
||||||
const SEEN_PATTERNS = [
|
|
||||||
/\/api\/v1\/media\/[\w-]+\/seen\//,
|
|
||||||
/\/api\/v1\/stories\/reel\/seen\//,
|
|
||||||
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
|
|
||||||
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
|
|
||||||
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Activity patterns (like, comment) — intercepted for local history ────
|
|
||||||
const ACTIVITY_PATTERNS = [
|
|
||||||
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
|
|
||||||
/\/api\/v1\/web\/comments\/add\//,
|
|
||||||
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
|
|
||||||
];
|
|
||||||
|
|
||||||
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
|
|
||||||
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
|
|
||||||
|
|
||||||
const fakeOkResponse = () =>
|
|
||||||
new Response(JSON.stringify({ status: 'ok' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Fetch override ───────────────────────────────────────────────────────
|
|
||||||
const _fetch = window.fetch.bind(window);
|
|
||||||
|
|
||||||
const patchedFetch = async function (input, init) {
|
|
||||||
const url =
|
|
||||||
typeof input === 'string'
|
|
||||||
? input
|
|
||||||
: input instanceof URL
|
|
||||||
? input.href
|
|
||||||
: input?.url ?? '';
|
|
||||||
|
|
||||||
// Block seen
|
|
||||||
if (isSeen(url)) {
|
|
||||||
if (window.GhostChannel) {
|
|
||||||
window.GhostChannel.postMessage(
|
|
||||||
JSON.stringify({ type: 'seen_blocked', url })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fakeOkResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intercept activity for local history
|
|
||||||
if (isActivity(url) && window.ActivityChannel) {
|
|
||||||
const body = init?.body;
|
|
||||||
const bodyText =
|
|
||||||
body instanceof URLSearchParams
|
|
||||||
? body.toString()
|
|
||||||
: typeof body === 'string'
|
|
||||||
? body
|
|
||||||
: '';
|
|
||||||
window.ActivityChannel.postMessage(
|
|
||||||
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _fetch(input, init);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Disguise as native
|
|
||||||
Object.defineProperty(window, 'fetch', {
|
|
||||||
value: patchedFetch,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
enumerable: true,
|
|
||||||
});
|
|
||||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
|
||||||
window.fetch[Symbol.toStringTag] = 'fetch';
|
|
||||||
|
|
||||||
// ─── XMLHttpRequest override ──────────────────────────────────────────────
|
|
||||||
const _XHROpen = XMLHttpRequest.prototype.open;
|
|
||||||
const _XHRSend = XMLHttpRequest.prototype.send;
|
|
||||||
|
|
||||||
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
|
||||||
this._fg_url = url ?? '';
|
|
||||||
this._fg_method = (method ?? '').toUpperCase();
|
|
||||||
return _XHROpen.call(this, method, url, ...args);
|
|
||||||
};
|
|
||||||
|
|
||||||
XMLHttpRequest.prototype.send = function (body) {
|
|
||||||
if (this._fg_url && isSeen(this._fg_url)) {
|
|
||||||
// Fire readyState 4 with fake success without actually sending
|
|
||||||
const self = this;
|
|
||||||
setTimeout(() => {
|
|
||||||
Object.defineProperty(self, 'readyState', { get: () => 4 });
|
|
||||||
Object.defineProperty(self, 'status', { get: () => 200 });
|
|
||||||
Object.defineProperty(self, 'responseText', {
|
|
||||||
get: () => '{"status":"ok"}',
|
|
||||||
});
|
|
||||||
Object.defineProperty(self, 'response', {
|
|
||||||
get: () => '{"status":"ok"}',
|
|
||||||
});
|
|
||||||
self.dispatchEvent(new Event('readystatechange'));
|
|
||||||
self.dispatchEvent(new Event('load'));
|
|
||||||
}, 10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return _XHRSend.call(this, body);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
|
|
||||||
// Strict WS URL blocking (ghost mode requirement)
|
|
||||||
// sid/cid vary per user/chat; block by endpoint prefix, not exact query.
|
|
||||||
const isBlockedWssUrl = (u) => {
|
|
||||||
if (!u) return false;
|
|
||||||
const urlStr = String(u);
|
|
||||||
|
|
||||||
return (
|
|
||||||
urlStr.startsWith('wss://gateway.instagram.com/ws/streamcontroller') ||
|
|
||||||
urlStr.startsWith('wss://edge-chat.instagram.com/chat?sid=')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Signal to other injected scripts that ghost-mode is active
|
|
||||||
window.__fgGhostModeActive = true;
|
|
||||||
|
|
||||||
const _WS = window.WebSocket;
|
|
||||||
|
|
||||||
function PatchedWebSocket(url, protocols) {
|
|
||||||
const urlStr = typeof url === 'string' ? url : url?.toString?.() ?? '';
|
|
||||||
|
|
||||||
// If the WebSocket URL is one of the blocked endpoints, return an inert WS-like object
|
|
||||||
if (isBlockedWssUrl(urlStr)) {
|
|
||||||
return {
|
|
||||||
send: () => {},
|
|
||||||
close: () => {},
|
|
||||||
readyState: 1,
|
|
||||||
addEventListener: () => {},
|
|
||||||
removeEventListener: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
|
||||||
const _send = ws.send.bind(ws);
|
|
||||||
|
|
||||||
ws.send = function (data) {
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
if (
|
|
||||||
parsed?.op === '4' ||
|
|
||||||
parsed?.op === 'seen' ||
|
|
||||||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
|
|
||||||
) {
|
|
||||||
return; // drop
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
// Text-based seen signal check
|
|
||||||
if (data.includes('"seen"') && data.includes('"thread_id"')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _send(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return ws;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve WebSocket prototype chain so IG's ws checks pass
|
|
||||||
PatchedWebSocket.prototype = _WS.prototype;
|
|
||||||
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
|
|
||||||
PatchedWebSocket.OPEN = _WS.OPEN;
|
|
||||||
PatchedWebSocket.CLOSING = _WS.CLOSING;
|
|
||||||
PatchedWebSocket.CLOSED = _WS.CLOSED;
|
|
||||||
window.WebSocket = PatchedWebSocket;
|
|
||||||
|
|
||||||
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
|
|
||||||
// Only applied if user enables online-status hiding
|
|
||||||
// Wrapped in a named fn so Flutter can call it:
|
|
||||||
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
|
|
||||||
window.__fgEnableOnlineHide = function () {
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
get: () => 'hidden',
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(document, 'hidden', {
|
|
||||||
get: () => true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
document.dispatchEvent(new Event('visibilitychange'));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.__fgDisableOnlineHide = function () {
|
|
||||||
// Restore by deleting the overrides (falls back to native getter)
|
|
||||||
delete document.visibilityState;
|
|
||||||
delete document.hidden;
|
|
||||||
document.dispatchEvent(new Event('visibilitychange'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Signal to Flutter that ghost mode JS is active
|
|
||||||
if (window.GhostChannel) {
|
|
||||||
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Theme Detector
|
|
||||||
* Reads Instagram's background + bottom nav color and reports to Flutter.
|
|
||||||
* Injected at DOCUMENT_END so DOM is ready.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const parseRgb = (str) => {
|
|
||||||
// Parses "rgb(255, 255, 255)" or "rgba(0, 0, 0, 1)" → { r, g, b, a }
|
|
||||||
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
||||||
if (!m) return null;
|
|
||||||
return {
|
|
||||||
r: parseInt(m[1]),
|
|
||||||
g: parseInt(m[2]),
|
|
||||||
b: parseInt(m[3]),
|
|
||||||
a: m[4] !== undefined ? parseFloat(m[4]) : 1,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const toHex = ({ r, g, b }) =>
|
|
||||||
'#' +
|
|
||||||
[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
const detectColors = () => {
|
|
||||||
// Background — Instagram sets it on <body> or a root div
|
|
||||||
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
|
||||||
|
|
||||||
// Bottom nav — IG mobile web renders a fixed bottom bar
|
|
||||||
// Target by role="navigation" or position:fixed at bottom
|
|
||||||
let navBg = bodyBg;
|
|
||||||
const navCandidates = document.querySelectorAll(
|
|
||||||
'nav, [role="navigation"], div[style*="bottom"]'
|
|
||||||
);
|
|
||||||
for (const el of navCandidates) {
|
|
||||||
const style = getComputedStyle(el);
|
|
||||||
if (
|
|
||||||
style.position === 'fixed' &&
|
|
||||||
parseInt(style.bottom) <= 10 &&
|
|
||||||
style.backgroundColor !== 'rgba(0, 0, 0, 0)'
|
|
||||||
) {
|
|
||||||
navBg = style.backgroundColor;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyColor = parseRgb(bodyBg);
|
|
||||||
const navColor = parseRgb(navBg);
|
|
||||||
|
|
||||||
if (!bodyColor) return;
|
|
||||||
|
|
||||||
// Determine dark/light
|
|
||||||
const luminance = (0.299 * bodyColor.r + 0.587 * bodyColor.g + 0.114 * bodyColor.b) / 255;
|
|
||||||
const isDark = luminance < 0.5;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
bodyHex: toHex(bodyColor),
|
|
||||||
navHex: navColor ? toHex(navColor) : toHex(bodyColor),
|
|
||||||
isDark,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (window.ThemeChannel) {
|
|
||||||
window.ThemeChannel.postMessage(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run on load
|
|
||||||
detectColors();
|
|
||||||
|
|
||||||
// Watch for Instagram's dark mode toggle (adds/removes class on <html>)
|
|
||||||
const observer = new MutationObserver(detectColors);
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['class', 'style', 'color-scheme'],
|
|
||||||
});
|
|
||||||
observer.observe(document.body, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['class', 'style'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also run after navigation (Instagram is SPA, URL changes without reload)
|
|
||||||
let lastUrl = location.href;
|
|
||||||
new MutationObserver(() => {
|
|
||||||
if (location.href !== lastUrl) {
|
|
||||||
lastUrl = location.href;
|
|
||||||
setTimeout(detectColors, 300); // small delay for IG to render new page
|
|
||||||
}
|
|
||||||
}).observe(document.body, { childList: true, subtree: true });
|
|
||||||
})();
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
import '../theme/system_ui_manager.dart';
|
|
||||||
|
|
||||||
typedef ActivityCallback = void Function(Map<String, dynamic> event);
|
|
||||||
|
|
||||||
class ChannelRegistry {
|
|
||||||
final ActivityCallback? onActivityEvent;
|
|
||||||
|
|
||||||
const ChannelRegistry({this.onActivityEvent});
|
|
||||||
|
|
||||||
// ── Build all JavaScript channels ─────────────────────────────────────────
|
|
||||||
Set<JavaScriptChannel> build() {
|
|
||||||
return {
|
|
||||||
_ghostChannel(),
|
|
||||||
_themeChannel(),
|
|
||||||
_contentChannel(),
|
|
||||||
_activityChannel(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
JavaScriptChannel _ghostChannel() => JavaScriptChannel(
|
|
||||||
name: 'GhostChannel',
|
|
||||||
onMessageReceived: (msg) {
|
|
||||||
try {
|
|
||||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint('[Ghost] ${data['type']} — ${data['url'] ?? ''}');
|
|
||||||
}
|
|
||||||
// In release: silent. Could surface to a debug overlay in dev builds.
|
|
||||||
} catch (_) {}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
JavaScriptChannel _themeChannel() => JavaScriptChannel(
|
|
||||||
name: 'ThemeChannel',
|
|
||||||
onMessageReceived: (msg) {
|
|
||||||
SystemUiManager.applyFromThemePayload(msg.message);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
JavaScriptChannel _contentChannel() => JavaScriptChannel(
|
|
||||||
name: 'ContentChannel',
|
|
||||||
onMessageReceived: (msg) {
|
|
||||||
// 'ready' signal — engine pushes flags back via evaluateJavascript
|
|
||||||
// handled in ScriptEngine.injectDocumentEndScripts()
|
|
||||||
if (kDebugMode) debugPrint('[Content] ${msg.message}');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
JavaScriptChannel _activityChannel() => JavaScriptChannel(
|
|
||||||
name: 'ActivityChannel',
|
|
||||||
onMessageReceived: (msg) {
|
|
||||||
try {
|
|
||||||
final data = jsonDecode(msg.message) as Map<String, dynamic>;
|
|
||||||
onActivityEvent?.call(data);
|
|
||||||
} catch (_) {}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Content Hider
|
|
||||||
* Toggleable visibility for: stories tray, feed posts, suggested content.
|
|
||||||
* Flutter controls via window.__fgContent.*
|
|
||||||
* Injected at DOCUMENT_END.
|
|
||||||
*
|
|
||||||
* Improvements:
|
|
||||||
* - Better story tray detection using multiple strategies
|
|
||||||
* - Overlay for hidden feed content with loading indicator
|
|
||||||
* - Improved suggested posts detection
|
|
||||||
* - Fixed reels hiding to avoid blank feed issues
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const STYLE_ID = 'fg-content-hider';
|
|
||||||
const OVERLAY_ID = 'fg-content-overlay';
|
|
||||||
let hideStories = false;
|
|
||||||
let hidePosts = false;
|
|
||||||
let hideSuggested = false;
|
|
||||||
let hideReels = false;
|
|
||||||
|
|
||||||
// ─── CSS rules ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const buildCSS = () => {
|
|
||||||
let css = '';
|
|
||||||
|
|
||||||
if (hideStories) {
|
|
||||||
// Story tray: IG mobile web renders as a scrollable <ul> of circles
|
|
||||||
// near the top of the main feed. We target the outermost container
|
|
||||||
// by its scroll behaviour and presence of story-like items.
|
|
||||||
css += `
|
|
||||||
/* Story tray */
|
|
||||||
div[style*="overflow-x"] > ul,
|
|
||||||
div[role="menu"] > ul,
|
|
||||||
section > div > div:first-child ul[style*="scroll"] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hidePosts) {
|
|
||||||
// Feed articles — but NOT DM threads or profile pages
|
|
||||||
// Only apply on /, /reels/ — not /direct/ or /p/ or /@username/
|
|
||||||
css += `
|
|
||||||
/* Feed posts */
|
|
||||||
main article {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hideReels) {
|
|
||||||
css += `
|
|
||||||
/* Reels in feed */
|
|
||||||
article:has(video) {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return css;
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyCSS = () => {
|
|
||||||
let style = document.getElementById(STYLE_ID);
|
|
||||||
if (!style) {
|
|
||||||
style = document.createElement('style');
|
|
||||||
style.id = STYLE_ID;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
style.textContent = buildCSS();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── JS-based removal for suggested (CSS can't catch dynamic text) ────────
|
|
||||||
|
|
||||||
const removeSuggested = () => {
|
|
||||||
if (!hideSuggested) return;
|
|
||||||
document.querySelectorAll('article, section, div').forEach((el) => {
|
|
||||||
const firstLeaf = el.querySelector('span:not(:has(*)), h4');
|
|
||||||
if (!firstLeaf) return;
|
|
||||||
const t = firstLeaf.textContent.trim().toLowerCase();
|
|
||||||
if (
|
|
||||||
t === 'suggested for you' ||
|
|
||||||
t === 'you might like' ||
|
|
||||||
t === 'suggested posts' ||
|
|
||||||
t === 'posts you might like'
|
|
||||||
) {
|
|
||||||
(el.closest('article') ?? el).remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Story tray JS fallback (for when CSS selector misses) ───────────────
|
|
||||||
|
|
||||||
const hideStoryTrayJS = () => {
|
|
||||||
if (!hideStories) return;
|
|
||||||
document.querySelectorAll('ul').forEach((ul) => {
|
|
||||||
const items = ul.querySelectorAll('li');
|
|
||||||
if (items.length < 2) return;
|
|
||||||
// Story bubbles: li contains a button with a circular image
|
|
||||||
const first = items[0];
|
|
||||||
const hasCircleImg =
|
|
||||||
first.querySelector('canvas') ||
|
|
||||||
first.querySelector('img') ||
|
|
||||||
first.querySelector('button');
|
|
||||||
const isHorizontal = ul.scrollWidth > ul.clientWidth;
|
|
||||||
if (hasCircleImg && isHorizontal) {
|
|
||||||
ul.style.setProperty('display', 'none', 'important');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Public API — Flutter calls these via evaluateJavascript ─────────────
|
|
||||||
|
|
||||||
window.__fgContent = {
|
|
||||||
setHideStories: (val) => {
|
|
||||||
hideStories = !!val;
|
|
||||||
applyCSS();
|
|
||||||
hideStoryTrayJS();
|
|
||||||
},
|
|
||||||
setHidePosts: (val) => {
|
|
||||||
hidePosts = !!val;
|
|
||||||
applyCSS();
|
|
||||||
},
|
|
||||||
setHideReels: (val) => {
|
|
||||||
hideReels = !!val;
|
|
||||||
applyCSS();
|
|
||||||
},
|
|
||||||
setHideSuggested: (val) => {
|
|
||||||
hideSuggested = !!val;
|
|
||||||
if (val) removeSuggested();
|
|
||||||
},
|
|
||||||
applyAll: (flags) => {
|
|
||||||
hideStories = !!flags.stories;
|
|
||||||
hidePosts = !!flags.posts;
|
|
||||||
hideReels = !!flags.reels;
|
|
||||||
hideSuggested = !!flags.suggested;
|
|
||||||
applyCSS();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideStories) hideStoryTrayJS();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── MutationObserver to re-apply on SPA navigation ──────────────────────
|
|
||||||
|
|
||||||
let lastUrl = location.href;
|
|
||||||
const mo = new MutationObserver(() => {
|
|
||||||
if (location.href !== lastUrl) {
|
|
||||||
lastUrl = location.href;
|
|
||||||
setTimeout(() => {
|
|
||||||
applyCSS();
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
if (hideStories) hideStoryTrayJS();
|
|
||||||
}, 400);
|
|
||||||
}
|
|
||||||
if (hideSuggested) removeSuggested();
|
|
||||||
});
|
|
||||||
|
|
||||||
mo.observe(document.body, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
// Signal ready — Flutter will call applyAll() with stored prefs
|
|
||||||
if (window.ContentChannel) {
|
|
||||||
window.ContentChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Unified Feed Filter via Fetch Interception
|
|
||||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
|
||||||
*
|
|
||||||
* This script intercepts GraphQL fetch calls and filters feed content based on:
|
|
||||||
* - Ads (is_ad, ad_action_link, product_type, ad_id, ad_header_style)
|
|
||||||
* - Sponsored posts (ad_action_link, ad_header_style)
|
|
||||||
* - Suggested posts (is_suggested, is_suggested_for_you, __typename)
|
|
||||||
* - Videos/Reels (is_video, media_type, clips_metadata)
|
|
||||||
* - Autoplay blocking (video autoplay prevention)
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Configuration flags (set by Flutter via prefs)
|
|
||||||
window.__fgFilterConfig = {
|
|
||||||
blockAds: false,
|
|
||||||
blockSponsored: false,
|
|
||||||
blockSuggested: false,
|
|
||||||
blockVideos: false,
|
|
||||||
blockAutoplay: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is an ad
|
|
||||||
const isAdNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
return !!(
|
|
||||||
node.is_ad ||
|
|
||||||
node.ad_id ||
|
|
||||||
node.ad_action_link ||
|
|
||||||
node.ad_action_links?.length > 0 ||
|
|
||||||
node.is_paid_partnership ||
|
|
||||||
node.sponsor_tags?.length > 0 ||
|
|
||||||
(node.commerciality_status === 'ad') ||
|
|
||||||
(node.commerciality_status === 'shoppable_feed_ad') ||
|
|
||||||
(node.product_type === 'ad') ||
|
|
||||||
(node.ad_header_style && node.ad_header_style !== 'none') ||
|
|
||||||
node.__typename === 'GraphAdStory' ||
|
|
||||||
node.__typename === 'XDTAdFeedUnit' ||
|
|
||||||
(node.__typename?.toLowerCase().includes('ad'))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is sponsored
|
|
||||||
const isSponsoredNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
(node.ad_action_link && node.ad_action_link.href) ||
|
|
||||||
(node.ad_header_style && node.ad_header_style !== 'none')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is suggested content
|
|
||||||
const isSuggestedNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
node.is_suggested ||
|
|
||||||
node.is_suggested_for_you ||
|
|
||||||
(node.__typename && node.__typename.includes('Suggested'))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check if a node is a video/reel
|
|
||||||
const isVideoNode = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
return !!(
|
|
||||||
node.is_video ||
|
|
||||||
(node.media_type === 2) ||
|
|
||||||
node.clips_metadata ||
|
|
||||||
(node.__typename && (
|
|
||||||
node.__typename.includes('Clips') ||
|
|
||||||
node.__typename.includes('Video')
|
|
||||||
))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: Check for media in carousel
|
|
||||||
const hasVideoInCarousel = (node) => {
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
if (node.media_type === 8) {
|
|
||||||
const edges = node.edge_sidecar_to_children?.edges || [];
|
|
||||||
return edges.some(edge => isVideoNode(edge.node));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main filter function for feed nodes
|
|
||||||
const shouldFilterNode = (node) => {
|
|
||||||
const config = window.__fgFilterConfig;
|
|
||||||
|
|
||||||
if (!node || typeof node !== 'object') return false;
|
|
||||||
|
|
||||||
// Check ads
|
|
||||||
if (config.blockAds && isAdNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check sponsored (separate from ads)
|
|
||||||
if (config.blockSponsored && isSponsoredNode(node) && !isAdNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check suggested content
|
|
||||||
if (config.blockSuggested && isSuggestedNode(node)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check videos/reels
|
|
||||||
if (config.blockVideos && (isVideoNode(node) || hasVideoInCarousel(node))) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively filter GraphQL response edges
|
|
||||||
const filterEdges = (edges, path = []) => {
|
|
||||||
if (!Array.isArray(edges)) return edges;
|
|
||||||
|
|
||||||
return edges.filter(edge => {
|
|
||||||
if (!edge || !edge.node) return true;
|
|
||||||
const node = edge.node;
|
|
||||||
|
|
||||||
// Keep the edge if it doesn't match any filter
|
|
||||||
if (!shouldFilterNode(node)) return true;
|
|
||||||
|
|
||||||
// Log filtered content for debugging
|
|
||||||
if (window.__fgDebugFilter) {
|
|
||||||
const type = node.__typename || 'Unknown';
|
|
||||||
console.debug('[FocusGram Filter]', `Filtered ${type} at ${path.join('/')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Recursively walk GraphQL response and filter edges
|
|
||||||
const walkAndFilter = (obj, visited = new Set()) => {
|
|
||||||
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
|
|
||||||
visited.add(obj);
|
|
||||||
|
|
||||||
// Handle arrays
|
|
||||||
if (Array.isArray(obj)) {
|
|
||||||
obj.forEach(item => walkAndFilter(item, visited));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for edges array (common GraphQL pattern)
|
|
||||||
if (obj.edges && Array.isArray(obj.edges)) {
|
|
||||||
obj.edges = filterEdges(obj.edges);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recurse into children
|
|
||||||
for (const key in obj) {
|
|
||||||
if (obj.hasOwnProperty(key) && key !== '__typename') {
|
|
||||||
const val = obj[key];
|
|
||||||
if (val && typeof val === 'object') {
|
|
||||||
walkAndFilter(val, visited);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Override fetch
|
|
||||||
const _fetch = window.fetch.bind(window);
|
|
||||||
|
|
||||||
window.fetch = async function (input, init) {
|
|
||||||
const url = typeof input === 'string'
|
|
||||||
? input
|
|
||||||
: input instanceof URL
|
|
||||||
? input.href
|
|
||||||
: input?.url ?? '';
|
|
||||||
|
|
||||||
// Call original fetch
|
|
||||||
let response = await _fetch(input, init);
|
|
||||||
|
|
||||||
// Only intercept GraphQL feed queries
|
|
||||||
if (!url.includes('/graphql') && !url.includes('/api/v1/feed')) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone response to read body
|
|
||||||
const cloned = response.clone();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
if (!contentType.includes('application/json')) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await cloned.json();
|
|
||||||
|
|
||||||
// Filter the response data
|
|
||||||
walkAndFilter(data);
|
|
||||||
|
|
||||||
// Return modified response
|
|
||||||
return new Response(JSON.stringify(data), {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers: response.headers,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// On error, return original response
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Preserve native function appearance
|
|
||||||
Object.defineProperty(window, 'fetch', {
|
|
||||||
value: window.fetch,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
|
||||||
|
|
||||||
// Allow Flutter to update config flags
|
|
||||||
window.__fgSetFilterConfig = function (config) {
|
|
||||||
if (typeof config === 'object') {
|
|
||||||
Object.assign(window.__fgFilterConfig, config);
|
|
||||||
if (window.__fgDebugFilter) {
|
|
||||||
console.debug('[FocusGram Filter] Config updated:', window.__fgFilterConfig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enable debug logging
|
|
||||||
window.__fgDebugFilter = false;
|
|
||||||
})();
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Ghost Mode
|
|
||||||
* Injected at DOCUMENT_START — before Instagram's JS loads.
|
|
||||||
* Blocks story-seen, message-seen, and online-presence signals.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ─── Seen API patterns ────────────────────────────────────────────────────
|
|
||||||
const SEEN_PATTERNS = [
|
|
||||||
/\/api\/v1\/media\/[\w-]+\/seen\//,
|
|
||||||
/\/api\/v1\/stories\/reel\/seen\//,
|
|
||||||
/\/api\/v1\/direct_v2\/threads\/[\w-]+\/seen\//,
|
|
||||||
/\/api\/v1\/direct_v2\/visual_message\/[\w-]+\/seen\//,
|
|
||||||
/\/api\/v1\/live\/[\w-]+\/comment\/seen\//,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Activity patterns (like, comment) — intercepted for local history ────
|
|
||||||
const ACTIVITY_PATTERNS = [
|
|
||||||
/\/api\/v1\/web\/likes\/[\w-]+\/like\//,
|
|
||||||
/\/api\/v1\/web\/comments\/add\//,
|
|
||||||
/\/api\/v1\/friendships\/[\w-]+\/follow\//,
|
|
||||||
];
|
|
||||||
|
|
||||||
const isSeen = (url) => SEEN_PATTERNS.some((p) => p.test(url));
|
|
||||||
const isActivity = (url) => ACTIVITY_PATTERNS.some((p) => p.test(url));
|
|
||||||
|
|
||||||
const fakeOkResponse = () =>
|
|
||||||
new Response(JSON.stringify({ status: 'ok' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Fetch override ───────────────────────────────────────────────────────
|
|
||||||
const _fetch = window.fetch.bind(window);
|
|
||||||
|
|
||||||
const patchedFetch = async function (input, init) {
|
|
||||||
const url =
|
|
||||||
typeof input === 'string'
|
|
||||||
? input
|
|
||||||
: input instanceof URL
|
|
||||||
? input.href
|
|
||||||
: input?.url ?? '';
|
|
||||||
|
|
||||||
// Block seen
|
|
||||||
if (isSeen(url)) {
|
|
||||||
if (window.GhostChannel) {
|
|
||||||
window.GhostChannel.postMessage(
|
|
||||||
JSON.stringify({ type: 'seen_blocked', url })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fakeOkResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intercept activity for local history
|
|
||||||
if (isActivity(url) && window.ActivityChannel) {
|
|
||||||
const body = init?.body;
|
|
||||||
const bodyText =
|
|
||||||
body instanceof URLSearchParams
|
|
||||||
? body.toString()
|
|
||||||
: typeof body === 'string'
|
|
||||||
? body
|
|
||||||
: '';
|
|
||||||
window.ActivityChannel.postMessage(
|
|
||||||
JSON.stringify({ url, body: bodyText, timestamp: Date.now() })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _fetch(input, init);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Disguise as native
|
|
||||||
Object.defineProperty(window, 'fetch', {
|
|
||||||
value: patchedFetch,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
enumerable: true,
|
|
||||||
});
|
|
||||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
|
||||||
window.fetch[Symbol.toStringTag] = 'fetch';
|
|
||||||
|
|
||||||
// ─── XMLHttpRequest override ──────────────────────────────────────────────
|
|
||||||
const _XHROpen = XMLHttpRequest.prototype.open;
|
|
||||||
const _XHRSend = XMLHttpRequest.prototype.send;
|
|
||||||
|
|
||||||
XMLHttpRequest.prototype.open = function (method, url, ...args) {
|
|
||||||
this._fg_url = url ?? '';
|
|
||||||
this._fg_method = (method ?? '').toUpperCase();
|
|
||||||
return _XHROpen.call(this, method, url, ...args);
|
|
||||||
};
|
|
||||||
|
|
||||||
XMLHttpRequest.prototype.send = function (body) {
|
|
||||||
if (this._fg_url && isSeen(this._fg_url)) {
|
|
||||||
// Fire readyState 4 with fake success without actually sending
|
|
||||||
const self = this;
|
|
||||||
setTimeout(() => {
|
|
||||||
Object.defineProperty(self, 'readyState', { get: () => 4 });
|
|
||||||
Object.defineProperty(self, 'status', { get: () => 200 });
|
|
||||||
Object.defineProperty(self, 'responseText', {
|
|
||||||
get: () => '{"status":"ok"}',
|
|
||||||
});
|
|
||||||
Object.defineProperty(self, 'response', {
|
|
||||||
get: () => '{"status":"ok"}',
|
|
||||||
});
|
|
||||||
self.dispatchEvent(new Event('readystatechange'));
|
|
||||||
self.dispatchEvent(new Event('load'));
|
|
||||||
}, 10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return _XHRSend.call(this, body);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── WebSocket intercept (message-seen via WS) ────────────────────────────
|
|
||||||
const _WS = window.WebSocket;
|
|
||||||
|
|
||||||
function PatchedWebSocket(url, protocols) {
|
|
||||||
const ws = protocols ? new _WS(url, protocols) : new _WS(url);
|
|
||||||
const _send = ws.send.bind(ws);
|
|
||||||
|
|
||||||
ws.send = function (data) {
|
|
||||||
if (typeof data === 'string') {
|
|
||||||
// IG sends seen ops as JSON with "op":"4" or "op":"seen" depending on version
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data);
|
|
||||||
if (
|
|
||||||
parsed?.op === '4' ||
|
|
||||||
parsed?.op === 'seen' ||
|
|
||||||
(parsed?.payload && JSON.parse(parsed.payload)?.op === 'seen')
|
|
||||||
) {
|
|
||||||
return; // drop
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
// Text-based seen signal check
|
|
||||||
if (data.includes('"seen"') && data.includes('"thread_id"')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _send(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return ws;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve WebSocket prototype chain so IG's ws checks pass
|
|
||||||
PatchedWebSocket.prototype = _WS.prototype;
|
|
||||||
PatchedWebSocket.CONNECTING = _WS.CONNECTING;
|
|
||||||
PatchedWebSocket.OPEN = _WS.OPEN;
|
|
||||||
PatchedWebSocket.CLOSING = _WS.CLOSING;
|
|
||||||
PatchedWebSocket.CLOSED = _WS.CLOSED;
|
|
||||||
window.WebSocket = PatchedWebSocket;
|
|
||||||
|
|
||||||
// ─── Visibility trick — hide "Active Now" ────────────────────────────────
|
|
||||||
// Only applied if user enables online-status hiding
|
|
||||||
// Wrapped in a named fn so Flutter can call it:
|
|
||||||
// controller.evaluateJavascript(source: 'window.__fgEnableOnlineHide()')
|
|
||||||
window.__fgEnableOnlineHide = function () {
|
|
||||||
Object.defineProperty(document, 'visibilityState', {
|
|
||||||
get: () => 'hidden',
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(document, 'hidden', {
|
|
||||||
get: () => true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
document.dispatchEvent(new Event('visibilitychange'));
|
|
||||||
};
|
|
||||||
|
|
||||||
window.__fgDisableOnlineHide = function () {
|
|
||||||
// Restore by deleting the overrides (falls back to native getter)
|
|
||||||
delete document.visibilityState;
|
|
||||||
delete document.hidden;
|
|
||||||
document.dispatchEvent(new Event('visibilitychange'));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Signal to Flutter that ghost mode JS is active
|
|
||||||
if (window.GhostChannel) {
|
|
||||||
window.GhostChannel.postMessage(JSON.stringify({ type: 'ready' }));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
// lib/services/ghost_mode_script.dart
|
|
||||||
// Injected at AT_DOCUMENT_START — before Instagram's JS caches fetch/XHR refs
|
|
||||||
|
|
||||||
const String kGhostModeJS = r"""
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ─── BLOCKED REST ENDPOINTS ───────────────────────────────────────────────
|
|
||||||
// Patterns matched against full request URL
|
|
||||||
const URL_BLOCKLIST = [
|
|
||||||
// Story viewed receipts
|
|
||||||
/\/api\/v1\/media\/seen\//,
|
|
||||||
/\/api\/v1\/feed\/viewed_story\//,
|
|
||||||
/\/api\/v1\/feed\/reels_tray\/seen\//,
|
|
||||||
|
|
||||||
// DM read receipts (REST fallback path)
|
|
||||||
/\/api\/v1\/direct_v2\/threads\/[^/]+\/mark_item_seen\//,
|
|
||||||
/\/api\/v1\/direct_v2\/mark_item_seen\//,
|
|
||||||
|
|
||||||
// Ephemeral photo/video reply viewed (Anti-Reply Image)
|
|
||||||
/\/api\/v1\/direct_v2\/threads\/[^/]+\/items\/[^/]+\/mark_visual_item_seen\//,
|
|
||||||
/\/api\/v1\/direct_v2\/visual_thread\/[^/]+\/seen\//,
|
|
||||||
|
|
||||||
// Voice message listened receipt
|
|
||||||
/\/api\/v1\/direct_v2\/threads\/[^/]+\/items\/[^/]+\/mark_audio_seen\//,
|
|
||||||
|
|
||||||
// Live join broadcast notification
|
|
||||||
/\/api\/v1\/live\/[^/]+\/join\//,
|
|
||||||
/\/api\/v1\/live\/[^/]+\/get_join_requests\//,
|
|
||||||
/\/api\/v1\/live\/[^/]+\/start_broadcast\//,
|
|
||||||
|
|
||||||
// Analytics / tracking
|
|
||||||
/\/api\/v1\/qe\//,
|
|
||||||
/\/api\/v1\/launcher\/sync\//,
|
|
||||||
/\/api\/v1\/logging\//,
|
|
||||||
/\/api\/v1\/fb_onetap_logging\//,
|
|
||||||
/\/ajax\/bz/,
|
|
||||||
/\/ajax\/logging\//,
|
|
||||||
/\/api\/v1\/stats\//,
|
|
||||||
/\/api\/v1\/fbanalytics\//,
|
|
||||||
/\/api\/v1\/growth\/account_linked_now\//,
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── BLOCKED GRAPHQL OPERATIONS ───────────────────────────────────────────
|
|
||||||
// Instagram web uses GraphQL for many actions — match by operation name in body
|
|
||||||
const GRAPHQL_OP_BLOCKLIST = [
|
|
||||||
// Story seen
|
|
||||||
'MarkStorySeen',
|
|
||||||
'markStorySeen',
|
|
||||||
'ReelSeenMutation',
|
|
||||||
'reel_seen',
|
|
||||||
'IgFeedSeen',
|
|
||||||
|
|
||||||
// DM read receipts
|
|
||||||
'MarkDirectThreadItemSeen',
|
|
||||||
'markDirectThreadItemSeen',
|
|
||||||
'DirectMarkItemSeen',
|
|
||||||
'DirectThreadMarkSeen',
|
|
||||||
|
|
||||||
// Ephemeral media seen
|
|
||||||
'MarkVisualMessageSeen',
|
|
||||||
'DirectMarkVisualItemSeen',
|
|
||||||
|
|
||||||
// Voice message listened
|
|
||||||
'MarkAudioMessageSeen',
|
|
||||||
'AudioSeenMutation',
|
|
||||||
|
|
||||||
// Live join
|
|
||||||
'LiveJoinBroadcast',
|
|
||||||
'JoinLiveBroadcast',
|
|
||||||
'MarkLiveViewer',
|
|
||||||
|
|
||||||
// Analytics mutations
|
|
||||||
'LogImpression',
|
|
||||||
'LogClick',
|
|
||||||
'FeedbackSeenMutation',
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── HELPERS ──────────────────────────────────────────────────────────────
|
|
||||||
function shouldBlockUrl(url) {
|
|
||||||
if (!url) return false;
|
|
||||||
try {
|
|
||||||
const path = new URL(url, location.origin).pathname + new URL(url, location.origin).search;
|
|
||||||
return URL_BLOCKLIST.some(p => p.test(path));
|
|
||||||
} catch {
|
|
||||||
return URL_BLOCKLIST.some(p => p.test(url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldBlockGraphQL(body) {
|
|
||||||
if (!body) return false;
|
|
||||||
let str = '';
|
|
||||||
if (typeof body === 'string') {
|
|
||||||
str = body;
|
|
||||||
} else if (body instanceof URLSearchParams) {
|
|
||||||
str = body.toString();
|
|
||||||
}
|
|
||||||
return GRAPHQL_OP_BLOCKLIST.some(op => str.includes(op));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGraphQLEndpoint(url) {
|
|
||||||
return url.includes('/graphql') || url.includes('/api/graphql');
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeOk(body) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(body || { status: 'ok', result: 'success' }),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── FETCH INTERCEPT ──────────────────────────────────────────────────────
|
|
||||||
const _fetch = window.fetch;
|
|
||||||
window.fetch = async function (input, init) {
|
|
||||||
const url =
|
|
||||||
typeof input === 'string'
|
|
||||||
? input
|
|
||||||
: input instanceof Request
|
|
||||||
? input.url
|
|
||||||
: String(input);
|
|
||||||
|
|
||||||
if (shouldBlockUrl(url)) {
|
|
||||||
return fakeOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone body for GraphQL inspection without consuming it
|
|
||||||
if (isGraphQLEndpoint(url) && init) {
|
|
||||||
let bodyStr = '';
|
|
||||||
if (typeof init.body === 'string') {
|
|
||||||
bodyStr = init.body;
|
|
||||||
} else if (init.body instanceof URLSearchParams) {
|
|
||||||
bodyStr = init.body.toString();
|
|
||||||
} else if (init.body instanceof FormData) {
|
|
||||||
// FormData: iterate entries to build string
|
|
||||||
try {
|
|
||||||
init.body.forEach((v, k) => { bodyStr += k + '=' + v + '&'; });
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if (shouldBlockGraphQL(bodyStr)) {
|
|
||||||
return fakeOk();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _fetch.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── XHR INTERCEPT ───────────────────────────────────────────────────────
|
|
||||||
const _xhrOpen = XMLHttpRequest.prototype.open;
|
|
||||||
const _xhrSend = XMLHttpRequest.prototype.send;
|
|
||||||
|
|
||||||
XMLHttpRequest.prototype.open = function (method, url) {
|
|
||||||
this.__ghostUrl = url;
|
|
||||||
return _xhrOpen.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
XMLHttpRequest.prototype.send = function (body) {
|
|
||||||
const url = this.__ghostUrl || '';
|
|
||||||
|
|
||||||
const blockByUrl = shouldBlockUrl(url);
|
|
||||||
const blockByOp = isGraphQLEndpoint(url) && shouldBlockGraphQL(
|
|
||||||
typeof body === 'string' ? body : ''
|
|
||||||
);
|
|
||||||
|
|
||||||
if (blockByUrl || blockByOp) {
|
|
||||||
const self = this;
|
|
||||||
// Must use defineProperty because readyState etc are read-only
|
|
||||||
Object.defineProperty(self, 'readyState', { get: () => 4, configurable: true });
|
|
||||||
Object.defineProperty(self, 'status', { get: () => 200, configurable: true });
|
|
||||||
Object.defineProperty(self, 'responseText', {
|
|
||||||
get: () => '{"status":"ok"}',
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
Object.defineProperty(self, 'response', {
|
|
||||||
get: () => '{"status":"ok"}',
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
try { self.onreadystatechange && self.onreadystatechange(); } catch {}
|
|
||||||
try { self.onload && self.onload(); } catch {}
|
|
||||||
// Fire events
|
|
||||||
['readystatechange', 'load'].forEach(t => {
|
|
||||||
try { self.dispatchEvent(new Event(t)); } catch {}
|
|
||||||
});
|
|
||||||
}, 10);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _xhrSend.apply(this, arguments);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── WEBSOCKET INTERCEPT (typing + live join) ─────────────────────────────
|
|
||||||
// Instagram uses MQTT over WebSocket for real-time events.
|
|
||||||
// Typing indicator = MQTT PUBLISH to topic containing typing/activity tokens.
|
|
||||||
// Live join viewer notification = MQTT PUBLISH with live topic.
|
|
||||||
const _OrigWS = window.WebSocket;
|
|
||||||
|
|
||||||
function GhostWebSocket(url, protocols) {
|
|
||||||
const ws = protocols ? new _OrigWS(url, protocols) : new _OrigWS(url);
|
|
||||||
const _wsSend = ws.send.bind(ws);
|
|
||||||
|
|
||||||
ws.send = function (data) {
|
|
||||||
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
|
||||||
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
||||||
|
|
||||||
// MQTT packet type in top 4 bits of byte 0
|
|
||||||
// PUBLISH = 0x3x (0x30 QoS0, 0x32 QoS1, 0x34 QoS2)
|
|
||||||
const packetType = bytes[0] & 0xF0;
|
|
||||||
if (packetType === 0x30) {
|
|
||||||
// Read remaining length (byte 1, simplified for short packets)
|
|
||||||
// MQTT topic starts at byte 4 (2 byte remaining-len + 2 byte topic-len)
|
|
||||||
try {
|
|
||||||
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
|
|
||||||
// Block typing / activity indicator publishes
|
|
||||||
if (
|
|
||||||
decoded.includes('/t_fs') || // foreground state (typing)
|
|
||||||
decoded.includes('activity_indicator') ||
|
|
||||||
decoded.includes('is_typing') ||
|
|
||||||
decoded.includes('direct_typing') ||
|
|
||||||
decoded.includes('/live/viewer') || // live join notification
|
|
||||||
decoded.includes('live_viewer_list')
|
|
||||||
) {
|
|
||||||
return; // Drop packet silently
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} else if (typeof data === 'string') {
|
|
||||||
// Some WS implementations send JSON
|
|
||||||
if (
|
|
||||||
data.includes('typing') ||
|
|
||||||
data.includes('live_viewer') ||
|
|
||||||
data.includes('is_typing')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _wsSend(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return ws;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve static properties
|
|
||||||
GhostWebSocket.prototype = _OrigWS.prototype;
|
|
||||||
Object.assign(GhostWebSocket, {
|
|
||||||
CONNECTING: _OrigWS.CONNECTING,
|
|
||||||
OPEN: _OrigWS.OPEN,
|
|
||||||
CLOSING: _OrigWS.CLOSING,
|
|
||||||
CLOSED: _OrigWS.CLOSED,
|
|
||||||
});
|
|
||||||
window.WebSocket = GhostWebSocket;
|
|
||||||
|
|
||||||
// ─── KILL SERVICE WORKER ──────────────────────────────────────────────────
|
|
||||||
// SW runs in separate context — bypasses all JS intercepts above.
|
|
||||||
// Kill registration so our fetch/XHR overrides are the only intercept layer.
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
// Block new registrations
|
|
||||||
navigator.serviceWorker.register = function () {
|
|
||||||
return Promise.reject(new Error('[GhostMode] SW blocked'));
|
|
||||||
};
|
|
||||||
// Unregister any already registered
|
|
||||||
navigator.serviceWorker.getRegistrations().then(regs => {
|
|
||||||
regs.forEach(r => r.unregister());
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── BEACON API BLOCK ────────────────────────────────────────────────────
|
|
||||||
// Instagram uses sendBeacon for analytics on page unload
|
|
||||||
if (navigator.sendBeacon) {
|
|
||||||
navigator.sendBeacon = function (url) {
|
|
||||||
if (shouldBlockUrl(url)) return true; // Lie — say it succeeded
|
|
||||||
// Block all beacon calls to ig domains — analytics only
|
|
||||||
if (url.includes('instagram.com') || url.includes('facebook.com')) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[FocusGram] GhostMode active');
|
|
||||||
})();
|
|
||||||
""";
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
// lib/services/ghost_mode_service.dart
|
|
||||||
//
|
|
||||||
// Three-layer ghost mode:
|
|
||||||
// 1. AT_DOCUMENT_START JS injection — overrides fetch/XHR/WS before IG code runs
|
|
||||||
// 2. shouldInterceptRequest — native Android intercept (catches SW requests too)
|
|
||||||
// 3. FLAG_SECURE — anti-screenshot at OS level (disabled per user request)
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// final service = GhostModeService();
|
|
||||||
// await service.load(); // reads saved prefs
|
|
||||||
//
|
|
||||||
// InAppWebView(
|
|
||||||
// initialUserScripts: service.buildUserScripts(),
|
|
||||||
// onWebViewCreated: (c) => service.onWebViewCreated(c),
|
|
||||||
// shouldInterceptRequest: service.shouldInterceptRequest,
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// // Anti-screenshot: disabled per user request
|
|
||||||
// // service.applyWindowFlags(context);
|
|
||||||
|
|
||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import 'ghost_mode_script.dart';
|
|
||||||
|
|
||||||
// ─── Feature flags ────────────────────────────────────────────────────────────
|
|
||||||
class GhostFeatures {
|
|
||||||
bool hideStoryViews;
|
|
||||||
bool hideReadReceipts;
|
|
||||||
bool hideLiveJoin;
|
|
||||||
bool hideTypingIndicator;
|
|
||||||
bool hideVoiceListened;
|
|
||||||
bool hideReplyImageViewed;
|
|
||||||
bool disableAnalytics;
|
|
||||||
|
|
||||||
GhostFeatures({
|
|
||||||
this.hideStoryViews = true,
|
|
||||||
this.hideReadReceipts = true,
|
|
||||||
this.hideLiveJoin = true,
|
|
||||||
this.hideTypingIndicator = true,
|
|
||||||
this.hideVoiceListened = true,
|
|
||||||
this.hideReplyImageViewed = true,
|
|
||||||
this.disableAnalytics = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const _keys = {
|
|
||||||
'hideStoryViews': 'gm_story',
|
|
||||||
'hideReadReceipts': 'gm_read',
|
|
||||||
'hideLiveJoin': 'gm_live',
|
|
||||||
'hideTypingIndicator': 'gm_typing',
|
|
||||||
'hideVoiceListened': 'gm_voice',
|
|
||||||
'hideReplyImageViewed': 'gm_reply',
|
|
||||||
'disableAnalytics': 'gm_analytics',
|
|
||||||
};
|
|
||||||
|
|
||||||
Future<void> save() async {
|
|
||||||
final p = await SharedPreferences.getInstance();
|
|
||||||
await Future.wait([
|
|
||||||
p.setBool(_keys['hideStoryViews']!, hideStoryViews),
|
|
||||||
p.setBool(_keys['hideReadReceipts']!, hideReadReceipts),
|
|
||||||
p.setBool(_keys['hideLiveJoin']!, hideLiveJoin),
|
|
||||||
p.setBool(_keys['hideTypingIndicator']!, hideTypingIndicator),
|
|
||||||
p.setBool(_keys['hideVoiceListened']!, hideVoiceListened),
|
|
||||||
p.setBool(_keys['hideReplyImageViewed']!, hideReplyImageViewed),
|
|
||||||
p.setBool(_keys['disableAnalytics']!, disableAnalytics),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<GhostFeatures> load() async {
|
|
||||||
final p = await SharedPreferences.getInstance();
|
|
||||||
return GhostFeatures(
|
|
||||||
hideStoryViews: p.getBool(_keys['hideStoryViews']!) ?? true,
|
|
||||||
hideReadReceipts: p.getBool(_keys['hideReadReceipts']!) ?? true,
|
|
||||||
hideLiveJoin: p.getBool(_keys['hideLiveJoin']!) ?? true,
|
|
||||||
hideTypingIndicator: p.getBool(_keys['hideTypingIndicator']!) ?? true,
|
|
||||||
hideVoiceListened: p.getBool(_keys['hideVoiceListened']!) ?? true,
|
|
||||||
hideReplyImageViewed: p.getBool(_keys['hideReplyImageViewed']!) ?? true,
|
|
||||||
disableAnalytics: p.getBool(_keys['disableAnalytics']!) ?? true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Native URL blocklist (mirrors JS side — belt & suspenders) ───────────────
|
|
||||||
final _nativeBlocklist = [
|
|
||||||
RegExp(r'/api/v1/media/seen/'),
|
|
||||||
RegExp(r'/api/v1/feed/viewed_story/'),
|
|
||||||
RegExp(r'/api/v1/feed/reels_tray/seen/'),
|
|
||||||
RegExp(r'/api/v1/direct_v2/threads/[^/]+/mark_item_seen/'),
|
|
||||||
RegExp(r'/api/v1/direct_v2/mark_item_seen/'),
|
|
||||||
RegExp(r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_visual_item_seen/'),
|
|
||||||
RegExp(r'/api/v1/direct_v2/visual_thread/[^/]+/seen/'),
|
|
||||||
RegExp(r'/api/v1/direct_v2/threads/[^/]+/items/[^/]+/mark_audio_seen/'),
|
|
||||||
RegExp(r'/api/v1/live/[^/]+/join/'),
|
|
||||||
RegExp(r'/api/v1/live/[^/]+/get_join_requests/'),
|
|
||||||
RegExp(r'/api/v1/qe/'),
|
|
||||||
RegExp(r'/api/v1/launcher/sync/'),
|
|
||||||
RegExp(r'/api/v1/logging/'),
|
|
||||||
RegExp(r'/api/v1/stats/'),
|
|
||||||
RegExp(r'/api/v1/fb_onetap_logging/'),
|
|
||||||
RegExp(r'/ajax/bz'),
|
|
||||||
RegExp(r'/ajax/logging/'),
|
|
||||||
];
|
|
||||||
|
|
||||||
final Uint8List _fakeOkBody = Uint8List.fromList('{"status":"ok"}'.codeUnits);
|
|
||||||
|
|
||||||
// ─── Main service ─────────────────────────────────────────────────────────────
|
|
||||||
class GhostModeService {
|
|
||||||
GhostFeatures features = GhostFeatures();
|
|
||||||
InAppWebViewController? _controller;
|
|
||||||
|
|
||||||
Future<void> load() async {
|
|
||||||
features = await GhostFeatures.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── WebView setup ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Call from InAppWebView.onWebViewCreated
|
|
||||||
void onWebViewCreated(InAppWebViewController controller) {
|
|
||||||
_controller = controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pass to InAppWebView.initialUserScripts
|
|
||||||
/// AT_DOCUMENT_START = injected before ANY page script — critical for
|
|
||||||
/// overriding fetch/XHR before Instagram caches original refs.
|
|
||||||
List<UserScript> buildUserScripts() {
|
|
||||||
if (!_anyGhostEnabled()) return [];
|
|
||||||
return [
|
|
||||||
UserScript(
|
|
||||||
source: _buildConfiguredScript(),
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
forMainFrameOnly: false, // Apply to iframes too
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pass to InAppWebView.shouldInterceptRequest
|
|
||||||
/// Works at native Android level — catches requests from service workers too.
|
|
||||||
Future<WebResourceResponse?> shouldInterceptRequest(
|
|
||||||
InAppWebViewController controller,
|
|
||||||
WebResourceRequest request,
|
|
||||||
) async {
|
|
||||||
if (!_anyGhostEnabled()) return null;
|
|
||||||
final path = request.url.path;
|
|
||||||
if (_nativeBlocklist.any((re) => re.hasMatch(path))) {
|
|
||||||
return WebResourceResponse(
|
|
||||||
statusCode: 200,
|
|
||||||
reasonPhrase: 'OK',
|
|
||||||
contentType: 'application/json',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
data: _fakeOkBody,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null; // Let through
|
|
||||||
}
|
|
||||||
|
|
||||||
/// InAppWebViewSettings required for shouldInterceptRequest to fire
|
|
||||||
InAppWebViewSettings buildWebViewSettings() {
|
|
||||||
return InAppWebViewSettings(
|
|
||||||
useShouldInterceptRequest: true, // Enable native intercept callback
|
|
||||||
useShouldOverrideUrlLoading: true,
|
|
||||||
javaScriptEnabled: true,
|
|
||||||
disableDefaultErrorPage: true,
|
|
||||||
useHybridComposition:
|
|
||||||
true, // Needed for FLAG_SECURE to work (though disabled)
|
|
||||||
// Disable service worker cache that can replay seen-events offline
|
|
||||||
cacheEnabled: false, // Start clean — optional, tradeoff vs perf
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Anti-screenshot ────────────────────────────────────────────────────────
|
|
||||||
// Anti-screenshot disabled per user request
|
|
||||||
|
|
||||||
Future<void> applyWindowFlags(BuildContext context) async {
|
|
||||||
// Anti-screenshot disabled per user request
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> clearWindowFlags() async {
|
|
||||||
// Anti-screenshot disabled per user request
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Re-inject after page nav (SPA navigation doesn't re-run userScripts) ──
|
|
||||||
|
|
||||||
/// Call from InAppWebView.onLoadStop
|
|
||||||
Future<void> onPageLoaded(Uri? url) async {
|
|
||||||
if (_controller == null || !_anyGhostEnabled()) return;
|
|
||||||
// Re-inject on each navigation — SPA route changes don't re-fire userScripts
|
|
||||||
await _controller!.evaluateJavascript(source: _buildConfiguredScript());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Private helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
bool _anyGhostEnabled() =>
|
|
||||||
features.hideStoryViews ||
|
|
||||||
features.hideReadReceipts ||
|
|
||||||
features.hideLiveJoin ||
|
|
||||||
features.hideTypingIndicator ||
|
|
||||||
features.hideVoiceListened ||
|
|
||||||
features.hideReplyImageViewed ||
|
|
||||||
features.disableAnalytics;
|
|
||||||
|
|
||||||
/// Build JS with feature flags baked in — disabled features skip their blocks
|
|
||||||
String _buildConfiguredScript() {
|
|
||||||
// Prepend a config object that the script reads
|
|
||||||
// The kGhostModeJS already handles all features unconditionally.
|
|
||||||
// If you need per-feature toggles, swap the const for a builder function.
|
|
||||||
//
|
|
||||||
// For now: only inject if ghost mode is on at all.
|
|
||||||
// Per-feature granularity can be added by replacing URL_BLOCKLIST
|
|
||||||
// sections conditionally — left as extension point.
|
|
||||||
return '''
|
|
||||||
window.__GHOST_CONFIG__ = ${_configJson()};
|
|
||||||
$kGhostModeJS
|
|
||||||
''';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _configJson() {
|
|
||||||
return '''{
|
|
||||||
"hideStoryViews": ${features.hideStoryViews},
|
|
||||||
"hideReadReceipts": ${features.hideReadReceipts},
|
|
||||||
"hideLiveJoin": ${features.hideLiveJoin},
|
|
||||||
"hideTypingIndicator": ${features.hideTypingIndicator},
|
|
||||||
"hideVoiceListened": ${features.hideVoiceListened},
|
|
||||||
"hideReplyImageViewed": ${features.hideReplyImageViewed},
|
|
||||||
"disableAnalytics": ${features.disableAnalytics}
|
|
||||||
}''';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../injection/script_engine.dart';
|
|
||||||
import '../injection/script_registry.dart';
|
|
||||||
import '../channels/channel_registry.dart';
|
|
||||||
import '../webview/webview_config.dart';
|
|
||||||
import '../services/ghost_mode_service.dart';
|
|
||||||
|
|
||||||
class InstagramWebView extends StatefulWidget {
|
|
||||||
const InstagramWebView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<InstagramWebView> createState() => InstagramWebViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class InstagramWebViewState extends State<InstagramWebView> {
|
|
||||||
InAppWebViewController? _controller;
|
|
||||||
ScriptEngine? _engine;
|
|
||||||
GhostModeService? _ghostMode;
|
|
||||||
bool _loading = true;
|
|
||||||
|
|
||||||
// ── Public API — call from Settings screen ─────────────────────────────
|
|
||||||
Future<void> toggleScript(ScriptId id, bool enabled) async {
|
|
||||||
await _engine?.toggle(id, enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setContentFlag(String flag, bool value) async {
|
|
||||||
await _engine?.setContentFlag(flag, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setOnlineHide(bool enabled) async {
|
|
||||||
await _engine?.setOnlineHide(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ghost mode controls
|
|
||||||
Future<void> setGhostModeEnabled(bool enabled) async {
|
|
||||||
if (_ghostMode != null) {
|
|
||||||
_ghostMode!.features.disableAnalytics = enabled;
|
|
||||||
_ghostMode!.features.hideStoryViews = enabled;
|
|
||||||
_ghostMode!.features.hideReadReceipts = enabled;
|
|
||||||
_ghostMode!.features.hideLiveJoin = enabled;
|
|
||||||
_ghostMode!.features.hideTypingIndicator = enabled;
|
|
||||||
_ghostMode!.features.hideVoiceListened = enabled;
|
|
||||||
_ghostMode!.features.hideReplyImageViewed = enabled;
|
|
||||||
await _ghostMode!.features.save();
|
|
||||||
// Reapply settings if webview exists
|
|
||||||
if (_controller != null) {
|
|
||||||
// Force reload to apply new settings
|
|
||||||
await _controller!.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setAntiScreenshot(bool enabled) async {
|
|
||||||
if (_ghostMode != null) {
|
|
||||||
_ghostMode!.features.antiScreenshot = enabled;
|
|
||||||
await _ghostMode!.features.save();
|
|
||||||
if (_ghostMode!.features.antiScreenshot) {
|
|
||||||
await _ghostMode!.applyWindowFlags(context);
|
|
||||||
} else {
|
|
||||||
await _ghostMode!.clearWindowFlags();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
InAppWebView(
|
|
||||||
initialUrlRequest: WebViewConfig.initialRequest,
|
|
||||||
initialSettings:
|
|
||||||
_ghostMode?.buildWebViewSettings() ?? WebViewConfig.settings,
|
|
||||||
|
|
||||||
// ── ContentBlockers — merged base + EasyList rules ──────────────
|
|
||||||
contentBlockers: WebViewConfig.baseContentBlockers,
|
|
||||||
|
|
||||||
// ── User Scripts — AT_DOCUMENT_START critical for ghost mode ─────
|
|
||||||
initialUserScripts: UnmodifiableListView(
|
|
||||||
_ghostMode?.buildUserScripts() ?? [],
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── JavaScript channels ─────────────────────────────────────────
|
|
||||||
javascriptChannels: ChannelRegistry(
|
|
||||||
onActivityEvent: (event) {
|
|
||||||
// Forward to history DB in Phase 2
|
|
||||||
debugPrint('[Activity] $event');
|
|
||||||
},
|
|
||||||
).build(),
|
|
||||||
|
|
||||||
onWebViewCreated: (controller) async {
|
|
||||||
_controller = controller;
|
|
||||||
|
|
||||||
//Interceptor for adblock
|
|
||||||
shouldInterceptRequest:
|
|
||||||
(controller, request) async {
|
|
||||||
final url = request.url.toString();
|
|
||||||
|
|
||||||
const adDomains = [
|
|
||||||
'an.facebook.com',
|
|
||||||
'connect.facebook.net',
|
|
||||||
'pixel.facebook.com',
|
|
||||||
'graph.facebook.com/logging',
|
|
||||||
'www.instagram.com/ajax/bz',
|
|
||||||
'www.instagram.com/api/v1/web/comet/logcalls',
|
|
||||||
'doubleclick.net',
|
|
||||||
'googletagmanager.com',
|
|
||||||
'scorecardresearch.com',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (adDomains.any(url.contains)) {
|
|
||||||
return WebResourceResponse(
|
|
||||||
contentType: 'application/json',
|
|
||||||
httpStatus: WebResourceResponseHTTPStatus(statusCode: 200),
|
|
||||||
data: Uint8List.fromList(utf8.encode('{}')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize GhostModeService
|
|
||||||
_ghostMode = GhostModeService();
|
|
||||||
await _ghostMode!.load();
|
|
||||||
|
|
||||||
// Initialize existing script engine for other scripts
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
_engine = ScriptEngine(controller: controller, prefs: prefs);
|
|
||||||
|
|
||||||
// Inject DOCUMENT_START scripts (ghost mode, etc.)
|
|
||||||
await _engine!.initDocumentStartScripts();
|
|
||||||
},
|
|
||||||
|
|
||||||
onLoadStop: (controller, url) async {
|
|
||||||
// Inject DOCUMENT_END scripts
|
|
||||||
await _engine?.injectDocumentEndScripts();
|
|
||||||
|
|
||||||
// Re-inject ghost mode scripts on SPA navigation
|
|
||||||
await _ghostMode?.onPageLoaded(url?.uriValue);
|
|
||||||
|
|
||||||
setState(() => _loading = false);
|
|
||||||
},
|
|
||||||
|
|
||||||
onLoadStart: (controller, url) {
|
|
||||||
setState(() => _loading = true);
|
|
||||||
},
|
|
||||||
|
|
||||||
onProgressChanged: (controller, progress) {
|
|
||||||
if (progress >= 80 && _loading) {
|
|
||||||
setState(() => _loading = false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Navigation policy ───────────────────────────────────────────
|
|
||||||
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
|
||||||
final url = navigationAction.request.url?.toString() ?? '';
|
|
||||||
|
|
||||||
// Block external redirects — keep user inside instagram.com
|
|
||||||
if (!url.contains('instagram.com') &&
|
|
||||||
!url.contains('cdninstagram.com') &&
|
|
||||||
!url.contains('fbcdn.net') &&
|
|
||||||
url.startsWith('http')) {
|
|
||||||
// TODO: open in external browser via url_launcher
|
|
||||||
return NavigationActionPolicy.CANCEL;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NavigationActionPolicy.ALLOW;
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Re-inject on SPA navigation ─────────────────────────────────
|
|
||||||
// Instagram is a SPA — URL changes via pushState don't trigger
|
|
||||||
// onLoadStop. Re-inject DOM scripts on URL change.
|
|
||||||
onUpdateVisitedHistory: (controller, url, isReload) async {
|
|
||||||
if (!isReload!) {
|
|
||||||
await _engine?.injectDocumentEndScripts();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Native intercept for service worker requests ────────────────
|
|
||||||
shouldInterceptRequest: (controller, request) async {
|
|
||||||
return await _ghostMode?.shouldInterceptRequest(
|
|
||||||
controller,
|
|
||||||
request,
|
|
||||||
) ??
|
|
||||||
null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Subtle loading indicator ──────────────────────────────────────
|
|
||||||
if (_loading)
|
|
||||||
const LinearProgressIndicator(
|
|
||||||
minHeight: 2,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
import 'core/theme/system_ui_manager.dart';
|
|
||||||
import 'core/webview/instagram_webview.dart';
|
|
||||||
|
|
||||||
void main() async {
|
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
// Enable web contents debugging for ghost mode verification
|
|
||||||
if (kDebugMode) {
|
|
||||||
InAppWebViewController.setWebContentsDebuggingEnabled(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
await SystemUiManager.enableEdgeToEdge();
|
|
||||||
runApp(const FocusGramApp());
|
|
||||||
}
|
|
||||||
|
|
||||||
class FocusGramApp extends StatelessWidget {
|
|
||||||
const FocusGramApp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return MaterialApp(
|
|
||||||
title: 'FocusGram',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
theme: ThemeData(
|
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0095F6)),
|
|
||||||
useMaterial3: true,
|
|
||||||
),
|
|
||||||
home: const MainScreen(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MainScreen extends StatefulWidget {
|
|
||||||
const MainScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<MainScreen> createState() => _MainScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MainScreenState extends State<MainScreen> {
|
|
||||||
final _webViewKey = GlobalKey<InstagramWebViewState>();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
// backgroundColor transparent — lets WebView color bleed to system bars
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
body: SafeArea(
|
|
||||||
// bottom: false — let WebView extend behind nav bar for true edge-to-edge
|
|
||||||
bottom: false,
|
|
||||||
child: InstagramWebView(key: _webViewKey),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import 'script_registry.dart';
|
|
||||||
|
|
||||||
class ScriptEngine {
|
|
||||||
final InAppWebViewController controller;
|
|
||||||
final SharedPreferences prefs;
|
|
||||||
|
|
||||||
// Cache raw JS per asset path to avoid repeated rootBundle reads
|
|
||||||
final Map<String, String> _cache = {};
|
|
||||||
|
|
||||||
ScriptEngine({required this.controller, required this.prefs});
|
|
||||||
|
|
||||||
// ── Init: restore enabled state from prefs, inject DOCUMENT_START scripts ─
|
|
||||||
// Call this from onWebViewCreated (for DOCUMENT_START scripts via addUserScript)
|
|
||||||
Future<void> initDocumentStartScripts() async {
|
|
||||||
for (final script in ScriptRegistry.all) {
|
|
||||||
// Restore enabled state
|
|
||||||
final saved = prefs.getBool('script_${script.id.name}');
|
|
||||||
if (saved != null) script.enabled = saved;
|
|
||||||
|
|
||||||
if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START &&
|
|
||||||
script.enabled) {
|
|
||||||
final code = await _load(script.assetPath);
|
|
||||||
if (code == null) continue;
|
|
||||||
await controller.addUserScript(
|
|
||||||
UserScript(
|
|
||||||
source: code,
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
allowedOriginRules: {'https://www.instagram.com'},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize script configurations after scripts are loaded
|
|
||||||
await _initializeScriptConfigs();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Initialize script configurations from saved preferences ────────────────
|
|
||||||
Future<void> _initializeScriptConfigs() async {
|
|
||||||
// Fetch interceptor config
|
|
||||||
final fetchInterceptor = ScriptRegistry.byId(ScriptId.fetchInterceptor);
|
|
||||||
if (fetchInterceptor.enabled) {
|
|
||||||
await _updateFetchInterceptorConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Autoplay blocker config
|
|
||||||
final autoplayBlocker = ScriptRegistry.byId(ScriptId.autoplayBlocker);
|
|
||||||
if (autoplayBlocker.enabled) {
|
|
||||||
await _updateAutoplayBlockerConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content hider flags
|
|
||||||
await _pushContentFlags();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Called from onLoadStop: inject all DOCUMENT_END enabled scripts ────────
|
|
||||||
Future<void> injectDocumentEndScripts() async {
|
|
||||||
for (final script in ScriptRegistry.all.where(
|
|
||||||
(s) =>
|
|
||||||
s.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_END &&
|
|
||||||
s.enabled,
|
|
||||||
)) {
|
|
||||||
await _inject(script);
|
|
||||||
}
|
|
||||||
// After content_hider is injected, push saved content flags
|
|
||||||
await _pushContentFlags();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toggle a script on/off ─────────────────────────────────────────────────
|
|
||||||
Future<void> toggle(ScriptId id, bool enabled) async {
|
|
||||||
final script = ScriptRegistry.byId(id);
|
|
||||||
script.enabled = enabled;
|
|
||||||
await prefs.setBool('script_${id.name}', enabled);
|
|
||||||
|
|
||||||
if (!enabled) {
|
|
||||||
if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
|
|
||||||
await controller.removeUserScriptsByGroupName(id.name);
|
|
||||||
}
|
|
||||||
// For DOM scripts: reload so mutations stop
|
|
||||||
await controller.reload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (script.injectionTime == UserScriptInjectionTime.AT_DOCUMENT_START) {
|
|
||||||
final code = await _load(script.assetPath);
|
|
||||||
if (code == null) return;
|
|
||||||
await controller.addUserScript(
|
|
||||||
UserScript(
|
|
||||||
source: code,
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
groupName: id.name,
|
|
||||||
allowedOriginRules: {'https://www.instagram.com'},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await controller.reload();
|
|
||||||
} else {
|
|
||||||
await _inject(script);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-initialize configurations after toggle
|
|
||||||
await _initializeScriptConfigs();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Content hider flags ────────────────────────────────────────────────────
|
|
||||||
Future<void> setContentFlag(String flag, bool value) async {
|
|
||||||
await prefs.setBool('content_$flag', value);
|
|
||||||
await _pushContentFlags();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _pushContentFlags() async {
|
|
||||||
final contentHider = ScriptRegistry.byId(ScriptId.contentHider);
|
|
||||||
if (!contentHider.enabled) return;
|
|
||||||
|
|
||||||
final flags = {
|
|
||||||
'stories': prefs.getBool('content_stories') ?? false,
|
|
||||||
'posts': prefs.getBool('content_posts') ?? false,
|
|
||||||
'reels': prefs.getBool('content_reels') ?? false,
|
|
||||||
'suggested': prefs.getBool('content_suggested') ?? false,
|
|
||||||
};
|
|
||||||
await controller.evaluateJavascript(
|
|
||||||
source: 'window.__fgContent?.applyAll(${jsonEncode(flags)})',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fetch interceptor configuration ────────────────────────────────────────
|
|
||||||
Future<void> setFetchInterceptorConfig({
|
|
||||||
bool? blockAds,
|
|
||||||
bool? blockSponsored,
|
|
||||||
bool? blockSuggested,
|
|
||||||
bool? blockVideos,
|
|
||||||
bool? blockAutoplay,
|
|
||||||
}) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final config = {
|
|
||||||
'blockAds': blockAds ?? prefs.getBool('fetch_block_ads') ?? false,
|
|
||||||
'blockSponsored':
|
|
||||||
blockSponsored ?? prefs.getBool('fetch_block_sponsored') ?? false,
|
|
||||||
'blockSuggested':
|
|
||||||
blockSuggested ?? prefs.getBool('fetch_block_suggested') ?? false,
|
|
||||||
'blockVideos':
|
|
||||||
blockVideos ?? prefs.getBool('fetch_block_videos') ?? false,
|
|
||||||
'blockAutoplay':
|
|
||||||
blockAutoplay ?? prefs.getBool('fetch_block_autoplay') ?? false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save individual prefs
|
|
||||||
await prefs.setBool('fetch_block_ads', config['blockAds']!);
|
|
||||||
await prefs.setBool('fetch_block_sponsored', config['blockSponsored']!);
|
|
||||||
await prefs.setBool('fetch_block_suggested', config['blockSuggested']!);
|
|
||||||
await prefs.setBool('fetch_block_videos', config['blockVideos']!);
|
|
||||||
await prefs.setBool('fetch_block_autoplay', config['blockAutoplay']!);
|
|
||||||
|
|
||||||
// Apply to webview
|
|
||||||
await controller.evaluateJavascript(
|
|
||||||
source: 'window.__fgSetFilterConfig?.(${jsonEncode(config)})',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _updateFetchInterceptorConfig() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await setFetchInterceptorConfig(
|
|
||||||
blockAds: prefs.getBool('fetch_block_ads'),
|
|
||||||
blockSponsored: prefs.getBool('fetch_block_sponsored'),
|
|
||||||
blockSuggested: prefs.getBool('fetch_block_suggested'),
|
|
||||||
blockVideos: prefs.getBool('fetch_block_videos'),
|
|
||||||
blockAutoplay: prefs.getBool('fetch_block_autoplay'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Autoplay blocker configuration ─────────────────────────────────────────
|
|
||||||
Future<void> setAutoplayBlockerEnabled(bool enabled) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setBool('autoplay_blocker_enabled', enabled);
|
|
||||||
|
|
||||||
// Apply to webview
|
|
||||||
await controller.evaluateJavascript(
|
|
||||||
source: 'window.__fgSetBlockAutoplay?.(${jsonEncode(enabled)})',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _updateAutoplayBlockerConfig() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await setAutoplayBlockerEnabled(
|
|
||||||
prefs.getBool('autoplay_blocker_enabled') ?? false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Online status hide ─────────────────────────────────────────────────────
|
|
||||||
Future<void> setOnlineHide(bool enabled) async {
|
|
||||||
await prefs.setBool('ghost_online_hide', enabled);
|
|
||||||
if (enabled) {
|
|
||||||
await controller.evaluateJavascript(
|
|
||||||
source: 'window.__fgEnableOnlineHide?.()',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await controller.evaluateJavascript(
|
|
||||||
source: 'window.__fgDisableOnlineHide?.()',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Private helpers ────────────────────────────────────────────────────────
|
|
||||||
Future<void> _inject(InstaScript script) async {
|
|
||||||
final code = await _load(script.assetPath);
|
|
||||||
if (code == null) return;
|
|
||||||
try {
|
|
||||||
await controller.evaluateJavascript(source: code);
|
|
||||||
} catch (e) {
|
|
||||||
// Script failed — log but don't crash
|
|
||||||
debugPrint('[ScriptEngine] Failed to inject ${script.id.name}: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> _load(String assetPath) async {
|
|
||||||
if (_cache.containsKey(assetPath)) return _cache[assetPath];
|
|
||||||
try {
|
|
||||||
final code = await rootBundle.loadString(assetPath);
|
|
||||||
_cache[assetPath] = code;
|
|
||||||
return code;
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[ScriptEngine] Asset not found: $assetPath');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
|
|
||||||
enum ScriptId {
|
|
||||||
ghostMode,
|
|
||||||
themeDetector,
|
|
||||||
contentHider,
|
|
||||||
fetchInterceptor,
|
|
||||||
autoplayBlocker,
|
|
||||||
mediaDetector,
|
|
||||||
historyTracker,
|
|
||||||
}
|
|
||||||
|
|
||||||
class InstaScript {
|
|
||||||
final ScriptId id;
|
|
||||||
final String name;
|
|
||||||
final String description;
|
|
||||||
final String assetPath;
|
|
||||||
final UserScriptInjectionTime injectionTime;
|
|
||||||
bool enabled;
|
|
||||||
|
|
||||||
InstaScript({
|
|
||||||
required this.id,
|
|
||||||
required this.name,
|
|
||||||
required this.description,
|
|
||||||
required this.assetPath,
|
|
||||||
this.injectionTime = UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
this.enabled = false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class ScriptRegistry {
|
|
||||||
static final List<InstaScript> all = [
|
|
||||||
// ── DOCUMENT_START — must be before IG's JS loads ──
|
|
||||||
InstaScript(
|
|
||||||
id: ScriptId.fetchInterceptor,
|
|
||||||
name: 'Ad & Content Blocker',
|
|
||||||
description:
|
|
||||||
'Blocks ads, sponsored, suggested content, videos, and prevents autoplay via GraphQL interception.',
|
|
||||||
assetPath: 'assets/scripts/fetch_interceptor.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
InstaScript(
|
|
||||||
id: ScriptId.autoplayBlocker,
|
|
||||||
name: 'Autoplay Blocker',
|
|
||||||
description: 'Prevents video autoplay.',
|
|
||||||
assetPath: 'assets/scripts/autoplay_blocker.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── DOCUMENT_END — DOM must be ready ──
|
|
||||||
InstaScript(
|
|
||||||
id: ScriptId.themeDetector,
|
|
||||||
name: 'Theme Detector',
|
|
||||||
description: 'Reads page colors and syncs system UI bars.',
|
|
||||||
assetPath: 'assets/scripts/theme_detector.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
enabled: true, // always on — needed for native feel
|
|
||||||
),
|
|
||||||
InstaScript(
|
|
||||||
id: ScriptId.contentHider,
|
|
||||||
name: 'Content Hider',
|
|
||||||
description: 'Toggleable hide for stories, posts, reels, suggested.',
|
|
||||||
assetPath: 'assets/scripts/content_hider.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
// Phase 2 scripts — registered but empty asset paths for now
|
|
||||||
InstaScript(
|
|
||||||
id: ScriptId.mediaDetector,
|
|
||||||
name: 'Media Downloader',
|
|
||||||
description: 'Injects download buttons on photos and reels.',
|
|
||||||
assetPath: 'assets/scripts/media_detector.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
InstaScript(
|
|
||||||
id: ScriptId.historyTracker,
|
|
||||||
name: 'History Tracker',
|
|
||||||
description: 'Locally tracks reels watched and actions taken.',
|
|
||||||
assetPath: 'assets/scripts/history_tracker.js',
|
|
||||||
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END,
|
|
||||||
enabled: false,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
class SystemUiManager {
|
|
||||||
// ── Apply colors read from JS ThemeDetector ─────────────────────────────
|
|
||||||
static void applyFromThemePayload(String jsonPayload) {
|
|
||||||
try {
|
|
||||||
final data = jsonDecode(jsonPayload) as Map<String, dynamic>;
|
|
||||||
final isDark = data['isDark'] as bool? ?? false;
|
|
||||||
final bodyHex =
|
|
||||||
data['bodyHex'] as String? ?? (isDark ? '#000000' : '#ffffff');
|
|
||||||
final navHex = data['navHex'] as String? ?? bodyHex;
|
|
||||||
|
|
||||||
final bodyColor = _parseHex(bodyHex);
|
|
||||||
final navColor = _parseHex(navHex);
|
|
||||||
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
|
||||||
SystemUiOverlayStyle(
|
|
||||||
statusBarColor: bodyColor,
|
|
||||||
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
|
|
||||||
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
|
|
||||||
systemNavigationBarColor: navColor,
|
|
||||||
systemNavigationBarIconBrightness: isDark
|
|
||||||
? Brightness.light
|
|
||||||
: Brightness.dark,
|
|
||||||
systemNavigationBarDividerColor: Colors.transparent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
// Fallback to safe defaults
|
|
||||||
applyLight();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fallback presets ─────────────────────────────────────────────────────
|
|
||||||
static void applyLight() {
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
|
||||||
const SystemUiOverlayStyle(
|
|
||||||
statusBarColor: Color(0xFFFFFFFF),
|
|
||||||
statusBarIconBrightness: Brightness.dark,
|
|
||||||
statusBarBrightness: Brightness.light,
|
|
||||||
systemNavigationBarColor: Color(0xFFFFFFFF),
|
|
||||||
systemNavigationBarIconBrightness: Brightness.dark,
|
|
||||||
systemNavigationBarDividerColor: Colors.transparent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void applyDark() {
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
|
||||||
const SystemUiOverlayStyle(
|
|
||||||
statusBarColor: Color(0xFF000000),
|
|
||||||
statusBarIconBrightness: Brightness.light,
|
|
||||||
statusBarBrightness: Brightness.dark,
|
|
||||||
systemNavigationBarColor: Color(0xFF000000),
|
|
||||||
systemNavigationBarIconBrightness: Brightness.light,
|
|
||||||
systemNavigationBarDividerColor: Colors.transparent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Edge-to-edge setup — call once in main() ─────────────────────────────
|
|
||||||
static Future<void> enableEdgeToEdge() async {
|
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
||||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
|
||||||
applyLight(); // default until theme detector fires
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
|
||||||
static Color _parseHex(String hex) {
|
|
||||||
final clean = hex.replaceAll('#', '');
|
|
||||||
if (clean.length == 6) {
|
|
||||||
return Color(int.parse('FF$clean', radix: 16));
|
|
||||||
} else if (clean.length == 8) {
|
|
||||||
return Color(int.parse(clean, radix: 16));
|
|
||||||
}
|
|
||||||
return Colors.white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* FocusGram Theme Detector
|
|
||||||
* Reads Instagram's background + bottom nav color and reports to Flutter.
|
|
||||||
* Injected at DOCUMENT_END so DOM is ready.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const parseRgb = (str) => {
|
|
||||||
// Parses "rgb(255, 255, 255)" or "rgba(0, 0, 0, 1)" → { r, g, b, a }
|
|
||||||
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
||||||
if (!m) return null;
|
|
||||||
return {
|
|
||||||
r: parseInt(m[1]),
|
|
||||||
g: parseInt(m[2]),
|
|
||||||
b: parseInt(m[3]),
|
|
||||||
a: m[4] !== undefined ? parseFloat(m[4]) : 1,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const toHex = ({ r, g, b }) =>
|
|
||||||
'#' +
|
|
||||||
[r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
||||||
|
|
||||||
const detectColors = () => {
|
|
||||||
// Background — Instagram sets it on <body> or a root div
|
|
||||||
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
|
||||||
|
|
||||||
// Bottom nav — IG mobile web renders a fixed bottom bar
|
|
||||||
// Target by role="navigation" or position:fixed at bottom
|
|
||||||
let navBg = bodyBg;
|
|
||||||
const navCandidates = document.querySelectorAll(
|
|
||||||
'nav, [role="navigation"], div[style*="bottom"]'
|
|
||||||
);
|
|
||||||
for (const el of navCandidates) {
|
|
||||||
const style = getComputedStyle(el);
|
|
||||||
if (
|
|
||||||
style.position === 'fixed' &&
|
|
||||||
parseInt(style.bottom) <= 10 &&
|
|
||||||
style.backgroundColor !== 'rgba(0, 0, 0, 0)'
|
|
||||||
) {
|
|
||||||
navBg = style.backgroundColor;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyColor = parseRgb(bodyBg);
|
|
||||||
const navColor = parseRgb(navBg);
|
|
||||||
|
|
||||||
if (!bodyColor) return;
|
|
||||||
|
|
||||||
// Determine dark/light
|
|
||||||
const luminance = (0.299 * bodyColor.r + 0.587 * bodyColor.g + 0.114 * bodyColor.b) / 255;
|
|
||||||
const isDark = luminance < 0.5;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
bodyHex: toHex(bodyColor),
|
|
||||||
navHex: navColor ? toHex(navColor) : toHex(bodyColor),
|
|
||||||
isDark,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (window.ThemeChannel) {
|
|
||||||
window.ThemeChannel.postMessage(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run on load
|
|
||||||
detectColors();
|
|
||||||
|
|
||||||
// Watch for Instagram's dark mode toggle (adds/removes class on <html>)
|
|
||||||
const observer = new MutationObserver(detectColors);
|
|
||||||
observer.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['class', 'style', 'color-scheme'],
|
|
||||||
});
|
|
||||||
observer.observe(document.body, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['class', 'style'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also run after navigation (Instagram is SPA, URL changes without reload)
|
|
||||||
let lastUrl = location.href;
|
|
||||||
new MutationObserver(() => {
|
|
||||||
if (location.href !== lastUrl) {
|
|
||||||
lastUrl = location.href;
|
|
||||||
setTimeout(detectColors, 300); // small delay for IG to render new page
|
|
||||||
}
|
|
||||||
}).observe(document.body, { childList: true, subtree: true });
|
|
||||||
})();
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
|
||||||
|
|
||||||
class WebViewConfig {
|
|
||||||
// ── User agent — exactly as user specified ────────────────────────────────
|
|
||||||
static const String userAgent =
|
|
||||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) '
|
|
||||||
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
|
|
||||||
'Version/26.0 Mobile/15E148 Safari/604.1';
|
|
||||||
|
|
||||||
static const String instagramUrl = 'https://www.instagram.com/';
|
|
||||||
|
|
||||||
// ── Base InAppWebView settings ────────────────────────────────────────────
|
|
||||||
static InAppWebViewSettings get settings => InAppWebViewSettings(
|
|
||||||
// Identity
|
|
||||||
userAgent: userAgent,
|
|
||||||
|
|
||||||
// Performance
|
|
||||||
hardwareAcceleration: true,
|
|
||||||
// useHybridComposition: false breaks some Android 12+ devices — keep true
|
|
||||||
useHybridComposition: true,
|
|
||||||
cacheEnabled: true,
|
|
||||||
cacheMode: CacheMode.LOAD_DEFAULT,
|
|
||||||
|
|
||||||
// Media
|
|
||||||
mediaPlaybackRequiresUserGesture: false,
|
|
||||||
allowsInlineMediaPlayback: true,
|
|
||||||
allowsPictureInPictureMediaPlayback: true,
|
|
||||||
|
|
||||||
// UX — feel like native, not browser
|
|
||||||
overScrollMode: OverScrollMode.NEVER,
|
|
||||||
verticalScrollBarEnabled: false,
|
|
||||||
horizontalScrollBarEnabled: false,
|
|
||||||
supportZoom: false,
|
|
||||||
builtInZoomControls: false,
|
|
||||||
displayZoomControls: false,
|
|
||||||
scrollsToTop: true,
|
|
||||||
|
|
||||||
// JS & storage — IG needs all of these
|
|
||||||
javaScriptEnabled: true,
|
|
||||||
javaScriptCanOpenWindowsAutomatically: false,
|
|
||||||
domStorageEnabled: true,
|
|
||||||
databaseEnabled: true,
|
|
||||||
allowFileAccessFromFileURLs: false,
|
|
||||||
allowUniversalAccessFromFileURLs: false,
|
|
||||||
|
|
||||||
// Compat
|
|
||||||
mixedContentMode: MixedContentMode.COMPATIBILITY_MODE,
|
|
||||||
safeBrowsingEnabled:
|
|
||||||
false, // IG known-safe domain, no need for extra latency
|
|
||||||
// Disable Chrome custom tabs popup (links open in WebView)
|
|
||||||
suppressesIncrementalRendering: false,
|
|
||||||
|
|
||||||
// iOS specific
|
|
||||||
allowsBackForwardNavigationGestures: true,
|
|
||||||
allowsLinkPreview: false,
|
|
||||||
isFraudulentWebsiteWarningEnabled: false,
|
|
||||||
|
|
||||||
// Android specific
|
|
||||||
forceDark: ForceDark.AUTO, // respect system dark mode
|
|
||||||
algorithmicDarkeningAllowed: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── ContentBlocker rules — ad network blocking ─────────────────────────
|
|
||||||
// These are baked-in rules targeting known ad/tracking domains.
|
|
||||||
// Full EasyList parsing is handled separately and merged at runtime.
|
|
||||||
// This set is always-on regardless of user toggle.
|
|
||||||
static List<ContentBlocker> get baseContentBlockers => [
|
|
||||||
// Meta ad infrastructure
|
|
||||||
_block('.*connect\\.facebook\\.net.*'),
|
|
||||||
_block('.*graph\\.facebook\\.com.*ads.*'),
|
|
||||||
_block('.*an\\.facebook\\.com.*'),
|
|
||||||
|
|
||||||
// Google ad networks
|
|
||||||
_block('.*doubleclick\\.net.*'),
|
|
||||||
_block('.*googleadservices\\.com.*'),
|
|
||||||
_block('.*googlesyndication\\.com.*'),
|
|
||||||
_block('.*adservice\\.google\\..*'),
|
|
||||||
|
|
||||||
// Common trackers
|
|
||||||
_block('.*scorecardresearch\\.com.*'),
|
|
||||||
_block('.*quantserve\\.com.*'),
|
|
||||||
_block('.*chartbeat\\.com.*'),
|
|
||||||
_block('.*newrelic\\.com.*'),
|
|
||||||
|
|
||||||
// Ad servers
|
|
||||||
_block('.*ads\\.yahoo\\.com.*'),
|
|
||||||
_block('.*advertising\\.com.*'),
|
|
||||||
_block('.*adnxs\\.com.*'),
|
|
||||||
_block('.*adsrvr\\.org.*'),
|
|
||||||
_block('.*taboola\\.com.*'),
|
|
||||||
_block('.*outbrain\\.com.*'),
|
|
||||||
_block('.*pubmatic\\.com.*'),
|
|
||||||
_block('.*rubiconproject\\.com.*'),
|
|
||||||
_block('.*openx\\.net.*'),
|
|
||||||
_block('.*casalemedia\\.com.*'),
|
|
||||||
_block('.*criteo\\.com.*'),
|
|
||||||
_block('.*criteo\\.net.*'),
|
|
||||||
|
|
||||||
// Pixel trackers
|
|
||||||
_block('.*pixel\\.quantserve\\.com.*'),
|
|
||||||
_block('.*pixel\\.facebook\\.com.*'),
|
|
||||||
|
|
||||||
// IG-specific ad endpoints (safe to block — don't affect core IG)
|
|
||||||
_block('.*\\.instagram\\.com.*\\/ads\\/.*'),
|
|
||||||
];
|
|
||||||
|
|
||||||
static ContentBlocker _block(String pattern) => ContentBlocker(
|
|
||||||
trigger: ContentBlockerTrigger(urlFilter: pattern),
|
|
||||||
action: ContentBlockerAction(type: ContentBlockerActionType.BLOCK),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── URLRequest for initial load ───────────────────────────────────────────
|
|
||||||
static URLRequest get initialRequest => URLRequest(
|
|
||||||
url: WebUri(instagramUrl),
|
|
||||||
headers: {'Accept-Language': 'en-US,en;q=0.9', 'DNT': '1'},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user