diff --git a/.github/assets/IzzyOnDroid.png b/.github/assets/IzzyOnDroid.png
new file mode 100644
index 00000000..59ba94bf
Binary files /dev/null and b/.github/assets/IzzyOnDroid.png differ
diff --git a/assets/banner_dark.svg b/.github/assets/banner_dark.svg
similarity index 100%
rename from assets/banner_dark.svg
rename to .github/assets/banner_dark.svg
diff --git a/assets/banner_light.svg b/.github/assets/banner_light.svg
similarity index 100%
rename from assets/banner_light.svg
rename to .github/assets/banner_light.svg
diff --git a/.github/assets/get_it_on_downloader.png b/.github/assets/get_it_on_downloader.png
new file mode 100644
index 00000000..fd6967fa
Binary files /dev/null and b/.github/assets/get_it_on_downloader.png differ
diff --git a/.github/assets/get_it_on_github.png b/.github/assets/get_it_on_github.png
new file mode 100644
index 00000000..343eee44
Binary files /dev/null and b/.github/assets/get_it_on_github.png differ
diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg
new file mode 100644
index 00000000..48c7774a
--- /dev/null
+++ b/.github/badges/coverage.svg
@@ -0,0 +1,20 @@
+
+ Coverage: 46.7%
+
+
+
+
+
+
+
+
+
+
+
+ Coverage
+ Coverage
+ 46.7%
+ 46.7%
+
+
+
\ No newline at end of file
diff --git a/.github/badges/downloads.svg b/.github/badges/downloads.svg
new file mode 100644
index 00000000..2a62ae84
--- /dev/null
+++ b/.github/badges/downloads.svg
@@ -0,0 +1,20 @@
+
+ Downloads: 15.5k
+
+
+
+
+
+
+
+
+
+
+
+ Downloads
+ Downloads
+ 15.5k
+ 15.5k
+
+
+
\ No newline at end of file
diff --git a/.github/badges/prerelease.png b/.github/badges/prerelease.png
new file mode 100644
index 00000000..ff533f6c
Binary files /dev/null and b/.github/badges/prerelease.png differ
diff --git a/.github/badges/stars.svg b/.github/badges/stars.svg
new file mode 100644
index 00000000..a50f8431
--- /dev/null
+++ b/.github/badges/stars.svg
@@ -0,0 +1,20 @@
+
+ Stars: 249
+
+
+
+
+
+
+
+
+
+
+
+ Stars
+ Stars
+ 249
+ 249
+
+
+
\ No newline at end of file
diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg
new file mode 100644
index 00000000..7fc3e18a
--- /dev/null
+++ b/.github/badges/tests.svg
@@ -0,0 +1,20 @@
+
+ Tests: 198/199 passed
+
+
+
+
+
+
+
+
+
+
+
+ Tests
+ Tests
+ 198/199 passed
+ 198/199 passed
+
+
+
\ No newline at end of file
diff --git a/.github/badges/version.svg b/.github/badges/version.svg
new file mode 100644
index 00000000..0334fb2b
--- /dev/null
+++ b/.github/badges/version.svg
@@ -0,0 +1,20 @@
+
+ Version: v2026.07.10
+
+
+
+
+
+
+
+
+
+
+
+ Version
+ Version
+ v2026.07.10
+ v2026.07.10
+
+
+
\ No newline at end of file
diff --git a/.github/scripts/calculate_coverage.py b/.github/scripts/calculate_coverage.py
new file mode 100644
index 00000000..3fb18340
--- /dev/null
+++ b/.github/scripts/calculate_coverage.py
@@ -0,0 +1,32 @@
+import os
+
+if os.path.exists('coverage/lcov.info'):
+ lf = 0
+ lh = 0
+ with open('coverage/lcov.info') as f:
+ for line in f:
+ if line.startswith('LF:'):
+ lf += int(line.split(':')[1])
+ elif line.startswith('LH:'):
+ lh += int(line.split(':')[1])
+ percentage = (lh / lf) * 100 if lf > 0 else 0.0
+else:
+ percentage = 0.0
+
+percentage_str = f"{percentage:.1f}"
+
+# Color logic
+if percentage >= 90:
+ color = 'green'
+elif percentage >= 75:
+ color = 'yellowgreen'
+elif percentage >= 50:
+ color = 'yellow'
+else:
+ color = 'red'
+
+github_output = os.environ.get('GITHUB_OUTPUT')
+if github_output:
+ with open(github_output, 'a') as f:
+ f.write(f'percent={percentage_str}\n')
+ f.write(f'color={color}\n')
diff --git a/.github/scripts/calculate_test_stats.py b/.github/scripts/calculate_test_stats.py
new file mode 100644
index 00000000..23d2200b
--- /dev/null
+++ b/.github/scripts/calculate_test_stats.py
@@ -0,0 +1,36 @@
+import json
+import os
+
+if os.path.exists('test-reports/results.json'):
+ success = 0
+ failed = 0
+ with open('test-reports/results.json') as f:
+ for line in f:
+ try:
+ data = json.loads(line)
+ if data.get('type') == 'testDone':
+ if data.get('hidden', False):
+ continue
+ res = data.get('result')
+ if res == 'success':
+ success += 1
+ elif res in ('failure', 'error'):
+ failed += 1
+ except Exception:
+ pass
+ total = success + failed
+ if failed > 0:
+ summary = f'{success}/{total} passed'
+ color = 'red'
+ else:
+ summary = f'{success} passed'
+ color = 'green'
+else:
+ summary = 'no tests'
+ color = 'grey'
+
+github_output = os.environ.get('GITHUB_OUTPUT')
+if github_output:
+ with open(github_output, 'a') as f:
+ f.write(f'summary={summary}\n')
+ f.write(f'color={color}\n')
diff --git a/.github/scripts/fetch_stats.py b/.github/scripts/fetch_stats.py
new file mode 100644
index 00000000..98beb8c5
--- /dev/null
+++ b/.github/scripts/fetch_stats.py
@@ -0,0 +1,47 @@
+import urllib.request
+import json
+import os
+
+headers = {'User-Agent': 'Mozilla/5.0'}
+
+# Fetch Stars
+try:
+ req_repo = urllib.request.Request('https://api.github.com/repos/LeanBitLab/LtvLauncher', headers=headers)
+ with urllib.request.urlopen(req_repo) as response:
+ repo_data = json.loads(response.read().decode())
+ stars = repo_data.get('stargazers_count', 0)
+except Exception as e:
+ print(f'Error fetching stars: {e}')
+ stars = 0
+
+# Fetch Releases & Downloads
+try:
+ req_releases = urllib.request.Request('https://api.github.com/repos/LeanBitLab/LtvLauncher/releases', headers=headers)
+ with urllib.request.urlopen(req_releases) as response:
+ releases_data = json.loads(response.read().decode())
+ version = releases_data[0].get('tag_name', 'unknown') if releases_data else 'unknown'
+ downloads = 0
+ for release in releases_data:
+ for asset in release.get('assets', []):
+ downloads += asset.get('download_count', 0)
+except Exception as e:
+ print(f'Error fetching releases: {e}')
+ version = 'unknown'
+ downloads = 0
+
+def format_num(n):
+ if n >= 1000000:
+ return f'{n/1000000:.1f}M'
+ if n >= 1000:
+ return f'{n/1000:.1f}k'
+ return str(n)
+
+stars_str = format_num(stars)
+downloads_str = format_num(downloads)
+
+github_output = os.environ.get('GITHUB_OUTPUT')
+if github_output:
+ with open(github_output, 'a') as f:
+ f.write(f'stars={stars_str}\n')
+ f.write(f'version={version}\n')
+ f.write(f'downloads={downloads_str}\n')
diff --git a/.github/scripts/generate_release_notes.py b/.github/scripts/generate_release_notes.py
new file mode 100644
index 00000000..3c038db5
--- /dev/null
+++ b/.github/scripts/generate_release_notes.py
@@ -0,0 +1,117 @@
+import os
+import subprocess
+
+def get_version_code():
+ try:
+ with open("pubspec.yaml", "r") as f:
+ for line in f:
+ if line.strip().startswith("version:"):
+ parts = line.strip().split(":")
+ version_str = parts[1].strip()
+ if "+" in version_str:
+ return version_str.split("+")[1].strip()
+ except Exception:
+ pass
+ return None
+
+def get_fastlane_changelog(version_code):
+ if not version_code:
+ return None
+ changelog_path = f"fastlane/metadata/android/en-US/changelogs/{version_code}.txt"
+ if os.path.exists(changelog_path):
+ try:
+ with open(changelog_path, "r") as f:
+ return f.read().strip()
+ except Exception:
+ pass
+ return None
+
+def get_git_changelog(current_tag):
+ # Check if the tag exists in git
+ tag_exists = False
+ try:
+ subprocess.check_call(['git', 'rev-parse', current_tag], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ tag_exists = True
+ except Exception:
+ tag_exists = False
+
+ # If tag exists, compare. If not, use HEAD as current commit.
+ current_ref = current_tag if tag_exists else "HEAD"
+
+ try:
+ # Find the previous tag before the current tag
+ prev_tag = subprocess.check_output(
+ ['git', 'describe', '--tags', '--abbrev=0', f'{current_ref}^'],
+ stderr=subprocess.DEVNULL
+ ).decode().strip()
+ except Exception:
+ prev_tag = ""
+
+ if prev_tag:
+ git_log_range = f"{prev_tag}..{current_ref}"
+ else:
+ git_log_range = current_ref
+
+ try:
+ log_output = subprocess.check_output(
+ ['git', 'log', '--pretty=format:- %s', git_log_range]
+ ).decode().strip()
+
+ changelog_lines = []
+ if log_output:
+ for line in log_output.split('\n'):
+ # Exclude merge commits or chore commits
+ if "Merge" in line or "chore: " in line:
+ continue
+ changelog_lines.append(line)
+ return "\n".join(changelog_lines) if changelog_lines else "- General improvements and bug fixes"
+ except Exception as e:
+ return f"- New changes in release {current_tag}"
+
+def get_file_size(path):
+ if os.path.exists(path):
+ size_bytes = os.path.getsize(path)
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
+ return "0.0 MB"
+
+def main():
+ tag_name = os.environ.get('TAG_NAME', '')
+ if not tag_name:
+ try:
+ tag_name = subprocess.check_output(
+ ['git', 'describe', '--tags', '--always']
+ ).decode().strip()
+ except Exception:
+ tag_name = "v-dev"
+
+ # Try loading Fastlane changelog first
+ version_code = get_version_code()
+ changelog = get_fastlane_changelog(version_code)
+
+ # Fallback to git changelog if Fastlane is missing/empty
+ if not changelog:
+ changelog = get_git_changelog(tag_name)
+
+ size_universal = get_file_size("build/app/outputs/flutter-apk/app-release.apk")
+ size_armv7 = get_file_size("build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk")
+ size_arm64 = get_file_size("build/app/outputs/flutter-apk/app-arm64-v8a-release.apk")
+
+ release_notes = f"""### ❤️ Support the Project
+If you love using LTvLauncher (ad-free, lightweight, and open source), please consider supporting us! Your contributions help buy testing hardware, cover bills, and keep development active.
+👉 **[Sponsor LeanBitLab on GitHub](https://github.com/sponsors/LeanBitLab)**
+
+### Whats New
+{changelog}
+
+#### 📦 Artifacts
+* **LTvLauncher-universal-release.apk**: Universal build containing all architectures.
+* **LTvLauncher-armeabi-v7a-release.apk**: Optimized build for ARMv7 architectures (older Android TVs & Fire TV sticks).
+* **LTvLauncher-arm64-v8a-release.apk**: Optimized build for ARM64 architectures (newer Android TVs & NVIDIA Shield).
+"""
+
+ with open("release_notes.md", "w") as f:
+ f.write(release_notes)
+ print("Successfully generated release_notes.md")
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/badges.yml b/.github/workflows/badges.yml
new file mode 100644
index 00000000..b21b93a0
--- /dev/null
+++ b/.github/workflows/badges.yml
@@ -0,0 +1,113 @@
+name: Update Badges
+
+on:
+ schedule:
+ - cron: '0 0 * * *' # Run daily at midnight
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ update-badges:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '17'
+
+ - name: Install SQLite
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y sqlite3 libsqlite3-dev
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: '3.24.5'
+
+ - name: Install Dependencies
+ run: flutter pub get
+
+ - name: Generate Mocks
+ run: flutter pub run build_runner build --delete-conflicting-outputs
+
+ - name: Generate Database Migrations Schema
+ run: flutter pub run drift_dev schema generate drift_schemas/ test/generated_migrations/
+
+ - name: Run Tests with Coverage and Reports
+ run: |
+ mkdir -p test-reports
+ flutter test --coverage --file-reporter json:test-reports/results.json > test-reports/test_run.log || true
+
+ - name: Calculate Coverage Percentage
+ id: coverage
+ run: python3 .github/scripts/calculate_coverage.py
+
+ - name: Calculate Test Statistics
+ id: test_stats
+ run: python3 .github/scripts/calculate_test_stats.py
+
+ - name: Create Coverage Badge
+ uses: emibcn/badge-action@v2.0.2
+ with:
+ label: 'Coverage'
+ status: '${{ steps.coverage.outputs.percent }}%'
+ color: '${{ steps.coverage.outputs.color }}'
+ path: '.github/badges/coverage.svg'
+
+ - name: Create Tests Badge
+ uses: emibcn/badge-action@v2.0.2
+ with:
+ label: 'Tests'
+ status: '${{ steps.test_stats.outputs.summary }}'
+ color: '${{ steps.test_stats.outputs.color }}'
+ path: '.github/badges/tests.svg'
+
+ - name: Fetch GitHub Stats
+ id: stats
+ run: python3 .github/scripts/fetch_stats.py
+
+ - name: Create Version Badge
+ uses: emibcn/badge-action@v2.0.2
+ with:
+ label: 'Version'
+ status: '${{ steps.stats.outputs.version }}'
+ color: '7C4DFF'
+ path: '.github/badges/version.svg'
+
+ - name: Create Downloads Badge
+ uses: emibcn/badge-action@v2.0.2
+ with:
+ label: 'Downloads'
+ status: '${{ steps.stats.outputs.downloads }}'
+ color: '7C4DFF'
+ path: '.github/badges/downloads.svg'
+
+ - name: Create Stars Badge
+ uses: emibcn/badge-action@v2.0.2
+ with:
+ label: 'Stars'
+ status: '${{ steps.stats.outputs.stars }}'
+ color: '7C4DFF'
+ path: '.github/badges/stars.svg'
+
+ - name: Pull latest changes
+ run: |
+ git config --global user.name "github-actions[bot]"
+ git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git stash || true
+ git pull --rebase origin master
+ git stash pop || true
+
+ - name: Commit and Push Badges
+ uses: stefanzweifel/git-auto-commit-action@v5
+ with:
+ commit_message: "chore: auto-update badges"
+ file_pattern: ".github/badges/coverage.svg .github/badges/tests.svg .github/badges/version.svg .github/badges/downloads.svg .github/badges/stars.svg"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..70768ae7
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,94 @@
+name: Release APK
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ tag_name:
+ description: 'Tag name for the release (e.g. v1.0.0)'
+ required: true
+ default: 'v-dev'
+
+permissions:
+ contents: write
+
+jobs:
+ build-and-release:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '21'
+
+ - name: Install SQLite
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y sqlite3 libsqlite3-dev
+
+ - name: Set up Flutter
+ uses: subosito/flutter-action@v2
+ with:
+ flutter-version: '3.24.5'
+
+ - name: Install Dependencies
+ run: flutter pub get
+
+ - name: Generate Mocks
+ run: flutter pub run build_runner build --delete-conflicting-outputs
+
+ - name: Generate Database Migrations Schema
+ run: flutter pub run drift_dev schema generate drift_schemas/ test/generated_migrations/
+
+ - name: Configure Keystore Signing
+ env:
+ KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
+ if: ${{ env.KEYSTORE_BASE64 != '' }}
+ run: |
+ python3 -c "import os, base64; s = os.environ['KEYSTORE_BASE64']; s = ''.join(s.split()); m = len(s) % 4; s += '=' * (4 - m) if m else ''; open('android/my-release-key.jks', 'wb').write(base64.b64decode(s))"
+
+ - name: Build Release APKs
+ env:
+ KEYSTORE_FILE: "../my-release-key.jks"
+ KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ run: |
+ # Build universal APK first
+ flutter build apk --release --target-platform=android-arm,android-arm64
+ # Rename/save it temporarily
+ mv build/app/outputs/flutter-apk/app-release.apk build/app/outputs/flutter-apk/app-release.apk.temp
+ # Build split APKs
+ flutter build apk --release --split-per-abi --target-platform=android-arm,android-arm64
+ # Rename to final LTvLauncher-* names
+ mv build/app/outputs/flutter-apk/app-release.apk.temp build/app/outputs/flutter-apk/LTvLauncher-universal-release.apk
+ mv build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk build/app/outputs/flutter-apk/LTvLauncher-armeabi-v7a-release.apk
+ mv build/app/outputs/flutter-apk/app-arm64-v8a-release.apk build/app/outputs/flutter-apk/LTvLauncher-arm64-v8a-release.apk
+
+ - name: Generate Release Notes
+ env:
+ TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
+ run: python3 .github/scripts/generate_release_notes.py
+
+ - name: Create GitHub Release and Upload APKs
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.event.inputs.tag_name || github.ref_name }}
+ body_path: release_notes.md
+ files: |
+ build/app/outputs/flutter-apk/LTvLauncher-universal-release.apk
+ build/app/outputs/flutter-apk/LTvLauncher-armeabi-v7a-release.apk
+ build/app/outputs/flutter-apk/LTvLauncher-arm64-v8a-release.apk
+ draft: false
+ prerelease: ${{ contains(github.event.inputs.tag_name || github.ref_name, '-') }}
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 144f6a80..1572e4eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,24 +3,27 @@
*.log
*.pyc
*.swp
+*~
+*.bak
+*.tmp
+*.temp
+Thumbs.db
+ehthumbs.db
+Desktop.ini
.DS_Store
.atom/
.buildlog/
.history
.svn/
-# IntelliJ related
+# IDEs and Editors
*.iml
*.ipr
*.iws
.idea/
+.vscode/
-# The .vscode folder contains launch configuration and tasks you configure in
-# VS Code which you may wish to be included in version control, so this line
-# is commented out by default.
-#.vscode/
-
-# Flutter/Dart/Pub related
+# Flutter / Dart / Pub
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
@@ -30,65 +33,94 @@
.pub-cache/
.pub/
/build/
+pubspec-overrides.yaml
-# Web related
+# Web / Platforms (Unused Flutter Modules)
lib/generated_plugin_registrant.dart
+.android/
+.ios/
-# Symbolication related
-app.*.symbols
+# Swift Package Manager
+.swiftpm/
+.build/
-# Obfuscation related
+# Symbols & Obfuscation
+app.*.symbols
app.*.map.json
-# Android Studio will place build artifacts here
+# Android Build & Secrets
/android/app/debug
/android/app/profile
/android/app/release
/android/app/build
/android/build
-
android/app/google-services.json
android/app/upload-keystore.jks
-coverage/
-
-# Secrets
+android/keystore.properties
*.jks
*.keystore
keystore.properties
key.properties
-# Logs
+# Coverage, Logs, and Test Reports
+coverage/
build_log.txt
release_build_log.txt
-
-# Gemini
-.geminiignore
-GEMINI.md
-GEMINI.original.md
-
-# Qwen
-.qwenignore
-QWEN.md
-.qwen/
-
-# Local Project Docs & Tools
+test-reports/
pdocs/
Pdoc/
-build_log.txt
-release_build_log.txt
+scratch/
+test/app_card_long_press_test.dart
-# Android Secrets
-android/keystore.properties
+# Drift Generated Schemas & Mockito Mocks
+drift_schemas/
+test/generated_migrations/
+test/mocks.mocks.dart
+# Local Tools, Libs & Scripts
+*.py
+!.github/scripts/**/*.py
+sqlite_lib/
+com.leanbitlab.ltvL.yml
+*.db
+*.db-journal
+*.db-shm
+*.db-wal
+*.sqlite
+*.sqlite-journal
+*.sqlite-shm
+*.sqlite-wal
+
+# Environment & Dev Tooling
+.env
+.gitlab-ci.yml
+commitlint.config.js
+node_modules/
+.npm/
+
+# AI Agent Configs & Documentation
.aider*
.antigravityignore
+.antigravitycli/
+.geminiignore
.gemini/
-
-# Swift Package Manager (Context7 recommendation)
-.swiftpm/
-.build/
-
-# Flutter module (Context7 recommendation)
-.android/
-.ios/
-*.py
+GEMINI.md
+GEMINI.original.md
+.qwenignore
+.qwen/
+QWEN.md
+.agents/
+AGENTS.md
+AGENTS.original.md
+.pi/
+.claude/
+CLAUDE.md
+CLAUDE.original.md
+.cursor/
+.cursorrules
+.continue/
+.roo/
+.supermaven/
+.windsurf/
+.cody/
+.copilot/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
deleted file mode 100644
index 512937a5..00000000
--- a/.gitlab-ci.yml
+++ /dev/null
@@ -1,154 +0,0 @@
-variables:
- FLUTTER_DOCKER_IMAGE: cirrusci/flutter:3.7.5
-
-stages:
- - precheck
- - analyze
- - test
- - build
- - transform
- - upload
- - deploy
- - promote
-
-commitlint:
- image:
- name: gtramontina/commitlint:8.3.5
- entrypoint: [ "" ]
- stage: precheck
- needs: [ ]
- script:
- - commitlint --from origin/master --verbose
- except:
- - master
-
-stylelint:
- image: $FLUTTER_DOCKER_IMAGE
- stage: precheck
- needs: [ ]
- script:
- - flutter format --line-length 120 --dry-run --set-exit-if-changed .
-
-analyze:
- image: $FLUTTER_DOCKER_IMAGE
- stage: analyze
- needs: [ ]
- script:
- - flutter analyze
-
-test:
- image: $FLUTTER_DOCKER_IMAGE
- stage: test
- needs: [ ]
- before_script:
- - apt-get update
- - apt-get install -y libsqlite3-dev
- script:
- - flutter test --coverage
- after_script:
- - lcov --remove coverage/lcov.info lib/database.drift.dart --output-file coverage/lcov.info
- - genhtml --output ./coverage coverage/lcov.info
- coverage: '/^\s+lines\.+:\s+([\d.]+\%)\s+/'
-
-build:appbundle:debug:
- image: $FLUTTER_DOCKER_IMAGE
- stage: build
- needs: [ ]
- except:
- - master
- - tags
- script:
- - flutter build appbundle --debug
-
-build:appbundle:release:
- image: $FLUTTER_DOCKER_IMAGE
- stage: build
- needs: [ ]
- only:
- - master
- - tags
- before_script:
- - echo "$GOOGLE_SERVICES_JSON" > android/app/google-services.json
- - echo -n "$SIGNING_JKS_FILE_BASE64" | base64 -d > android/app/upload-keystore.jks
- - if [ -n "$CI_COMMIT_TAG" ]; then BUILD_NAME=$CI_COMMIT_TAG; else BUILD_NAME=$CI_COMMIT_SHORT_SHA; fi
- script:
- - flutter build appbundle --release --build-number=$CI_PIPELINE_IID --build-name=$BUILD_NAME
- artifacts:
- paths:
- - build/app/outputs/bundle/release/app-release.aab
-
-build:apk:
- image: openjdk:16-buster
- stage: transform
- needs: [ "build:appbundle:release" ]
- only:
- - tags
- before_script:
- - echo -n "$SIGNING_JKS_FILE_BASE64" | base64 -d > android/app/upload-keystore.jks
- - wget https://github.com/google/bundletool/releases/download/1.6.1/bundletool-all-1.6.1.jar -O bundletool.jar
- script:
- - java -jar bundletool.jar build-apks --mode=universal --bundle=build/app/outputs/bundle/release/app-release.aab --output=app-release.apks --ks=android/app/upload-keystore.jks --ks-pass=pass:$SIGNING_KEYSTORE_PASSWORD --ks-key-alias=$SIGNING_KEY_ALIAS --key-pass=pass:$SIGNING_KEY_PASSWORD
- - unzip app-release.apks
- - mv universal.apk app-release.apk
- artifacts:
- paths:
- - app-release.apk
-
-deploy:gitlab-release:
- image: curlimages/curl:latest
- stage: deploy
- needs: [ "commitlint", "stylelint", "analyze", "test", "build:apk" ]
- only:
- - tags
- script:
- - 'curl --header "JOB-TOKEN: $CI_JOB_TOKEN" --upload-file app-release.apk "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/flauncher/$CI_COMMIT_TAG/flauncher-$CI_COMMIT_TAG.apk"'
-
-deploy:play-store:
- stage: deploy
- needs: [ "commitlint", "stylelint", "analyze", "test", "build:appbundle:release" ]
- image: ruby:2.6.3
- only:
- - tags
- before_script:
- - echo "$GOOGLE_PLAY_API_KEY" > android/fastlane/google_play_api_key.json
- - export LC_ALL=en_US.UTF-8
- - export LANG=en_US.UTF-8
- - gem install fastlane -NV
- - cd android
- script:
- - fastlane deploy
-
-promote:play-store:
- stage: promote
- needs: [ "deploy:play-store" ]
- image: ruby:2.6.3
- when: manual
- only:
- - tags
- before_script:
- - echo "$GOOGLE_PLAY_API_KEY" > android/fastlane/google_play_api_key.json
- - export LC_ALL=en_US.UTF-8
- - export LANG=en_US.UTF-8
- - gem install fastlane -NV
- - cd android
- script:
- - fastlane promote
-
-promote:gitlab-release:
- stage: promote
- needs: [ "deploy:gitlab-release" ]
- image: registry.gitlab.com/gitlab-org/release-cli:latest
- when: manual
- only:
- - tags
- script:
- - echo 'running promote:gitlab-release'
- release:
- name: $CI_COMMIT_TAG
- description: FLauncher v$CI_COMMIT_TAG
- tag_name: $CI_COMMIT_TAG
- assets:
- links:
- - name: flauncher-$CI_COMMIT_TAG.apk
- url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/flauncher/$CI_COMMIT_TAG/flauncher-$CI_COMMIT_TAG.apk
- filepath: /flauncher-$CI_COMMIT_TAG.apk
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index e18d1062..00000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "java.configuration.updateBuildConfiguration": "automatic",
- "java.compile.nullAnalysis.mode": "automatic",
- "editor.fontSize": 16,
- "editor.minimap.sectionHeaderFontSize": 11,
- "debug.console.fontSize": 16,
- "markdown.preview.fontSize": 16
-}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index 9998886d..00000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# LTvLauncher Agent Guidelines
-
-This document provides important guidelines, architectural decisions, and context for AI agents (like Jules) working on the LTvLauncher project. Read these instructions carefully before making modifications to ensure consistency, performance, and stability.
-
-## Project Overview
-
-- **Name:** LTvLauncher (a fork of FLauncher)
-- **Language:** Dart / Flutter
-- **Platform:** Android TV
-- **Package Names:** `com.leanbitlab.ltvL` (and legacy `me.efesser.flauncher`)
-
-## Architecture & State Management
-
-- **State Management:** The project uses the `provider` package (`Selector`, `ChangeNotifierProvider`, `context.read`) for state management and dependency injection.
-- **Service Logic:** Complex logic involving joining categories and applications, and visibility filtering, is handled in `AppsService._refreshState` rather than the database layer. Tests for these behaviors reside in `test/providers/apps_service_test.dart`.
-- **Race Conditions:** To prevent race conditions in asynchronous `ChangeNotifier` methods that update state, use a sequence counter (e.g., `_callCount`) incremented at method entry and compare it to a local snapshot before calling `notifyListeners()`.
-- **Localization:** Managed with `flutter gen-l10n` via `.arb` files in `lib/l10n/`. The generated `AppLocalizations` classes must be imported via `package:flutter_gen/gen_l10n/app_localizations.dart` (do not use relative paths).
-- **Settings Service Optimization:** Cache SharedPreferences values in local fields initialized during construction. Setters must update both the local cache and SharedPreferences to avoid N+1 synchronous read overhead. Use a `Key` suffix for SharedPreferences string constants (e.g., `_themesKey`).
-- **Backward Compatibility:** When renaming or refactoring settings features, preserve the underlying SharedPreferences string keys (e.g., `'wifi_usage_period'`) to maintain compatibility for existing users.
-- **Cache Invalidation:** In `AppsService`, ensure cache invalidation methods are called not only when the primary collection (`_categoriesById`) changes, but also when mutable properties of individual items (e.g., `category.name`) are updated.
-
-## Testing & Mocking
-
-- **Test Framework:** Use `mockito` and `flutter_test`. Run the test suite with `flutter test` (do not use `dart test`).
-- **Mock Generation:** Mocks are defined in `test/mocks.dart` using `@GenerateMocks`. To generate new mocks, append the target class to the array and run `flutter pub run build_runner build --delete-conflicting-outputs`.
-- **Model Tests:** Unit tests for data models should be organized under `test/models/` mirroring `lib/models/`.
-- **Database Testing:**
- - Instantiate the database with `FLauncherDatabase.inMemory()` to avoid side effects.
- - When testing Drift database operations with foreign keys, ensure parent records are inserted first (e.g., `database.persistApps()`).
-- **Provider / UI Widget Testing:**
- - Wrap the test widget in a `MultiProvider` and `Directionality` (e.g., `textDirection: TextDirection.ltr`).
- - Provide default stubs for visual preference getters in mocked `SettingsService` (e.g., `accentColorHex`, `theme`, `appHighlightAnimationEnabled`) to prevent null/type errors.
- - For `intl` date formatting (like `DateTimeWidget`), call `await initializeDateFormatting('en_US');` in `setUpAll`.
-- **FocusNode Geometry:** When testing FocusNode geometry or traversal (critical for Android TV), use `WidgetTester` with `Stack` and `Positioned` to explicitly lay out nodes, as they need to be attached to a rendered widget tree to have dimensions.
-- **AppsService Testing:**
- - Wait for the service to signal completion by polling the `initialized` property (`while (!appsService.initialized) { await Future.delayed(...); }`).
- - Ensure `MockFLauncherChannel` is stubbed for `getApplicationIcon` and `getApplicationBanner` to avoid `MissingStubError`.
-- **Asynchronous Interactions:** Use `await Future.delayed(Duration.zero);` to allow the microtask queue to clear before making assertions on unawaited asynchronous calls.
-- **SharedPreferences & Channels:** Initialize test environments using `SharedPreferencesStorePlatform.instance = InMemorySharedPreferencesStore.empty();` and mock channel calls via `TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler`.
-- **Test File Naming:** Avoid versioned suffixes like `_v2` in filenames; use descriptive names (e.g., `apps_service_batch_test.dart`).
-
-## Performance & Optimization
-
-- **File I/O:** Avoid synchronous file I/O (e.g., `File.existsSync`) on the main UI thread. Prefer `File.exists` and `await` the result to prevent jank.
-- **Map Lookups:** Prefer a single lookup with a null check (`final val = map[key]; if (val != null)`) over `containsKey` followed by a forced unwrap (`map[key]!`).
-- **Concurrency:** Use `package:pool` to manage concurrency limits for unawaited background tasks, particularly for intensive operations like fetching app icons and banners.
-- **Database Operations (Drift):** Use the `batch` API for multiple insertions, or rely on existing custom batch methods (like `insertAppsCategories`) for efficient single-transaction operations. Bulk operations (e.g., `addAllToCategory`) prevent N+1 query patterns.
-- **Logging:** Prefer `log` from `dart:developer` over `print`. Use the `name` parameter to provide class context (e.g., `name: 'NetworkService'`) and pass exceptions to the `error` parameter.
-- **Image Rebuilding:** To force an `Image` widget to rebuild when a `FileImage` changes but its path remains the same, append a version counter or timestamp to its `Key` (e.g., `Key("background_$version")`).
-
-## UI & Interactions
-
-- **Painting Order:** In `ListView` or `GridView`, items are painted sequentially. To prevent scaled-up focused items (like app banners) from being visually cropped by siblings, cap the maximum scale factor relative to the item's `maxWidth` using `LayoutBuilder` so expansion stays within spacing gaps.
-- **Keyboard Events:** When migrating from `onKey` to `onKeyEvent`, ensure `KeyRepeatEvent` is explicitly handled alongside `KeyDownEvent` to preserve continuous/long-press interactions.
-
-## Platform / Android Specifics
-
-- **Java Compatibility:** The Android project is configured with Java 21 compatibility (source and target).
-- **URI Injection Prevention:** Validate `packageName` strings received from Flutter MethodChannels (e.g., regex `^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)+$`) before using them in `Uri.fromParts` to construct Intents.
-- **NetworkStatsManager:** When querying `querySummaryForDevice`, ensure the `subscriberId` parameter is passed as `null` rather than an empty string `""`.
-- **Backups:** Android backups are explicitly disabled via `android:allowBackup="false"` and removing `android:fullBackupContent` in `AndroidManifest.xml` to prevent unauthorized data extraction via adb backup.
-- **Multi-Manifest:** The project follows a multi-manifest structure with source sets for `main`, `debug`, and `profile` located under `android/app/src/`.
-
-## Build, Environment, and Tooling
-
-- **Git & Environment Files:** **Never edit the `.gitignore` file.**
-- **Dependency Issues (Environment Constraints):**
- - Commands like `flutter pub get` or `flutter test` might unintentionally update `pubspec.lock` with newer/beta SDK constraints. Restore `pubspec.lock` (e.g., `git restore pubspec.lock`) before committing.
- - Constant evaluation errors (`FontWeight`) might occur when `google_fonts` >= 6.3.0. Pinning `google_fonts` to a lower version like `6.2.1` in `pubspec.yaml` can bypass this during test compilation.
- - The execution environment may encounter timeouts (>400s) on network-heavy commands (`flutter pub get`, `flutter test`, `flutter analyze`). Missing `.dart_tool/` triggers `pub get` automatically.
- - Use `dart analyze | grep ""` to quickly verify specific static analysis fixes without resolving all environment dependencies.
- - Standard Git network operations (e.g., `git fetch`) might timeout. Use `git restore` and manual re-application if necessary for merge conflicts.
-- **Android Gradle:** To execute Android Gradle commands (`gradle test`), the `android/local.properties` file must define `sdk.dir` and `flutter.sdk` (`/opt/flutter` in this environment).
-- **Releases & Fastlane:**
- - When updating `versionCode` in `pubspec.yaml`, rename the corresponding Fastlane changelog file in `fastlane/metadata/android/en-US/changelogs/` to match (e.g., `6101.txt`).
- - For Reproducible Builds (RB) on F-Droid / IzzyOnDroid, ensure release tags exactly match the commit where the `pubspec.yaml` version aligns with the published APK (e.g., `version: 3.2.2+6101`).
diff --git a/README.md b/README.md
index 2b90f12a..acaa0e1e 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,62 @@
# LTvLauncher
-
-
-
+
+
+
-[](https://github.com/LeanBitLab/LtvLauncher/releases/latest) [](https://github.com/LeanBitLab/LtvLauncher/releases) [](https://github.com/LeanBitLab/LtvLauncher/stargazers)
+[](https://github.com/LeanBitLab/LtvLauncher/releases/latest) [](https://github.com/LeanBitLab/LtvLauncher/releases) [](https://github.com/LeanBitLab/LtvLauncher/stargazers) [](https://github.com/LeanBitLab/LtvLauncher/actions/workflows/badges.yml) [](https://github.com/LeanBitLab/LtvLauncher/actions/workflows/badges.yml)
+
+
**LTvLauncher** is a fork of [FLauncher](https://github.com/osrosal/flauncher) (originally by [etienn01](https://gitlab.com/flauncher/flauncher)) - an open-source alternative launcher for Android TV.
This customized version introduces usability enhancements and some UX improvements by [LeanBitLab](https://github.com/LeanBitLab).
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+## Screenshots
+
+
+
+ Home Screen
+ Settings 1
+ Settings 2
+ Settings 3
+ Screensaver
+
+
+
+
+
+
+
+
+
+
## Key Features & Enhancements
+- **Multiple Backups & Restore Dialog** - Export backups with unique timestamps and restore them via a D-pad friendly in-app backup selection dialog.
+- **System-wide Notification Overlays** - Display notification alerts globally over other apps.
+- **Status Bar Inputs Toggle** - Easily toggle the visibility of the inputs widget in the status bar.
+- **TV Input Selector** - Directly switch between input sources (HDMI, AV, etc.) from the launcher.
+- **Accessibility Settings Page** - Manage launcher-specific accessibility settings.
- **Data Usage Widget** - Track daily Internet consumption (WiFi, Ethernet, Mobile) directly from the status bar.
- **Inbuilt OLED Screensaver** - Minimal screensaver with 30s clock position shifting to prevent burn-in.
- **Easy WiFi Access** - Network indicator doubles as a shortcut to system WiFi settings.
@@ -59,32 +95,23 @@ This customized version introduces usability enhancements and some UX improvemen
- [x] Navigation sound feedback
- [x] Official support for `armeabi-v7a`, `arm64-v8a`, and `x86_64` devices.
-## Screenshots
-
-
- Home Screen
- Settings 1
- Settings 2
- Settings 3
- Screensaver
-
-
-
-
-
-
-
-
-
+## Set LTvLauncher as default launcher
+### Method 1: Via Built-in Settings (Recommended)
+This is the easiest and native way. Go to **Settings -> Accessibility -> Set as default launcher**. This will open the system home picker or default apps settings directly where you can select **LTvLauncher** as your default home app.
-## Set LTvLauncher as default launcher
+### Method 2: Home Button Fix (Google TV / Fire TV)
+If your device blocks changing the default launcher (common on Google TV and newer Fire TV updates), you can use our built-in Home Button Fix:
+1. Open **Settings -> Accessibility**.
+2. Tap **Home Button Fix (Google TV)**.
+3. Turn on the accessibility service for **LTvLauncher** in the system settings.
+Once enabled, the launcher will intercept Home button presses to automatically return to **LTvLauncher**.
-### Method 1: Remap the Home button
-This is the "safer" and easiest way. Use [key Mapper](https://github.com/keymapperorg/KeyMapper) to remap the Home button of the remote to launch LTvLauncher.
+### Method 3: Remap the Home button
+This is another fallback if the built-in options don't suit your setup. Use [Key Mapper](https://github.com/keymapperorg/KeyMapper) to remap the Home button of the remote to launch LTvLauncher.
-### Method 2: Disable the default launcher
+### Method 4: Disable the default launcher via ADB
**:warning: Disclaimer :warning:**
**You are doing this at your own risk, and you'll be responsible in any case of malfunction on your device.**
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 0e2e4d4a..a1ce4189 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -21,6 +21,18 @@ plugins {
id "dev.flutter.flutter-gradle-plugin"
}
+// Force Flutter to only build for ARMv7 and ARMv8a by default
+if (!project.hasProperty('target-platform')) {
+ project.ext.set('target-platform', 'android-arm,android-arm64')
+} else {
+ def platforms = project.property('target-platform').split(',')
+ def filtered = platforms.findAll { it == 'android-arm' || it == 'android-arm64' }.join(',')
+ if (filtered) {
+ project.ext.set('target-platform', filtered)
+ }
+}
+
+
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
@@ -44,6 +56,9 @@ secretProperties.setProperty("signing_keystore_password", "${System.getenv('FLAU
secretProperties.setProperty("signing_key_password", "${System.getenv('FLAUNCHER_SIGNING_KEY_PASSWORD')}")
secretProperties.setProperty("signing_key_alias", "${System.getenv('FLAUNCHER_SIGNING_KEY_ALIAS')}")
+def keystoreFilePath = System.getenv("KEYSTORE_FILE") ?: localProperties['storeFile']
+def hasKeystore = keystoreFilePath != null && file(keystoreFilePath).exists()
+
android {
dependenciesInfo {
// Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid)
@@ -59,16 +74,19 @@ android {
targetSdk 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
+ ndk {
+ abiFilters 'armeabi-v7a', 'arm64-v8a'
+ }
}
signingConfigs {
debug {}
release {
- if (localProperties['storeFile'] != null) {
- storeFile file(localProperties['storeFile'])
- storePassword localProperties['storePassword']
- keyAlias localProperties['keyAlias']
- keyPassword localProperties['keyPassword']
+ if (hasKeystore) {
+ storeFile file(keystoreFilePath)
+ storePassword System.getenv("KEYSTORE_PASSWORD") ?: localProperties['storePassword']
+ keyAlias System.getenv("KEY_ALIAS") ?: localProperties['keyAlias']
+ keyPassword System.getenv("KEY_PASSWORD") ?: localProperties['keyPassword']
}
}
}
@@ -85,14 +103,21 @@ android {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
- signingConfig signingConfigs.release
+ if (hasKeystore) {
+ signingConfig signingConfigs.release
+ }
+ ndk {
+ abiFilters 'armeabi-v7a', 'arm64-v8a'
+ }
}
}
splits {
abi {
// https://stackoverflow.com/a/39950584/21037183
- enable gradle.startParameter.taskNames.any { it.contains("assembleRelease") }
+ enable gradle.startParameter.taskNames.any { it.contains("assembleRelease") } && System.getenv("UNIVERSAL_ONLY") != "true"
+ reset()
+ include "armeabi-v7a", "arm64-v8a"
universalApk true
}
}
@@ -106,6 +131,7 @@ android {
applicationVariants.all { variant ->
variant.outputs.all { output ->
+ output.versionCodeOverride = variant.versionCode
def abi = output.getFilter(com.android.build.OutputFile.ABI)
if (abi != null) {
outputFileName = "LTvLauncher-${abi}-${variant.buildType.name}.apk"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ed896d1f..6c3a222d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -30,12 +30,16 @@
+
+
+
+
@@ -89,6 +93,29 @@
android:resource="@xml/screensaver" />
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/leanbitlab/ltvL/LauncherAccessibilityService.java b/android/app/src/main/java/com/leanbitlab/ltvL/LauncherAccessibilityService.java
new file mode 100644
index 00000000..ca6738ae
--- /dev/null
+++ b/android/app/src/main/java/com/leanbitlab/ltvL/LauncherAccessibilityService.java
@@ -0,0 +1,30 @@
+package com.leanbitlab.ltvL;
+
+import android.accessibilityservice.AccessibilityService;
+import android.content.Intent;
+import android.view.KeyEvent;
+
+public class LauncherAccessibilityService extends AccessibilityService {
+ @Override
+ public void onAccessibilityEvent(android.view.accessibility.AccessibilityEvent event) {
+ // Not used
+ }
+
+ @Override
+ public void onInterrupt() {
+ // Not used
+ }
+
+ @Override
+ protected boolean onKeyEvent(KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_HOME) {
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ Intent intent = new Intent(this, MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(intent);
+ }
+ return true; // Consume Home key event
+ }
+ return super.onKeyEvent(event);
+ }
+}
diff --git a/android/app/src/main/java/com/leanbitlab/ltvL/LauncherNotificationListenerService.java b/android/app/src/main/java/com/leanbitlab/ltvL/LauncherNotificationListenerService.java
new file mode 100644
index 00000000..dc68c8c9
--- /dev/null
+++ b/android/app/src/main/java/com/leanbitlab/ltvL/LauncherNotificationListenerService.java
@@ -0,0 +1,254 @@
+package com.leanbitlab.ltvL;
+
+import android.app.Notification;
+import android.content.pm.PackageManager;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Gravity;
+import android.view.WindowManager;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+import java.util.ArrayList;
+import java.util.List;
+
+public class LauncherNotificationListenerService extends NotificationListenerService {
+ public interface NotificationListener {
+ void onNotificationChanged();
+ }
+
+ private static final List listeners = new ArrayList<>();
+ private static LauncherNotificationListenerService instance = null;
+
+ public static void registerListener(NotificationListener listener) {
+ synchronized (listeners) {
+ listeners.add(listener);
+ }
+ }
+
+ public static void unregisterListener(NotificationListener listener) {
+ synchronized (listeners) {
+ listeners.remove(listener);
+ }
+ }
+
+ public static LauncherNotificationListenerService getInstance() {
+ return instance;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ instance = this;
+ notifyListeners();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (instance == this) {
+ instance = null;
+ }
+ notifyListeners();
+ }
+
+ @Override
+ public void onNotificationPosted(StatusBarNotification sbn) {
+ notifyListeners();
+ showNotificationPopup(sbn);
+ }
+
+ @Override
+ public void onNotificationRemoved(StatusBarNotification sbn) {
+ notifyListeners();
+ }
+
+ private void notifyListeners() {
+ synchronized (listeners) {
+ for (NotificationListener listener : listeners) {
+ listener.onNotificationChanged();
+ }
+ }
+ }
+
+ private boolean canShowPopup() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ return android.provider.Settings.canDrawOverlays(this);
+ }
+ return true;
+ }
+
+ private int dpToPx(int dp) {
+ return (int) (dp * getResources().getDisplayMetrics().density);
+ }
+
+ private void showNotificationPopup(StatusBarNotification sbn) {
+ if (sbn == null || sbn.isOngoing()) {
+ return;
+ }
+
+ android.content.SharedPreferences prefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE);
+ boolean enabled = prefs.getBoolean("flutter.system_notifications_popup", false);
+ if (!enabled) {
+ return;
+ }
+
+ if (!canShowPopup()) {
+ return;
+ }
+
+ android.app.Notification notification = sbn.getNotification();
+ if (notification == null) return;
+
+ // Filter out service and media transport notifications
+ String category = notification.category;
+ if (android.app.Notification.CATEGORY_SERVICE.equals(category) ||
+ android.app.Notification.CATEGORY_TRANSPORT.equals(category)) {
+ return;
+ }
+
+ Bundle extras = notification.extras;
+ if (extras == null) return;
+
+ // Filter out media sessions (playback)
+ if (extras.containsKey(android.app.Notification.EXTRA_MEDIA_SESSION)) {
+ return;
+ }
+
+ CharSequence titleChar = extras.getCharSequence(Notification.EXTRA_TITLE);
+ CharSequence textChar = extras.getCharSequence(Notification.EXTRA_TEXT);
+
+ final String title = titleChar != null ? titleChar.toString().trim() : "";
+ final String text = textChar != null ? textChar.toString().trim() : "";
+
+ if (title.isEmpty() && text.isEmpty()) {
+ return;
+ }
+
+ final String packageName = sbn.getPackageName();
+ final PackageManager pm = getPackageManager();
+ String appLabel = packageName;
+ Drawable appIcon = null;
+ try {
+ android.content.pm.ApplicationInfo appInfo = pm.getApplicationInfo(packageName, 0);
+ appLabel = pm.getApplicationLabel(appInfo).toString().trim();
+ appIcon = pm.getApplicationIcon(appInfo);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ // Filter out generic app running notifications where title/text are just the app label
+ if (title.equalsIgnoreCase(appLabel) && (text.isEmpty() || text.equalsIgnoreCase(appLabel))) {
+ return;
+ }
+ if (title.equalsIgnoreCase(packageName) && (text.isEmpty() || text.equalsIgnoreCase(packageName))) {
+ return;
+ }
+
+ final String finalAppLabel = appLabel;
+ final Drawable finalAppIcon = appIcon;
+
+ final WindowManager windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
+ if (windowManager == null) return;
+
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ android.widget.LinearLayout container = new android.widget.LinearLayout(LauncherNotificationListenerService.this);
+ container.setOrientation(android.widget.LinearLayout.HORIZONTAL);
+ container.setGravity(Gravity.CENTER_VERTICAL);
+ int pad = dpToPx(16);
+ container.setPadding(pad, pad, pad, pad);
+
+ android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable();
+ background.setColor(android.graphics.Color.parseColor("#E01E1E1E"));
+ background.setCornerRadius(dpToPx(12));
+ background.setStroke(dpToPx(1), android.graphics.Color.parseColor("#44FFFFFF"));
+ container.setBackground(background);
+
+ android.widget.ImageView iconView = new android.widget.ImageView(LauncherNotificationListenerService.this);
+ if (finalAppIcon != null) {
+ iconView.setImageDrawable(finalAppIcon);
+ }
+ android.widget.LinearLayout.LayoutParams iconParams = new android.widget.LinearLayout.LayoutParams(dpToPx(40), dpToPx(40));
+ iconParams.rightMargin = dpToPx(12);
+ iconView.setLayoutParams(iconParams);
+ container.addView(iconView);
+
+ android.widget.LinearLayout textContainer = new android.widget.LinearLayout(LauncherNotificationListenerService.this);
+ textContainer.setOrientation(android.widget.LinearLayout.VERTICAL);
+ android.widget.LinearLayout.LayoutParams textContainerParams = new android.widget.LinearLayout.LayoutParams(
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ textContainer.setLayoutParams(textContainerParams);
+
+ android.widget.TextView titleView = new android.widget.TextView(LauncherNotificationListenerService.this);
+ String headerText = finalAppLabel;
+ if (title != null && !title.isEmpty()) {
+ headerText += " • " + title;
+ }
+ titleView.setText(headerText);
+ titleView.setTextColor(android.graphics.Color.WHITE);
+ titleView.setTextSize(14);
+ titleView.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
+ textContainer.addView(titleView);
+
+ if (text != null && !text.isEmpty()) {
+ android.widget.TextView bodyView = new android.widget.TextView(LauncherNotificationListenerService.this);
+ bodyView.setText(text);
+ bodyView.setTextColor(android.graphics.Color.parseColor("#CCCCCC"));
+ bodyView.setTextSize(13);
+ android.widget.LinearLayout.LayoutParams bodyParams = new android.widget.LinearLayout.LayoutParams(
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ bodyParams.topMargin = dpToPx(4);
+ bodyView.setLayoutParams(bodyParams);
+ textContainer.addView(bodyView);
+ }
+
+ container.addView(textContainer);
+
+ int layoutFlag;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ layoutFlag = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
+ } else {
+ layoutFlag = WindowManager.LayoutParams.TYPE_PHONE;
+ }
+
+ WindowManager.LayoutParams params = new WindowManager.LayoutParams(
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ layoutFlag,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
+ PixelFormat.TRANSLUCENT
+ );
+
+ params.gravity = Gravity.TOP | Gravity.END;
+ params.x = dpToPx(24);
+ params.y = dpToPx(24);
+
+ windowManager.addView(container, params);
+
+ new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ windowManager.removeView(container);
+ } catch (Exception e) {
+ }
+ }
+ }, 4000);
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ });
+ }
+}
diff --git a/android/app/src/main/java/com/leanbitlab/ltvL/MainActivity.java b/android/app/src/main/java/com/leanbitlab/ltvL/MainActivity.java
index fafc38f6..e13f0a44 100644
--- a/android/app/src/main/java/com/leanbitlab/ltvL/MainActivity.java
+++ b/android/app/src/main/java/com/leanbitlab/ltvL/MainActivity.java
@@ -31,6 +31,9 @@
import android.os.Build;
import android.provider.Settings;
import android.util.Pair;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvInputInfo;
+import android.media.tv.TvContract;
import androidx.annotation.NonNull;
@@ -63,10 +66,15 @@
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import android.service.notification.StatusBarNotification;
+import android.content.ComponentName;
+
public class MainActivity extends FlutterActivity {
private final String METHOD_CHANNEL = "me.efesser.flauncher/method";
private final String APPS_EVENT_CHANNEL = "me.efesser.flauncher/event_apps";
private final String NETWORK_EVENT_CHANNEL = "me.efesser.flauncher/event_network";
+ private final String NOTIFICATIONS_EVENT_CHANNEL = "me.efesser.flauncher/event_notifications";
+ private MethodChannel.Result pendingPermissionResult;
@Override
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
@@ -119,15 +127,57 @@ public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
result.success(null);
}
case "checkWriteSettingsPermission" -> result.success(checkWriteSettingsPermission());
- case "requestWriteSettingsPermission" -> {
- requestWriteSettingsPermission();
- result.success(null);
- }
+ case "requestWriteSettingsPermission" -> result.success(requestWriteSettingsPermission());
case "setSystemBrightness" -> {
int brightness = call.argument("brightness");
result.success(setSystemBrightness(brightness));
}
+ case "openDefaultLauncherSettings" -> result.success(openDefaultLauncherSettings());
case "openWifiSettings" -> result.success(openWifiSettings());
+ case "openVpnSettings" -> result.success(openVpnSettings());
+ case "getTvInputs" -> result.success(getTvInputs());
+ case "launchTvInput" -> result.success(launchTvInput(call.arguments()));
+ case "checkNotificationListenerPermission" -> result.success(checkNotificationListenerPermission());
+ case "requestNotificationListenerPermission" -> result.success(requestNotificationListenerPermission());
+ case "getActiveNotifications" -> result.success(getActiveNotifications());
+ case "dismissNotification" -> {
+ String key = call.argument("key");
+ result.success(dismissNotification(key));
+ }
+ case "dismissAllNotifications" -> result.success(dismissAllNotifications());
+ case "checkOverlayPermission" -> result.success(checkOverlayPermission());
+ case "requestOverlayPermission" -> result.success(requestOverlayPermission());
+ case "checkAccessibilityPermission" -> result.success(isAccessibilityServiceEnabled());
+ case "requestAccessibilityPermission" -> result.success(openAccessibilitySettings());
+ case "checkWatchNextPermission" -> result.success(checkWatchNextPermission());
+ case "requestWatchNextPermission" -> {
+ if (checkWatchNextPermission()) {
+ result.success(true);
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (pendingPermissionResult != null) {
+ result.error("ALREADY_REQUESTING", "A permission request is already in progress", null);
+ } else {
+ pendingPermissionResult = result;
+ requestPermissions(new String[]{"android.permission.READ_TV_LISTINGS"}, 1002);
+ }
+ } else {
+ result.success(true);
+ }
+ }
+ case "getWatchNextPrograms" -> result.success(getWatchNextPrograms());
+ case "getWatchNextPoster" -> {
+ String posterArtUri = call.argument("posterArtUri");
+ result.success(getWatchNextPoster(posterArtUri));
+ }
+ case "launchWatchNextProgram" -> {
+ String intentUri = call.argument("intentUri");
+ result.success(launchWatchNextProgram(intentUri));
+ }
+ case "getPackageName" -> result.success(getPackageName());
+ case "playClickSound" -> {
+ getWindow().getDecorView().playSoundEffect(android.view.SoundEffectConstants.CLICK);
+ result.success(null);
+ }
default -> throw new IllegalArgumentException();
}
});
@@ -137,6 +187,36 @@ public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
new EventChannel(messenger, NETWORK_EVENT_CHANNEL).setStreamHandler(
new NetworkEventStreamHandler(this));
+
+ new EventChannel(messenger, NOTIFICATIONS_EVENT_CHANNEL).setStreamHandler(
+ new EventChannel.StreamHandler() {
+ private LauncherNotificationListenerService.NotificationListener listener;
+
+ @Override
+ public void onListen(Object arguments, EventChannel.EventSink events) {
+ listener = () -> {
+ runOnUiThread(() -> {
+ try {
+ events.success(getActiveNotifications());
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ });
+ };
+ LauncherNotificationListenerService.registerListener(listener);
+ // Send current state immediately
+ listener.onNotificationChanged();
+ }
+
+ @Override
+ public void onCancel(Object arguments) {
+ if (listener != null) {
+ LauncherNotificationListenerService.unregisterListener(listener);
+ listener = null;
+ }
+ }
+ }
+ );
}
private List> getApplications() {
@@ -650,9 +730,35 @@ private long getMonthlyDataUsage() {
private boolean checkUsageStatsPermission() {
AppOpsManager appOps = (AppOpsManager) getSystemService(Context.APP_OPS_SERVICE);
- int mode = appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS,
- android.os.Process.myUid(), getPackageName());
- return mode == AppOpsManager.MODE_ALLOWED;
+ int mode;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ mode = appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS,
+ android.os.Process.myUid(), getPackageName());
+ } else {
+ mode = appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS,
+ android.os.Process.myUid(), getPackageName());
+ }
+
+ if (mode == AppOpsManager.MODE_ALLOWED) {
+ return true;
+ }
+
+ if (mode == AppOpsManager.MODE_DEFAULT) {
+ try {
+ NetworkStatsManager networkStatsManager = (NetworkStatsManager) getSystemService(Context.NETWORK_STATS_SERVICE);
+ if (networkStatsManager != null) {
+ long now = System.currentTimeMillis();
+ networkStatsManager.querySummaryForDevice(ConnectivityManager.TYPE_WIFI, null, now - 1, now);
+ return true;
+ }
+ } catch (SecurityException e) {
+ return false;
+ } catch (Exception e) {
+ return true;
+ }
+ }
+
+ return false;
}
private void requestUsageStatsPermission() {
@@ -667,12 +773,31 @@ private boolean checkWriteSettingsPermission() {
return true;
}
- private void requestWriteSettingsPermission() {
+ private boolean requestWriteSettingsPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS);
- intent.setData(Uri.parse("package:" + getPackageName()));
- tryStartActivity(intent);
+ try {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS,
+ Uri.parse("package:" + getPackageName()));
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (tryStartActivity(intent)) {
+ return true;
+ }
+ } catch (Exception ignored) {}
+ try {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (tryStartActivity(intent)) {
+ return true;
+ }
+ } catch (Exception ignored) {}
+ try {
+ Intent intent = new Intent(Settings.ACTION_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ return tryStartActivity(intent);
+ } catch (Exception ignored) {}
+ return false;
}
+ return true;
}
private boolean setSystemBrightness(int brightness) {
@@ -696,6 +821,23 @@ private boolean setSystemBrightness(int brightness) {
return false;
}
+ private boolean openDefaultLauncherSettings() {
+ // 1. Try Android TV home settings
+ Intent homeIntent = new Intent(Settings.ACTION_HOME_SETTINGS);
+ if (tryStartActivity(homeIntent)) {
+ return true;
+ }
+
+ // 2. Try manage default apps settings
+ Intent defaultAppsIntent = new Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS);
+ if (tryStartActivity(defaultAppsIntent)) {
+ return true;
+ }
+
+ // 3. Fallback to main settings
+ return launchActivityFromAction(Settings.ACTION_SETTINGS);
+ }
+
private boolean openWifiSettings() {
// 1. Try Android Q+ WiFi panel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -721,6 +863,23 @@ private boolean openWifiSettings() {
return launchActivityFromAction(Settings.ACTION_SETTINGS);
}
+ private boolean openVpnSettings() {
+ // 1. Try standard VPN settings
+ Intent vpnIntent = new Intent(Settings.ACTION_VPN_SETTINGS);
+ if (tryStartActivity(vpnIntent)) {
+ return true;
+ }
+
+ // 2. Fallback to general wireless settings
+ Intent wirelessIntent = new Intent(Settings.ACTION_WIRELESS_SETTINGS);
+ if (tryStartActivity(wirelessIntent)) {
+ return true;
+ }
+
+ // 3. Final fallback - open main settings
+ return launchActivityFromAction(Settings.ACTION_SETTINGS);
+ }
+
private boolean openScreensaverSettings() {
// 1. Try Android TV specific screensaver settings (DaydreamActivity - from
// Aerial Views)
@@ -748,4 +907,328 @@ private boolean openScreensaverSettings() {
return launchActivityFromAction(Settings.ACTION_SETTINGS);
}
+ private List> getTvInputs() {
+ List> result = new ArrayList<>();
+ try {
+ TvInputManager manager = (TvInputManager) getSystemService(Context.TV_INPUT_SERVICE);
+ if (manager != null) {
+ List inputs = manager.getTvInputList();
+ for (TvInputInfo input : inputs) {
+ if (input.isPassthroughInput()) {
+ Map map = new HashMap<>();
+ map.put("id", input.getId());
+ CharSequence label = input.loadLabel(this);
+ map.put("label", label != null ? label.toString() : input.getId());
+ map.put("type", input.getType());
+ result.add(map);
+ }
+ }
+ }
+ } catch (Exception e) {
+ // TIF might not be supported or initialized on emulator
+ }
+ return result;
+ }
+
+ private boolean launchTvInput(String inputId) {
+ try {
+ Uri uri = TvContract.buildChannelUriForPassthroughInput(inputId);
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(uri);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ return tryStartActivity(intent);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private boolean checkNotificationListenerPermission() {
+ String packageName = getPackageName();
+ String flat = Settings.Secure.getString(getContentResolver(), "enabled_notification_listeners");
+ if (flat != null) {
+ String[] names = flat.split(":");
+ for (String name : names) {
+ ComponentName cn = ComponentName.unflattenFromString(name);
+ if (cn != null && cn.getPackageName().equals(packageName)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean requestNotificationListenerPermission() {
+ try {
+ Intent intent = new Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS");
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ try {
+ Intent intent = new Intent(Settings.ACTION_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ return false;
+ }
+ }
+ }
+
+ private List> getActiveNotifications() {
+ List> list = new ArrayList<>();
+ LauncherNotificationListenerService service = LauncherNotificationListenerService.getInstance();
+ if (service == null) {
+ return list;
+ }
+ try {
+ StatusBarNotification[] sbns = service.getActiveNotifications();
+ if (sbns != null) {
+ for (StatusBarNotification sbn : sbns) {
+ Map map = new HashMap<>();
+ map.put("key", sbn.getKey());
+ map.put("packageName", sbn.getPackageName());
+
+ android.app.Notification notification = sbn.getNotification();
+ String title = "";
+ String text = "";
+ if (notification != null && notification.extras != null) {
+ CharSequence titleChar = notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE);
+ CharSequence textChar = notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT);
+ if (titleChar != null) title = titleChar.toString();
+ if (textChar != null) text = textChar.toString();
+ }
+ map.put("title", title);
+ map.put("text", text);
+ map.put("isClearable", sbn.isClearable());
+ list.add(map);
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return list;
+ }
+
+ private boolean dismissNotification(String key) {
+ LauncherNotificationListenerService service = LauncherNotificationListenerService.getInstance();
+ if (service == null || key == null) {
+ return false;
+ }
+ try {
+ service.cancelNotification(key);
+ return true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ private boolean dismissAllNotifications() {
+ LauncherNotificationListenerService service = LauncherNotificationListenerService.getInstance();
+ if (service == null) {
+ return false;
+ }
+ try {
+ service.cancelAllNotifications();
+ return true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ private boolean checkOverlayPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ return Settings.canDrawOverlays(this);
+ }
+ return true;
+ }
+
+ private boolean requestOverlayPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.parse("package:" + getPackageName()));
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception e) {
+ try {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ private static String cursorStringOrEmpty(android.database.Cursor cursor, String column) {
+ String val = cursor.getString(cursor.getColumnIndexOrThrow(column));
+ return val != null ? val : "";
+ }
+
+ private List> getWatchNextPrograms() {
+ List> list = new ArrayList<>();
+ try {
+ String[] projection = {
+ TvContract.WatchNextPrograms._ID,
+ TvContract.WatchNextPrograms.COLUMN_PACKAGE_NAME,
+ TvContract.WatchNextPrograms.COLUMN_TITLE,
+ TvContract.WatchNextPrograms.COLUMN_SHORT_DESCRIPTION,
+ TvContract.WatchNextPrograms.COLUMN_WATCH_NEXT_TYPE,
+ TvContract.WatchNextPrograms.COLUMN_LAST_ENGAGEMENT_TIME_UTC_MILLIS,
+ TvContract.WatchNextPrograms.COLUMN_LAST_PLAYBACK_POSITION_MILLIS,
+ TvContract.WatchNextPrograms.COLUMN_DURATION_MILLIS,
+ TvContract.WatchNextPrograms.COLUMN_INTENT_URI,
+ TvContract.WatchNextPrograms.COLUMN_POSTER_ART_URI
+ };
+
+ android.database.Cursor cursor = getContentResolver().query(
+ TvContract.WatchNextPrograms.CONTENT_URI,
+ projection,
+ null,
+ null,
+ TvContract.WatchNextPrograms.COLUMN_LAST_ENGAGEMENT_TIME_UTC_MILLIS + " DESC LIMIT 20"
+ );
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ Map map = new HashMap<>();
+ map.put("id", cursor.getLong(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms._ID)));
+ map.put("packageName", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_PACKAGE_NAME));
+ map.put("title", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_TITLE));
+ map.put("description", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_SHORT_DESCRIPTION));
+ map.put("watchNextType", cursor.getInt(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms.COLUMN_WATCH_NEXT_TYPE)));
+ map.put("lastEngagementTime", cursor.getLong(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms.COLUMN_LAST_ENGAGEMENT_TIME_UTC_MILLIS)));
+ map.put("playbackPosition", cursor.getLong(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms.COLUMN_LAST_PLAYBACK_POSITION_MILLIS)));
+ map.put("duration", cursor.getLong(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms.COLUMN_DURATION_MILLIS)));
+ map.put("intentUri", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_INTENT_URI));
+ map.put("posterArtUri", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_POSTER_ART_URI));
+ list.add(map);
+ }
+ cursor.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return list;
+ }
+
+ private byte[] getWatchNextPoster(String posterArtUri) {
+ if (posterArtUri == null || posterArtUri.isEmpty()) {
+ return null;
+ }
+ try {
+ Uri uri = Uri.parse(posterArtUri);
+ try (java.io.InputStream inputStream = getContentResolver().openInputStream(uri)) {
+ if (inputStream != null) {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int bytesRead;
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, bytesRead);
+ }
+ return outputStream.toByteArray();
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ private boolean launchWatchNextProgram(String intentUri) {
+ if (intentUri == null || intentUri.isEmpty()) {
+ return false;
+ }
+ // Security: only allow safe URI schemes
+ String lower = intentUri.toLowerCase();
+ if (!lower.startsWith("intent://") && !lower.startsWith("https://") &&
+ !lower.startsWith("http://") && !lower.startsWith("content://")) {
+ return false;
+ }
+ try {
+ Intent intent = Intent.parseUri(intentUri, Intent.URI_INTENT_SCHEME);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ private boolean isAccessibilityServiceEnabled() {
+ String service = getPackageName() + "/" + LauncherAccessibilityService.class.getName();
+ int accessibilityEnabled = 0;
+ try {
+ accessibilityEnabled = Settings.Secure.getInt(
+ getContentResolver(),
+ Settings.Secure.ACCESSIBILITY_ENABLED
+ );
+ } catch (Settings.SettingNotFoundException e) {
+ e.printStackTrace();
+ }
+
+ if (accessibilityEnabled == 1) {
+ String settingValue = Settings.Secure.getString(
+ getContentResolver(),
+ Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
+ );
+ if (settingValue != null) {
+ String[] services = settingValue.split(":");
+ for (String s : services) {
+ if (s.equalsIgnoreCase(service)) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean openAccessibilitySettings() {
+ try {
+ Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (tryStartActivity(intent)) {
+ return true;
+ }
+ } catch (Exception ignored) {}
+ try {
+ Intent intent = new Intent(Settings.ACTION_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ return tryStartActivity(intent);
+ } catch (Exception ignored) {}
+ return false;
+ }
+
+ private boolean checkWatchNextPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ return checkSelfPermission("android.permission.READ_TV_LISTINGS") == PackageManager.PERMISSION_GRANTED;
+ }
+ return true;
+ }
+
+
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == 1002) {
+ if (pendingPermissionResult != null) {
+ boolean granted = grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
+ pendingPermissionResult.success(granted);
+ pendingPermissionResult = null;
+ }
+ }
+ }
}
diff --git a/android/app/src/main/java/com/leanbitlab/ltvL/NetworkUtils.java b/android/app/src/main/java/com/leanbitlab/ltvL/NetworkUtils.java
index 903e31e7..3d531cab 100644
--- a/android/app/src/main/java/com/leanbitlab/ltvL/NetworkUtils.java
+++ b/android/app/src/main/java/com/leanbitlab/ltvL/NetworkUtils.java
@@ -30,6 +30,7 @@ public class NetworkUtils
public static final String KEY_NETWORK_ACCESS = "networkAccess";
public static final String KEY_NETWORK_TYPE = "networkType";
public static final String KEY_WIRELESS_SIGNAL_LEVEL = "wirelessSignalLevel";
+ public static final String KEY_VPN_ACTIVE = "vpnActive";
public static Map getNetworkCapabilitiesInformation(Context context, NetworkCapabilities capabilities)
{
@@ -45,7 +46,10 @@ public static Map getNetworkCapabilitiesInformation(Context cont
hasInternetAccess = hasNetworkAccess;
}
- if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+ if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
+ networkType = NETWORK_TYPE_VPN;
+ }
+ else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
networkType = NETWORK_TYPE_CELLULAR;
}
else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
@@ -73,9 +77,6 @@ else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
networkType = NETWORK_TYPE_WIFI;
}
- else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
- networkType = NETWORK_TYPE_VPN;
- }
else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
networkType = NETWORK_TYPE_WIRED;
}
@@ -90,28 +91,55 @@ else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
public static Map getNetworkInformation(Context context, Network network)
{
- Map map = null;
- int wirelessNetworkSignalLevel = 0;
+ ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ boolean isVpnActive = false;
+ NetworkCapabilities physicalCaps = null;
if (network != null) {
- ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
- NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
+ NetworkCapabilities activeCaps = connectivityManager.getNetworkCapabilities(network);
+ if (activeCaps != null) {
+ if (activeCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
+ isVpnActive = true;
+ } else {
+ physicalCaps = activeCaps;
+ }
+ }
+ }
- if (capabilities != null) {
- map = getNetworkCapabilitiesInformation(context, capabilities);
+ if (isVpnActive || physicalCaps == null) {
+ for (Network net : connectivityManager.getAllNetworks()) {
+ NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(net);
+ if (caps != null) {
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
+ isVpnActive = true;
+ } else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ||
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+ if (physicalCaps == null) {
+ physicalCaps = caps;
+ }
+ }
+ }
+ }
+ }
- if (Objects.equals(map.get(KEY_NETWORK_TYPE), NETWORK_TYPE_WIFI)) {
- try {
- WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
- if (wifiManager != null) {
- WifiInfo wifiInfo = wifiManager.getConnectionInfo();
- if (wifiInfo != null) {
- wirelessNetworkSignalLevel = getWifiSignalLevel(wifiInfo);
- }
+ Map map = null;
+ int wirelessNetworkSignalLevel = 0;
+
+ if (physicalCaps != null) {
+ map = getNetworkCapabilitiesInformation(context, physicalCaps);
+
+ if (Objects.equals(map.get(KEY_NETWORK_TYPE), NETWORK_TYPE_WIFI)) {
+ try {
+ WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ if (wifiManager != null) {
+ WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+ if (wifiInfo != null) {
+ wirelessNetworkSignalLevel = getWifiSignalLevel(wifiInfo);
}
- } catch (Exception e) {
- e.printStackTrace();
}
+ } catch (Exception e) {
+ e.printStackTrace();
}
}
}
@@ -127,6 +155,7 @@ public static Map getNetworkInformation(Context context, Network
}
map.put(KEY_WIRELESS_SIGNAL_LEVEL, wirelessNetworkSignalLevel);
+ map.put(KEY_VPN_ACTIVE, isVpnActive);
return map;
}
@@ -135,6 +164,16 @@ public static Map getNetworkInformation(Context context, @Nullab
{
boolean hasNetworkAccess = false;
int networkType = NETWORK_TYPE_UNKNOWN, networkInfoType, wirelessSignalLevel = 0;
+ boolean isVpnActive = false;
+
+ ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (connectivityManager != null) {
+ // noinspection deprecation
+ NetworkInfo vpnInfo = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_VPN);
+ if (vpnInfo != null && vpnInfo.isConnected()) {
+ isVpnActive = true;
+ }
+ }
if (networkInfo != null) {
hasNetworkAccess = networkInfo.isConnected();
@@ -143,7 +182,7 @@ public static Map getNetworkInformation(Context context, @Nullab
if (networkInfoType == ConnectivityManager.TYPE_MOBILE) {
networkType = NETWORK_TYPE_CELLULAR;
}
- if (networkInfoType == ConnectivityManager.TYPE_WIFI) {
+ else if (networkInfoType == ConnectivityManager.TYPE_WIFI) {
WifiManager wifiManager = (WifiManager) context
.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
@@ -160,7 +199,32 @@ public static Map getNetworkInformation(Context context, @Nullab
}
}
else if (networkInfoType == ConnectivityManager.TYPE_VPN) {
- networkType = NETWORK_TYPE_VPN;
+ isVpnActive = true;
+ // noinspection deprecation
+ NetworkInfo wifiInfo = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
+ // noinspection deprecation
+ NetworkInfo mobileInfo = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE);
+ // noinspection deprecation
+ NetworkInfo ethernetInfo = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_ETHERNET);
+
+ if (wifiInfo != null && wifiInfo.isConnected()) {
+ networkType = NETWORK_TYPE_WIFI;
+ WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ try {
+ if (wifiManager != null) {
+ WifiInfo wInfo = wifiManager.getConnectionInfo();
+ if (wInfo != null) {
+ wirelessSignalLevel = getWifiSignalLevel(wInfo);
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ } else if (ethernetInfo != null && ethernetInfo.isConnected()) {
+ networkType = NETWORK_TYPE_WIRED;
+ } else if (mobileInfo != null && mobileInfo.isConnected()) {
+ networkType = NETWORK_TYPE_CELLULAR;
+ }
}
else if (networkInfoType == ConnectivityManager.TYPE_ETHERNET) {
networkType = NETWORK_TYPE_WIRED;
@@ -172,6 +236,7 @@ else if (networkInfoType == ConnectivityManager.TYPE_ETHERNET) {
mapOut.put(KEY_NETWORK_ACCESS, hasNetworkAccess);
mapOut.put(KEY_INTERNET_ACCESS, hasNetworkAccess);
mapOut.put(KEY_WIRELESS_SIGNAL_LEVEL, wirelessSignalLevel);
+ mapOut.put(KEY_VPN_ACTIVE, isVpnActive);
return mapOut;
}
diff --git a/android/app/src/main/java/me/efesser/flauncher/LauncherAccessibilityService.java b/android/app/src/main/java/me/efesser/flauncher/LauncherAccessibilityService.java
new file mode 100644
index 00000000..13d6239d
--- /dev/null
+++ b/android/app/src/main/java/me/efesser/flauncher/LauncherAccessibilityService.java
@@ -0,0 +1,30 @@
+package me.efesser.flauncher;
+
+import android.accessibilityservice.AccessibilityService;
+import android.content.Intent;
+import android.view.KeyEvent;
+
+public class LauncherAccessibilityService extends AccessibilityService {
+ @Override
+ public void onAccessibilityEvent(android.view.accessibility.AccessibilityEvent event) {
+ // Not used
+ }
+
+ @Override
+ public void onInterrupt() {
+ // Not used
+ }
+
+ @Override
+ protected boolean onKeyEvent(KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_HOME) {
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ Intent intent = new Intent(this, MainActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ startActivity(intent);
+ }
+ return true; // Consume Home key event
+ }
+ return super.onKeyEvent(event);
+ }
+}
diff --git a/android/app/src/main/java/me/efesser/flauncher/LauncherNotificationListenerService.java b/android/app/src/main/java/me/efesser/flauncher/LauncherNotificationListenerService.java
new file mode 100644
index 00000000..b14faa7f
--- /dev/null
+++ b/android/app/src/main/java/me/efesser/flauncher/LauncherNotificationListenerService.java
@@ -0,0 +1,254 @@
+package me.efesser.flauncher;
+
+import android.app.Notification;
+import android.content.pm.PackageManager;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Gravity;
+import android.view.WindowManager;
+import android.service.notification.NotificationListenerService;
+import android.service.notification.StatusBarNotification;
+import java.util.ArrayList;
+import java.util.List;
+
+public class LauncherNotificationListenerService extends NotificationListenerService {
+ public interface NotificationListener {
+ void onNotificationChanged();
+ }
+
+ private static final List listeners = new ArrayList<>();
+ private static LauncherNotificationListenerService instance = null;
+
+ public static void registerListener(NotificationListener listener) {
+ synchronized (listeners) {
+ listeners.add(listener);
+ }
+ }
+
+ public static void unregisterListener(NotificationListener listener) {
+ synchronized (listeners) {
+ listeners.remove(listener);
+ }
+ }
+
+ public static LauncherNotificationListenerService getInstance() {
+ return instance;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ instance = this;
+ notifyListeners();
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (instance == this) {
+ instance = null;
+ }
+ notifyListeners();
+ }
+
+ @Override
+ public void onNotificationPosted(StatusBarNotification sbn) {
+ notifyListeners();
+ showNotificationPopup(sbn);
+ }
+
+ @Override
+ public void onNotificationRemoved(StatusBarNotification sbn) {
+ notifyListeners();
+ }
+
+ private void notifyListeners() {
+ synchronized (listeners) {
+ for (NotificationListener listener : listeners) {
+ listener.onNotificationChanged();
+ }
+ }
+ }
+
+ private boolean canShowPopup() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ return android.provider.Settings.canDrawOverlays(this);
+ }
+ return true;
+ }
+
+ private int dpToPx(int dp) {
+ return (int) (dp * getResources().getDisplayMetrics().density);
+ }
+
+ private void showNotificationPopup(StatusBarNotification sbn) {
+ if (sbn == null || sbn.isOngoing()) {
+ return;
+ }
+
+ android.content.SharedPreferences prefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE);
+ boolean enabled = prefs.getBoolean("flutter.system_notifications_popup", false);
+ if (!enabled) {
+ return;
+ }
+
+ if (!canShowPopup()) {
+ return;
+ }
+
+ android.app.Notification notification = sbn.getNotification();
+ if (notification == null) return;
+
+ // Filter out service and media transport notifications
+ String category = notification.category;
+ if (android.app.Notification.CATEGORY_SERVICE.equals(category) ||
+ android.app.Notification.CATEGORY_TRANSPORT.equals(category)) {
+ return;
+ }
+
+ Bundle extras = notification.extras;
+ if (extras == null) return;
+
+ // Filter out media sessions (playback)
+ if (extras.containsKey(android.app.Notification.EXTRA_MEDIA_SESSION)) {
+ return;
+ }
+
+ CharSequence titleChar = extras.getCharSequence(Notification.EXTRA_TITLE);
+ CharSequence textChar = extras.getCharSequence(Notification.EXTRA_TEXT);
+
+ final String title = titleChar != null ? titleChar.toString().trim() : "";
+ final String text = textChar != null ? textChar.toString().trim() : "";
+
+ if (title.isEmpty() && text.isEmpty()) {
+ return;
+ }
+
+ final String packageName = sbn.getPackageName();
+ final PackageManager pm = getPackageManager();
+ String appLabel = packageName;
+ Drawable appIcon = null;
+ try {
+ android.content.pm.ApplicationInfo appInfo = pm.getApplicationInfo(packageName, 0);
+ appLabel = pm.getApplicationLabel(appInfo).toString().trim();
+ appIcon = pm.getApplicationIcon(appInfo);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ // Filter out generic app running notifications where title/text are just the app label
+ if (title.equalsIgnoreCase(appLabel) && (text.isEmpty() || text.equalsIgnoreCase(appLabel))) {
+ return;
+ }
+ if (title.equalsIgnoreCase(packageName) && (text.isEmpty() || text.equalsIgnoreCase(packageName))) {
+ return;
+ }
+
+ final String finalAppLabel = appLabel;
+ final Drawable finalAppIcon = appIcon;
+
+ final WindowManager windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
+ if (windowManager == null) return;
+
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ android.widget.LinearLayout container = new android.widget.LinearLayout(LauncherNotificationListenerService.this);
+ container.setOrientation(android.widget.LinearLayout.HORIZONTAL);
+ container.setGravity(Gravity.CENTER_VERTICAL);
+ int pad = dpToPx(16);
+ container.setPadding(pad, pad, pad, pad);
+
+ android.graphics.drawable.GradientDrawable background = new android.graphics.drawable.GradientDrawable();
+ background.setColor(android.graphics.Color.parseColor("#E01E1E1E"));
+ background.setCornerRadius(dpToPx(12));
+ background.setStroke(dpToPx(1), android.graphics.Color.parseColor("#44FFFFFF"));
+ container.setBackground(background);
+
+ android.widget.ImageView iconView = new android.widget.ImageView(LauncherNotificationListenerService.this);
+ if (finalAppIcon != null) {
+ iconView.setImageDrawable(finalAppIcon);
+ }
+ android.widget.LinearLayout.LayoutParams iconParams = new android.widget.LinearLayout.LayoutParams(dpToPx(40), dpToPx(40));
+ iconParams.rightMargin = dpToPx(12);
+ iconView.setLayoutParams(iconParams);
+ container.addView(iconView);
+
+ android.widget.LinearLayout textContainer = new android.widget.LinearLayout(LauncherNotificationListenerService.this);
+ textContainer.setOrientation(android.widget.LinearLayout.VERTICAL);
+ android.widget.LinearLayout.LayoutParams textContainerParams = new android.widget.LinearLayout.LayoutParams(
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ textContainer.setLayoutParams(textContainerParams);
+
+ android.widget.TextView titleView = new android.widget.TextView(LauncherNotificationListenerService.this);
+ String headerText = finalAppLabel;
+ if (title != null && !title.isEmpty()) {
+ headerText += " • " + title;
+ }
+ titleView.setText(headerText);
+ titleView.setTextColor(android.graphics.Color.WHITE);
+ titleView.setTextSize(14);
+ titleView.setTypeface(android.graphics.Typeface.DEFAULT_BOLD);
+ textContainer.addView(titleView);
+
+ if (text != null && !text.isEmpty()) {
+ android.widget.TextView bodyView = new android.widget.TextView(LauncherNotificationListenerService.this);
+ bodyView.setText(text);
+ bodyView.setTextColor(android.graphics.Color.parseColor("#CCCCCC"));
+ bodyView.setTextSize(13);
+ android.widget.LinearLayout.LayoutParams bodyParams = new android.widget.LinearLayout.LayoutParams(
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
+ android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ bodyParams.topMargin = dpToPx(4);
+ bodyView.setLayoutParams(bodyParams);
+ textContainer.addView(bodyView);
+ }
+
+ container.addView(textContainer);
+
+ int layoutFlag;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ layoutFlag = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
+ } else {
+ layoutFlag = WindowManager.LayoutParams.TYPE_PHONE;
+ }
+
+ WindowManager.LayoutParams params = new WindowManager.LayoutParams(
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ layoutFlag,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
+ PixelFormat.TRANSLUCENT
+ );
+
+ params.gravity = Gravity.TOP | Gravity.END;
+ params.x = dpToPx(24);
+ params.y = dpToPx(24);
+
+ windowManager.addView(container, params);
+
+ new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ windowManager.removeView(container);
+ } catch (Exception e) {
+ }
+ }
+ }, 4000);
+
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ });
+ }
+}
diff --git a/android/app/src/main/java/me/efesser/flauncher/MainActivity.java b/android/app/src/main/java/me/efesser/flauncher/MainActivity.java
index 591b781d..78a00ee6 100644
--- a/android/app/src/main/java/me/efesser/flauncher/MainActivity.java
+++ b/android/app/src/main/java/me/efesser/flauncher/MainActivity.java
@@ -31,6 +31,9 @@
import android.os.Build;
import android.provider.Settings;
import android.util.Pair;
+import android.media.tv.TvInputManager;
+import android.media.tv.TvInputInfo;
+import android.media.tv.TvContract;
import androidx.annotation.NonNull;
@@ -63,10 +66,15 @@
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import android.service.notification.StatusBarNotification;
+import android.content.ComponentName;
+
public class MainActivity extends FlutterActivity {
private final String METHOD_CHANNEL = "me.efesser.flauncher/method";
private final String APPS_EVENT_CHANNEL = "me.efesser.flauncher/event_apps";
private final String NETWORK_EVENT_CHANNEL = "me.efesser.flauncher/event_network";
+ private final String NOTIFICATIONS_EVENT_CHANNEL = "me.efesser.flauncher/event_notifications";
+ private MethodChannel.Result pendingPermissionResult;
@Override
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
@@ -118,7 +126,58 @@ public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
requestUsageStatsPermission();
result.success(null);
}
+ case "checkWriteSettingsPermission" -> result.success(checkWriteSettingsPermission());
+ case "requestWriteSettingsPermission" -> result.success(requestWriteSettingsPermission());
+ case "setSystemBrightness" -> {
+ int brightness = call.argument("brightness");
+ result.success(setSystemBrightness(brightness));
+ }
+ case "openDefaultLauncherSettings" -> result.success(openDefaultLauncherSettings());
case "openWifiSettings" -> result.success(openWifiSettings());
+ case "openVpnSettings" -> result.success(openVpnSettings());
+ case "getTvInputs" -> result.success(getTvInputs());
+ case "launchTvInput" -> result.success(launchTvInput(call.arguments()));
+ case "checkNotificationListenerPermission" -> result.success(checkNotificationListenerPermission());
+ case "requestNotificationListenerPermission" -> result.success(requestNotificationListenerPermission());
+ case "getActiveNotifications" -> result.success(getActiveNotifications());
+ case "dismissNotification" -> {
+ String key = call.argument("key");
+ result.success(dismissNotification(key));
+ }
+ case "dismissAllNotifications" -> result.success(dismissAllNotifications());
+ case "checkOverlayPermission" -> result.success(checkOverlayPermission());
+ case "requestOverlayPermission" -> result.success(requestOverlayPermission());
+ case "checkAccessibilityPermission" -> result.success(isAccessibilityServiceEnabled());
+ case "requestAccessibilityPermission" -> result.success(openAccessibilitySettings());
+ case "checkWatchNextPermission" -> result.success(checkWatchNextPermission());
+ case "requestWatchNextPermission" -> {
+ if (checkWatchNextPermission()) {
+ result.success(true);
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (pendingPermissionResult != null) {
+ result.error("ALREADY_REQUESTING", "A permission request is already in progress", null);
+ } else {
+ pendingPermissionResult = result;
+ requestPermissions(new String[]{"android.permission.READ_TV_LISTINGS"}, 1002);
+ }
+ } else {
+ result.success(true);
+ }
+ }
+ case "getWatchNextPrograms" -> result.success(getWatchNextPrograms());
+ case "getWatchNextPoster" -> {
+ String posterArtUri = call.argument("posterArtUri");
+ result.success(getWatchNextPoster(posterArtUri));
+ }
+ case "launchWatchNextProgram" -> {
+ String intentUri = call.argument("intentUri");
+ result.success(launchWatchNextProgram(intentUri));
+ }
+ case "getPackageName" -> result.success(getPackageName());
+ case "playClickSound" -> {
+ getWindow().getDecorView().playSoundEffect(android.view.SoundEffectConstants.CLICK);
+ result.success(null);
+ }
default -> throw new IllegalArgumentException();
}
});
@@ -128,6 +187,36 @@ public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
new EventChannel(messenger, NETWORK_EVENT_CHANNEL).setStreamHandler(
new NetworkEventStreamHandler(this));
+
+ new EventChannel(messenger, NOTIFICATIONS_EVENT_CHANNEL).setStreamHandler(
+ new EventChannel.StreamHandler() {
+ private LauncherNotificationListenerService.NotificationListener listener;
+
+ @Override
+ public void onListen(Object arguments, EventChannel.EventSink events) {
+ listener = () -> {
+ runOnUiThread(() -> {
+ try {
+ events.success(getActiveNotifications());
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ });
+ };
+ LauncherNotificationListenerService.registerListener(listener);
+ // Send current state immediately
+ listener.onNotificationChanged();
+ }
+
+ @Override
+ public void onCancel(Object arguments) {
+ if (listener != null) {
+ LauncherNotificationListenerService.unregisterListener(listener);
+ listener = null;
+ }
+ }
+ }
+ );
}
private List> getApplications() {
@@ -456,7 +545,7 @@ private long getDailyDataUsage() {
try {
NetworkStats.Bucket wifiBucket = networkStatsManager.querySummaryForDevice(
ConnectivityManager.TYPE_WIFI,
- "",
+ null,
startTime,
endTime);
totalBytes += wifiBucket.getRxBytes() + wifiBucket.getTxBytes();
@@ -467,7 +556,7 @@ private long getDailyDataUsage() {
try {
NetworkStats.Bucket mobileBucket = networkStatsManager.querySummaryForDevice(
ConnectivityManager.TYPE_MOBILE,
- "",
+ null,
startTime,
endTime);
totalBytes += mobileBucket.getRxBytes() + mobileBucket.getTxBytes();
@@ -478,7 +567,7 @@ private long getDailyDataUsage() {
try {
NetworkStats.Bucket ethernetBucket = networkStatsManager.querySummaryForDevice(
ConnectivityManager.TYPE_ETHERNET,
- "",
+ null,
startTime,
endTime);
totalBytes += ethernetBucket.getRxBytes() + ethernetBucket.getTxBytes();
@@ -511,7 +600,7 @@ private long getWeeklyDataUsage() {
try {
NetworkStats.Bucket wifiBucket = networkStatsManager.querySummaryForDevice(
ConnectivityManager.TYPE_WIFI,
- "",
+ null,
startTime,
endTime);
totalBytes += wifiBucket.getRxBytes() + wifiBucket.getTxBytes();
@@ -522,7 +611,7 @@ private long getWeeklyDataUsage() {
try {
NetworkStats.Bucket mobileBucket = networkStatsManager.querySummaryForDevice(
ConnectivityManager.TYPE_MOBILE,
- "",
+ null,
startTime,
endTime);
totalBytes += mobileBucket.getRxBytes() + mobileBucket.getTxBytes();
@@ -533,7 +622,7 @@ private long getWeeklyDataUsage() {
try {
NetworkStats.Bucket ethernetBucket = networkStatsManager.querySummaryForDevice(
ConnectivityManager.TYPE_ETHERNET,
- "",
+ null,
startTime,
endTime);
totalBytes += ethernetBucket.getRxBytes() + ethernetBucket.getTxBytes();
@@ -566,7 +655,7 @@ private long getMonthlyDataUsage() {
try {
NetworkStats.Bucket wifiBucket = networkStatsManager.querySummaryForDevice(
ConnectivityManager.TYPE_WIFI,
- "",
+ null,
startTime,
endTime);
totalBytes += wifiBucket.getRxBytes() + wifiBucket.getTxBytes();
@@ -577,7 +666,7 @@ private long getMonthlyDataUsage() {
try {
NetworkStats.Bucket mobileBucket = networkStatsManager.querySummaryForDevice(
ConnectivityManager.TYPE_MOBILE,
- "",
+ null,
startTime,
endTime);
totalBytes += mobileBucket.getRxBytes() + mobileBucket.getTxBytes();
@@ -588,7 +677,7 @@ private long getMonthlyDataUsage() {
try {
NetworkStats.Bucket ethernetBucket = networkStatsManager.querySummaryForDevice(
ConnectivityManager.TYPE_ETHERNET,
- "",
+ null,
startTime,
endTime);
totalBytes += ethernetBucket.getRxBytes() + ethernetBucket.getTxBytes();
@@ -601,9 +690,35 @@ private long getMonthlyDataUsage() {
private boolean checkUsageStatsPermission() {
AppOpsManager appOps = (AppOpsManager) getSystemService(Context.APP_OPS_SERVICE);
- int mode = appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS,
- android.os.Process.myUid(), getPackageName());
- return mode == AppOpsManager.MODE_ALLOWED;
+ int mode;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ mode = appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS,
+ android.os.Process.myUid(), getPackageName());
+ } else {
+ mode = appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS,
+ android.os.Process.myUid(), getPackageName());
+ }
+
+ if (mode == AppOpsManager.MODE_ALLOWED) {
+ return true;
+ }
+
+ if (mode == AppOpsManager.MODE_DEFAULT) {
+ try {
+ NetworkStatsManager networkStatsManager = (NetworkStatsManager) getSystemService(Context.NETWORK_STATS_SERVICE);
+ if (networkStatsManager != null) {
+ long now = System.currentTimeMillis();
+ networkStatsManager.querySummaryForDevice(ConnectivityManager.TYPE_WIFI, null, now - 1, now);
+ return true;
+ }
+ } catch (SecurityException e) {
+ return false;
+ } catch (Exception e) {
+ return true;
+ }
+ }
+
+ return false;
}
private void requestUsageStatsPermission() {
@@ -611,6 +726,78 @@ private void requestUsageStatsPermission() {
tryStartActivity(intent);
}
+ private boolean checkWriteSettingsPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ return Settings.System.canWrite(this);
+ }
+ return true;
+ }
+
+ private boolean requestWriteSettingsPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS,
+ Uri.parse("package:" + getPackageName()));
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (tryStartActivity(intent)) {
+ return true;
+ }
+ } catch (Exception ignored) {}
+ try {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (tryStartActivity(intent)) {
+ return true;
+ }
+ } catch (Exception ignored) {}
+ try {
+ Intent intent = new Intent(Settings.ACTION_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ return tryStartActivity(intent);
+ } catch (Exception ignored) {}
+ return false;
+ }
+ return true;
+ }
+
+ private boolean setSystemBrightness(int brightness) {
+ if (checkWriteSettingsPermission()) {
+ try {
+ android.content.ContentResolver resolver = getContentResolver();
+ // 1. Standard Android brightness
+ Settings.System.putInt(resolver, Settings.System.SCREEN_BRIGHTNESS_MODE, Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL);
+ Settings.System.putInt(resolver, Settings.System.SCREEN_BRIGHTNESS, brightness);
+
+ // 2. Try common TV "Backlight" keys (Vendor specific)
+ Settings.System.putInt(resolver, "backlight", brightness);
+ Settings.System.putInt(resolver, "backlight_level", brightness);
+
+ return true;
+ } catch (Exception e) {
+ // Ignore errors on specific keys as they may not exist
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean openDefaultLauncherSettings() {
+ // 1. Try Android TV home settings
+ Intent homeIntent = new Intent(Settings.ACTION_HOME_SETTINGS);
+ if (tryStartActivity(homeIntent)) {
+ return true;
+ }
+
+ // 2. Try manage default apps settings
+ Intent defaultAppsIntent = new Intent(Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS);
+ if (tryStartActivity(defaultAppsIntent)) {
+ return true;
+ }
+
+ // 3. Fallback to main settings
+ return launchActivityFromAction(Settings.ACTION_SETTINGS);
+ }
+
private boolean openWifiSettings() {
// 1. Try Android Q+ WiFi panel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -636,6 +823,23 @@ private boolean openWifiSettings() {
return launchActivityFromAction(Settings.ACTION_SETTINGS);
}
+ private boolean openVpnSettings() {
+ // 1. Try standard VPN settings
+ Intent vpnIntent = new Intent(Settings.ACTION_VPN_SETTINGS);
+ if (tryStartActivity(vpnIntent)) {
+ return true;
+ }
+
+ // 2. Fallback to general wireless settings
+ Intent wirelessIntent = new Intent(Settings.ACTION_WIRELESS_SETTINGS);
+ if (tryStartActivity(wirelessIntent)) {
+ return true;
+ }
+
+ // 3. Final fallback - open main settings
+ return launchActivityFromAction(Settings.ACTION_SETTINGS);
+ }
+
private boolean openScreensaverSettings() {
// 1. Try Android TV specific screensaver settings (DaydreamActivity - from
// Aerial Views)
@@ -663,4 +867,328 @@ private boolean openScreensaverSettings() {
return launchActivityFromAction(Settings.ACTION_SETTINGS);
}
+ private List> getTvInputs() {
+ List> result = new ArrayList<>();
+ try {
+ TvInputManager manager = (TvInputManager) getSystemService(Context.TV_INPUT_SERVICE);
+ if (manager != null) {
+ List inputs = manager.getTvInputList();
+ for (TvInputInfo input : inputs) {
+ if (input.isPassthroughInput()) {
+ Map map = new HashMap<>();
+ map.put("id", input.getId());
+ CharSequence label = input.loadLabel(this);
+ map.put("label", label != null ? label.toString() : input.getId());
+ map.put("type", input.getType());
+ result.add(map);
+ }
+ }
+ }
+ } catch (Exception e) {
+ // TIF might not be supported or initialized on emulator
+ }
+ return result;
+ }
+
+ private boolean launchTvInput(String inputId) {
+ try {
+ Uri uri = TvContract.buildChannelUriForPassthroughInput(inputId);
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(uri);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ return tryStartActivity(intent);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private boolean checkNotificationListenerPermission() {
+ String packageName = getPackageName();
+ String flat = Settings.Secure.getString(getContentResolver(), "enabled_notification_listeners");
+ if (flat != null) {
+ String[] names = flat.split(":");
+ for (String name : names) {
+ ComponentName cn = ComponentName.unflattenFromString(name);
+ if (cn != null && cn.getPackageName().equals(packageName)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean requestNotificationListenerPermission() {
+ try {
+ Intent intent = new Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS");
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ try {
+ Intent intent = new Intent(Settings.ACTION_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ return false;
+ }
+ }
+ }
+
+ private List> getActiveNotifications() {
+ List> list = new ArrayList<>();
+ LauncherNotificationListenerService service = LauncherNotificationListenerService.getInstance();
+ if (service == null) {
+ return list;
+ }
+ try {
+ StatusBarNotification[] sbns = service.getActiveNotifications();
+ if (sbns != null) {
+ for (StatusBarNotification sbn : sbns) {
+ Map map = new HashMap<>();
+ map.put("key", sbn.getKey());
+ map.put("packageName", sbn.getPackageName());
+
+ android.app.Notification notification = sbn.getNotification();
+ String title = "";
+ String text = "";
+ if (notification != null && notification.extras != null) {
+ CharSequence titleChar = notification.extras.getCharSequence(android.app.Notification.EXTRA_TITLE);
+ CharSequence textChar = notification.extras.getCharSequence(android.app.Notification.EXTRA_TEXT);
+ if (titleChar != null) title = titleChar.toString();
+ if (textChar != null) text = textChar.toString();
+ }
+ map.put("title", title);
+ map.put("text", text);
+ map.put("isClearable", sbn.isClearable());
+ list.add(map);
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return list;
+ }
+
+ private boolean dismissNotification(String key) {
+ LauncherNotificationListenerService service = LauncherNotificationListenerService.getInstance();
+ if (service == null || key == null) {
+ return false;
+ }
+ try {
+ service.cancelNotification(key);
+ return true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ private boolean dismissAllNotifications() {
+ LauncherNotificationListenerService service = LauncherNotificationListenerService.getInstance();
+ if (service == null) {
+ return false;
+ }
+ try {
+ service.cancelAllNotifications();
+ return true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ private boolean checkOverlayPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ return Settings.canDrawOverlays(this);
+ }
+ return true;
+ }
+
+ private boolean requestOverlayPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ try {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.parse("package:" + getPackageName()));
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception e) {
+ try {
+ Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ private static String cursorStringOrEmpty(android.database.Cursor cursor, String column) {
+ String val = cursor.getString(cursor.getColumnIndexOrThrow(column));
+ return val != null ? val : "";
+ }
+
+ private List> getWatchNextPrograms() {
+ List> list = new ArrayList<>();
+ try {
+ String[] projection = {
+ TvContract.WatchNextPrograms._ID,
+ TvContract.WatchNextPrograms.COLUMN_PACKAGE_NAME,
+ TvContract.WatchNextPrograms.COLUMN_TITLE,
+ TvContract.WatchNextPrograms.COLUMN_SHORT_DESCRIPTION,
+ TvContract.WatchNextPrograms.COLUMN_WATCH_NEXT_TYPE,
+ TvContract.WatchNextPrograms.COLUMN_LAST_ENGAGEMENT_TIME_UTC_MILLIS,
+ TvContract.WatchNextPrograms.COLUMN_LAST_PLAYBACK_POSITION_MILLIS,
+ TvContract.WatchNextPrograms.COLUMN_DURATION_MILLIS,
+ TvContract.WatchNextPrograms.COLUMN_INTENT_URI,
+ TvContract.WatchNextPrograms.COLUMN_POSTER_ART_URI
+ };
+
+ android.database.Cursor cursor = getContentResolver().query(
+ TvContract.WatchNextPrograms.CONTENT_URI,
+ projection,
+ null,
+ null,
+ TvContract.WatchNextPrograms.COLUMN_LAST_ENGAGEMENT_TIME_UTC_MILLIS + " DESC LIMIT 20"
+ );
+
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ Map map = new HashMap<>();
+ map.put("id", cursor.getLong(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms._ID)));
+ map.put("packageName", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_PACKAGE_NAME));
+ map.put("title", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_TITLE));
+ map.put("description", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_SHORT_DESCRIPTION));
+ map.put("watchNextType", cursor.getInt(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms.COLUMN_WATCH_NEXT_TYPE)));
+ map.put("lastEngagementTime", cursor.getLong(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms.COLUMN_LAST_ENGAGEMENT_TIME_UTC_MILLIS)));
+ map.put("playbackPosition", cursor.getLong(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms.COLUMN_LAST_PLAYBACK_POSITION_MILLIS)));
+ map.put("duration", cursor.getLong(cursor.getColumnIndexOrThrow(TvContract.WatchNextPrograms.COLUMN_DURATION_MILLIS)));
+ map.put("intentUri", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_INTENT_URI));
+ map.put("posterArtUri", cursorStringOrEmpty(cursor, TvContract.WatchNextPrograms.COLUMN_POSTER_ART_URI));
+ list.add(map);
+ }
+ cursor.close();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return list;
+ }
+
+ private byte[] getWatchNextPoster(String posterArtUri) {
+ if (posterArtUri == null || posterArtUri.isEmpty()) {
+ return null;
+ }
+ try {
+ Uri uri = Uri.parse(posterArtUri);
+ try (java.io.InputStream inputStream = getContentResolver().openInputStream(uri)) {
+ if (inputStream != null) {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int bytesRead;
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, bytesRead);
+ }
+ return outputStream.toByteArray();
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return null;
+ }
+
+ private boolean launchWatchNextProgram(String intentUri) {
+ if (intentUri == null || intentUri.isEmpty()) {
+ return false;
+ }
+ // Security: only allow safe URI schemes
+ String lower = intentUri.toLowerCase();
+ if (!lower.startsWith("intent://") && !lower.startsWith("https://") &&
+ !lower.startsWith("http://") && !lower.startsWith("content://")) {
+ return false;
+ }
+ try {
+ Intent intent = Intent.parseUri(intentUri, Intent.URI_INTENT_SCHEME);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(intent);
+ return true;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ private boolean isAccessibilityServiceEnabled() {
+ String service = getPackageName() + "/" + LauncherAccessibilityService.class.getName();
+ int accessibilityEnabled = 0;
+ try {
+ accessibilityEnabled = Settings.Secure.getInt(
+ getContentResolver(),
+ Settings.Secure.ACCESSIBILITY_ENABLED
+ );
+ } catch (Settings.SettingNotFoundException e) {
+ e.printStackTrace();
+ }
+
+ if (accessibilityEnabled == 1) {
+ String settingValue = Settings.Secure.getString(
+ getContentResolver(),
+ Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
+ );
+ if (settingValue != null) {
+ String[] services = settingValue.split(":");
+ for (String s : services) {
+ if (s.equalsIgnoreCase(service)) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean openAccessibilitySettings() {
+ try {
+ Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ if (tryStartActivity(intent)) {
+ return true;
+ }
+ } catch (Exception ignored) {}
+ try {
+ Intent intent = new Intent(Settings.ACTION_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ return tryStartActivity(intent);
+ } catch (Exception ignored) {}
+ return false;
+ }
+
+ private boolean checkWatchNextPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ return checkSelfPermission("android.permission.READ_TV_LISTINGS") == PackageManager.PERMISSION_GRANTED;
+ }
+ return true;
+ }
+
+
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == 1002) {
+ if (pendingPermissionResult != null) {
+ boolean granted = grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
+ pendingPermissionResult.success(granted);
+ pendingPermissionResult = null;
+ }
+ }
+ }
}
diff --git a/android/app/src/main/java/me/efesser/flauncher/NetworkUtils.java b/android/app/src/main/java/me/efesser/flauncher/NetworkUtils.java
index e0dfc1b3..85f940f9 100644
--- a/android/app/src/main/java/me/efesser/flauncher/NetworkUtils.java
+++ b/android/app/src/main/java/me/efesser/flauncher/NetworkUtils.java
@@ -30,6 +30,7 @@ public class NetworkUtils
public static final String KEY_NETWORK_ACCESS = "networkAccess";
public static final String KEY_NETWORK_TYPE = "networkType";
public static final String KEY_WIRELESS_SIGNAL_LEVEL = "wirelessSignalLevel";
+ public static final String KEY_VPN_ACTIVE = "vpnActive";
public static Map getNetworkCapabilitiesInformation(Context context, NetworkCapabilities capabilities)
{
@@ -45,7 +46,10 @@ public static Map getNetworkCapabilitiesInformation(Context cont
hasInternetAccess = hasNetworkAccess;
}
- if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+ if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
+ networkType = NETWORK_TYPE_VPN;
+ }
+ else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
networkType = NETWORK_TYPE_CELLULAR;
}
else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
@@ -73,9 +77,6 @@ else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
networkType = NETWORK_TYPE_WIFI;
}
- else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
- networkType = NETWORK_TYPE_VPN;
- }
else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
networkType = NETWORK_TYPE_WIRED;
}
@@ -90,28 +91,55 @@ else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
public static Map getNetworkInformation(Context context, Network network)
{
- Map map = null;
- int wirelessNetworkSignalLevel = 0;
+ ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ boolean isVpnActive = false;
+ NetworkCapabilities physicalCaps = null;
if (network != null) {
- ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
- NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
+ NetworkCapabilities activeCaps = connectivityManager.getNetworkCapabilities(network);
+ if (activeCaps != null) {
+ if (activeCaps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
+ isVpnActive = true;
+ } else {
+ physicalCaps = activeCaps;
+ }
+ }
+ }
+
+ if (isVpnActive || physicalCaps == null) {
+ for (Network net : connectivityManager.getAllNetworks()) {
+ NetworkCapabilities caps = connectivityManager.getNetworkCapabilities(net);
+ if (caps != null) {
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
+ isVpnActive = true;
+ } else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ||
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+ if (physicalCaps == null) {
+ physicalCaps = caps;
+ }
+ }
+ }
+ }
+ }
- if (capabilities != null) {
- map = getNetworkCapabilitiesInformation(context, capabilities);
+ Map map = null;
+ int wirelessNetworkSignalLevel = 0;
- if (Objects.equals(map.get(KEY_NETWORK_TYPE), NETWORK_TYPE_WIFI)) {
- try {
- WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
- if (wifiManager != null) {
- WifiInfo wifiInfo = wifiManager.getConnectionInfo();
- if (wifiInfo != null) {
- wirelessNetworkSignalLevel = getWifiSignalLevel(wifiInfo);
- }
+ if (physicalCaps != null) {
+ map = getNetworkCapabilitiesInformation(context, physicalCaps);
+
+ if (Objects.equals(map.get(KEY_NETWORK_TYPE), NETWORK_TYPE_WIFI)) {
+ try {
+ WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ if (wifiManager != null) {
+ WifiInfo wifiInfo = wifiManager.getConnectionInfo();
+ if (wifiInfo != null) {
+ wirelessNetworkSignalLevel = getWifiSignalLevel(wifiInfo);
}
- } catch (Exception e) {
- e.printStackTrace();
}
+ } catch (Exception e) {
+ e.printStackTrace();
}
}
}
@@ -127,6 +155,7 @@ public static Map getNetworkInformation(Context context, Network
}
map.put(KEY_WIRELESS_SIGNAL_LEVEL, wirelessNetworkSignalLevel);
+ map.put(KEY_VPN_ACTIVE, isVpnActive);
return map;
}
@@ -135,6 +164,16 @@ public static Map getNetworkInformation(Context context, @Nullab
{
boolean hasNetworkAccess = false;
int networkType = NETWORK_TYPE_UNKNOWN, networkInfoType, wirelessSignalLevel = 0;
+ boolean isVpnActive = false;
+
+ ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (connectivityManager != null) {
+ // noinspection deprecation
+ NetworkInfo vpnInfo = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_VPN);
+ if (vpnInfo != null && vpnInfo.isConnected()) {
+ isVpnActive = true;
+ }
+ }
if (networkInfo != null) {
hasNetworkAccess = networkInfo.isConnected();
@@ -143,7 +182,7 @@ public static Map getNetworkInformation(Context context, @Nullab
if (networkInfoType == ConnectivityManager.TYPE_MOBILE) {
networkType = NETWORK_TYPE_CELLULAR;
}
- if (networkInfoType == ConnectivityManager.TYPE_WIFI) {
+ else if (networkInfoType == ConnectivityManager.TYPE_WIFI) {
WifiManager wifiManager = (WifiManager) context
.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
@@ -160,19 +199,45 @@ public static Map getNetworkInformation(Context context, @Nullab
}
}
else if (networkInfoType == ConnectivityManager.TYPE_VPN) {
- networkType = NETWORK_TYPE_VPN;
+ isVpnActive = true;
+ // noinspection deprecation
+ NetworkInfo wifiInfo = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_WIFI);
+ // noinspection deprecation
+ NetworkInfo mobileInfo = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_MOBILE);
+ // noinspection deprecation
+ NetworkInfo ethernetInfo = connectivityManager.getNetworkInfo(ConnectivityManager.TYPE_ETHERNET);
+
+ if (wifiInfo != null && wifiInfo.isConnected()) {
+ networkType = NETWORK_TYPE_WIFI;
+ WifiManager wifiManager = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ try {
+ if (wifiManager != null) {
+ WifiInfo wInfo = wifiManager.getConnectionInfo();
+ if (wInfo != null) {
+ wirelessSignalLevel = getWifiSignalLevel(wInfo);
+ }
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ } else if (ethernetInfo != null && ethernetInfo.isConnected()) {
+ networkType = NETWORK_TYPE_WIRED;
+ } else if (mobileInfo != null && mobileInfo.isConnected()) {
+ networkType = NETWORK_TYPE_CELLULAR;
+ }
}
else if (networkInfoType == ConnectivityManager.TYPE_ETHERNET) {
networkType = NETWORK_TYPE_WIRED;
}
}
- Map map = new java.util.HashMap<>();
- map.put(KEY_NETWORK_TYPE, networkType);
- map.put(KEY_NETWORK_ACCESS, hasNetworkAccess);
- map.put(KEY_INTERNET_ACCESS, hasNetworkAccess);
- map.put(KEY_WIRELESS_SIGNAL_LEVEL, wirelessSignalLevel);
- return map;
+ Map mapOut = new java.util.HashMap<>();
+ mapOut.put(KEY_NETWORK_TYPE, networkType);
+ mapOut.put(KEY_NETWORK_ACCESS, hasNetworkAccess);
+ mapOut.put(KEY_INTERNET_ACCESS, hasNetworkAccess);
+ mapOut.put(KEY_WIRELESS_SIGNAL_LEVEL, wirelessSignalLevel);
+ mapOut.put(KEY_VPN_ACTIVE, isVpnActive);
+ return mapOut;
}
public static int getWifiSignalLevel(WifiInfo wifiInfo)
diff --git a/android/app/src/main/res/xml/accessibility_service_config.xml b/android/app/src/main/res/xml/accessibility_service_config.xml
new file mode 100644
index 00000000..53e1997d
--- /dev/null
+++ b/android/app/src/main/res/xml/accessibility_service_config.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/android/gradle.properties b/android/gradle.properties
index 58e8f376..ed2180d1 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -24,3 +24,4 @@ org.gradle.daemon=true
android.useAndroidX=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
+android.enableR8.fullMode=true
diff --git a/assets/icon.png b/assets/icon.png
index 62120671..39ca53c8 100644
Binary files a/assets/icon.png and b/assets/icon.png differ
diff --git a/commitlint.config.js b/commitlint.config.js
deleted file mode 100644
index 8f69fc09..00000000
--- a/commitlint.config.js
+++ /dev/null
@@ -1,23 +0,0 @@
-module.exports = {
- extends: ['@commitlint/config-conventional'],
- rules: {
- 'type-enum': [
- 2,
- 'always',
- [
- 'build',
- 'ci',
- 'chore',
- 'docs',
- 'feat',
- 'fix',
- 'perf',
- 'refactor',
- 'revert',
- 'style',
- 'test',
- 'improvement'
- ]
- ]
- }
-};
diff --git a/drift_schemas/drift_schema_v8.json b/drift_schemas/drift_schema_v8.json
new file mode 100644
index 00000000..70b4a2c1
--- /dev/null
+++ b/drift_schemas/drift_schema_v8.json
@@ -0,0 +1 @@
+{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.1.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"apps","was_declared_in_moor":false,"columns":[{"name":"package_name","getter_name":"packageName","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"version","getter_name":"version","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hidden","getter_name":"hidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"hidden\" IN (0, 1))","default_dart":"const Constant(false)","default_client_dart":null,"dsl_features":[]},{"name":"last_launched_at","getter_name":"lastLaunchedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["package_name"]}},{"id":1,"references":[],"type":"table","data":{"name":"categories","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"sort","getter_name":"sort","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(0)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(CategorySort.values)","dart_type_name":"CategorySort"}},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(0)","default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(CategoryType.values)","dart_type_name":"CategoryType"}},{"name":"row_height","getter_name":"rowHeight","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(110)","default_client_dart":null,"dsl_features":[]},{"name":"columns_count","getter_name":"columnsCount","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const Constant(6)","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[1,0],"type":"table","data":{"name":"apps_categories","was_declared_in_moor":false,"columns":[{"name":"category_id","getter_name":"categoryId","moor_type":"int","nullable":false,"customConstraints":"REFERENCES categories(id) ON DELETE CASCADE","default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"app_package_name","getter_name":"appPackageName","moor_type":"string","nullable":false,"customConstraints":"REFERENCES apps(package_name) ON DELETE CASCADE","default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["category_id","app_package_name"]}},{"id":3,"references":[],"type":"table","data":{"name":"launcher_spacers","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"height","getter_name":"height","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}}]}
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/8103.txt b/fastlane/metadata/android/en-US/changelogs/8103.txt
new file mode 100644
index 00000000..48260624
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/8103.txt
@@ -0,0 +1,16 @@
+- Added system Accessibility Settings page with support for viewing default status and opening the system home picker.
+- Performance: Capped background icon pre-cache concurrency via pool to prevent Platform channel congestion.
+- Performance: Optimized SettingsService with local caching to reduce synchronous disk I/O overhead.
+- Performance: Fixed N+1 Drift database query during app categorization, and optimized new app category lookups to O(1).
+- Bug Fix: Capped focused card scaling factor to prevent premium banners from being cropped by sibling cards.
+- Bug Fix: Fixed ethernet data usage reporting, and fixed wallpaper not immediately updating after settings change.
+- Security: Disabled insecure Android auto-backups.
+- Testing: Substantially increased unit and widget test coverage across models, widgets, and services.
+- Added system-wide floating notification overlay popups (requires Android overlay permission).
+- Added "System-wide Popup Alert" settings switch toggle under Notification Access.
+- Added premium "Get it on Downloader" badge to the README supporting permanent numeric code 7259827.
+- Add support for 'Continue Watching' (Watch Next) row on home screen.
+- Add status bar settings toggle to show/hide the notification bell icon.
+- Improve system-wide popup alert permission status detection on app resume.
+- Add visible focus outline selector for notification dismissal button.
+- Add a focusable 'Clear All' button at the bottom of the notifications side panel.
diff --git a/fastlane/metadata/android/en-US/changelogs/8104.txt b/fastlane/metadata/android/en-US/changelogs/8104.txt
new file mode 100644
index 00000000..bf111716
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/8104.txt
@@ -0,0 +1,5 @@
+- Fix key click sound on D-pad key press navigation.
+- Fix local cache out-of-sync issue when reordering apps in custom sections.
+- Fix active VPN connection detection to correctly display the VPN key icon instead of Wi-Fi.
+- Make tapping the VPN status bar icon open native VPN settings instead of Wi-Fi settings.
+- Consolidate GitHub Actions badge generation workflows to prevent build push conflicts.
diff --git a/fastlane/metadata/android/en-US/changelogs/8105.txt b/fastlane/metadata/android/en-US/changelogs/8105.txt
new file mode 100644
index 00000000..b04e6edb
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/8105.txt
@@ -0,0 +1,7 @@
+- Improve Backup & Restore performance with batch inserts.
+- Fix bug where unhiding apps required a launcher restart.
+- Fix race condition where rapid reordering of apps caused duplicate icons or stuck states.
+- Support using the Back button (and gamepad B button) to exit reorder mode.
+- Auto-hide empty notification bell icon (optional setting).
+- Force notification badge count text color to white.
+- Fix bugs and visually polish Continue Watching.
diff --git a/fastlane/metadata/android/en-US/changelogs/8106.txt b/fastlane/metadata/android/en-US/changelogs/8106.txt
new file mode 100644
index 00000000..3e6b2917
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/8106.txt
@@ -0,0 +1,7 @@
+- Render both physical connection (WiFi/Ethernet/Cellular) and VPN status icons simultaneously.
+- Fix PACKAGE_USAGE_STATS permission check fallback (ADB/system settings default AppOps status).
+- Fix reorder mode race condition and duplicate move-icon glitches during rapid app reordering.
+- Fix back button handling in reorder mode to prevent home screensaver from launching on exit.
+- Fix D-pad navigation scrolling for Sections settings list with more than 6 sections.
+- Add Spanish translations for "Continue Watching" panel settings.
+- Skip Gradle release signing configuration if keystore file is absent.
diff --git a/l10n.yaml b/l10n.yaml
index 4e6692e5..d5dd1e46 100644
--- a/l10n.yaml
+++ b/l10n.yaml
@@ -1,3 +1,4 @@
arb-dir: lib/l10n
template-arb-file: app_en.arb
-output-localization-file: app_localizations.dart
\ No newline at end of file
+output-localization-file: app_localizations.dart
+synthetic-package: false
\ No newline at end of file
diff --git a/lib/actions.dart b/lib/actions.dart
index 19e4d6f5..dc81c4ca 100644
--- a/lib/actions.dart
+++ b/lib/actions.dart
@@ -16,6 +16,7 @@
* along with this program. If not, see .
*/
+import 'package:flauncher/flauncher_channel.dart';
import 'package:flauncher/providers/apps_service.dart';
import 'package:flauncher/providers/launcher_state.dart';
import 'package:flauncher/providers/settings_service.dart';
@@ -34,6 +35,7 @@ class SoundFeedbackDirectionalFocusAction extends DirectionalFocusAction {
SettingsService settingsService = context.read();
if (settingsService.appKeyClickEnabled) {
+ FLauncherChannel().playClickSound();
Feedback.forTap(context);
} else {
silentForTap(context);
diff --git a/lib/database.dart b/lib/database.dart
index 4fa2b40b..7ff72dab 100644
--- a/lib/database.dart
+++ b/lib/database.dart
@@ -53,13 +53,13 @@ class Categories extends Table
TextColumn get name => text()();
- IntColumn get sort => intEnum().withDefault(Constant(Category.Sort.index))();
+ IntColumn get sort => intEnum().withDefault(const Constant(0))();
- IntColumn get type => intEnum().withDefault(Constant(Category.Type.index))();
+ IntColumn get type => intEnum().withDefault(const Constant(0))();
- IntColumn get rowHeight => integer().withDefault(const Constant(Category.RowHeight))();
+ IntColumn get rowHeight => integer().withDefault(const Constant(110))();
- IntColumn get columnsCount => integer().withDefault(const Constant(Category.ColumnsCount))();
+ IntColumn get columnsCount => integer().withDefault(const Constant(6))();
IntColumn get order => integer()();
}
@@ -105,13 +105,19 @@ class FLauncherDatabase extends _$FLauncherDatabase
await migrator.createAll();
},
onUpgrade: (migrator, from, to) async {
- if (from <= 1) {
- await migrator.alterTable(TableMigration(apps, newColumns: [apps.hidden]));
+ if (from <= 1 && to >= 2) {
+ await migrator.alterTable(TableMigration(
+ apps,
+ newColumns: [
+ apps.hidden,
+ if (to >= 8) apps.lastLaunchedAt,
+ ],
+ ));
}
- if (from <= 2 && from != 1) {
+ if (from <= 2 && from != 1 && to >= 3) {
await migrator.addColumn(apps, apps.hidden);
}
- if (from <= 3) {
+ if (from <= 3 && to >= 4) {
await migrator.addColumn(categories, categories.sort);
await migrator.addColumn(categories, categories.type);
await migrator.addColumn(categories, categories.rowHeight);
@@ -119,15 +125,26 @@ class FLauncherDatabase extends _$FLauncherDatabase
await (update(categories)..where((tbl) => tbl.name.equals("Applications")))
.write(const CategoriesCompanion(type: Value(CategoryType.grid)));
}
- if (from < 6) {
- await customStatement("ALTER TABLE apps DROP COLUMN banner;");
- await customStatement("ALTER TABLE apps DROP COLUMN icon;");
+ if (from < 6 && to >= 6) {
+ final columns = await customSelect("PRAGMA table_info(apps);").get();
+ final hasBanner = columns.any((row) => row.read('name') == 'banner');
+ final hasIcon = columns.any((row) => row.read('name') == 'icon');
+ if (hasBanner) {
+ await customStatement("ALTER TABLE apps DROP COLUMN banner;");
+ }
+ if (hasIcon) {
+ await customStatement("ALTER TABLE apps DROP COLUMN icon;");
+ }
}
- if (from < 7) {
+ if (from < 7 && to >= 7) {
await migrator.createTable(launcherSpacers);
- await migrator.dropColumn(apps, "sideloaded");
+ final columns = await customSelect("PRAGMA table_info(apps);").get();
+ final hasSideloaded = columns.any((row) => row.read('name') == 'sideloaded');
+ if (hasSideloaded) {
+ await migrator.dropColumn(apps, "sideloaded");
+ }
}
- if (from < 8) {
+ if (from < 8 && from != 1 && to >= 8) {
await migrator.addColumn(apps, apps.lastLaunchedAt);
}
},
diff --git a/lib/database.drift.dart b/lib/database.drift.dart
index 1dbc0bde..80cd00ff 100644
--- a/lib/database.drift.dart
+++ b/lib/database.drift.dart
@@ -231,7 +231,7 @@ class $CategoriesTable extends Categories
GeneratedColumn('sort', aliasedName, false,
type: DriftSqlType.int,
requiredDuringInsert: false,
- defaultValue: Constant(Category.Sort.index))
+ defaultValue: const Constant(0))
.withConverter($CategoriesTable.$convertersort);
static const VerificationMeta _typeMeta = const VerificationMeta('type');
@override
@@ -239,7 +239,7 @@ class $CategoriesTable extends Categories
GeneratedColumn('type', aliasedName, false,
type: DriftSqlType.int,
requiredDuringInsert: false,
- defaultValue: Constant(Category.Type.index))
+ defaultValue: const Constant(0))
.withConverter($CategoriesTable.$convertertype);
static const VerificationMeta _rowHeightMeta =
const VerificationMeta('rowHeight');
@@ -248,7 +248,7 @@ class $CategoriesTable extends Categories
'row_height', aliasedName, false,
type: DriftSqlType.int,
requiredDuringInsert: false,
- defaultValue: const Constant(Category.RowHeight));
+ defaultValue: const Constant(110));
static const VerificationMeta _columnsCountMeta =
const VerificationMeta('columnsCount');
@override
@@ -256,7 +256,7 @@ class $CategoriesTable extends Categories
'columns_count', aliasedName, false,
type: DriftSqlType.int,
requiredDuringInsert: false,
- defaultValue: const Constant(Category.ColumnsCount));
+ defaultValue: const Constant(6));
static const VerificationMeta _orderMeta = const VerificationMeta('order');
@override
late final GeneratedColumn order = GeneratedColumn(
diff --git a/lib/flauncher.dart b/lib/flauncher.dart
index ed38470c..765c9451 100644
--- a/lib/flauncher.dart
+++ b/lib/flauncher.dart
@@ -28,6 +28,9 @@ import 'package:flauncher/widgets/launcher_alternative_view.dart';
import 'package:flauncher/widgets/focus_aware_app_bar.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
+import 'package:flauncher/widgets/continue_watching_row.dart';
+import 'package:flauncher/providers/watch_next_service.dart';
+import 'package:flauncher/providers/settings_service.dart';
import 'package:flauncher/l10n/app_localizations.dart';
import 'models/category.dart';
@@ -74,7 +77,15 @@ class _FLauncherState extends State {
child: Consumer(
builder: (context, appsService, _) {
if (appsService.initialized) {
- return SingleChildScrollView(child: _sections(appsService.launcherSections));
+ return SingleChildScrollView(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const ContinueWatchingRow(),
+ _sections(appsService.launcherSections),
+ ],
+ ),
+ );
}
else {
return _emptyState(context);
@@ -90,8 +101,12 @@ class _FLauncherState extends State {
);
Widget _sections(List sections) {
+ final settingsService = Provider.of(context, listen: false);
+ final watchNextService = Provider.of(context, listen: false);
+ final bool continueWatchingActive = settingsService.showContinueWatching && watchNextService.programs.isNotEmpty;
+
List children = [];
- bool firstCategoryFound = false;
+ bool firstCategoryFound = continueWatchingActive;
for (var section in sections) {
final Key sectionKey = Key(section.id.toString());
diff --git a/lib/flauncher_app.dart b/lib/flauncher_app.dart
index 520c7cc4..8d75ff3d 100644
--- a/lib/flauncher_app.dart
+++ b/lib/flauncher_app.dart
@@ -36,19 +36,6 @@ class FLauncherApp extends StatelessWidget
BackIntent()
]);
- static const MaterialColor _swatch = MaterialColor(0xFF011526, {
- 50: Color(0xFF36A0FA),
- 100: Color(0xFF067BDE),
- 200: Color(0xFF045CA7),
- 300: Color(0xFF033662),
- 400: Color(0xFF022544),
- 500: Color(0xFF011526),
- 600: Color(0xFF000508),
- 700: Color(0xFF000000),
- 800: Color(0xFF000000),
- 900: Color(0xFF000000),
- });
-
const FLauncherApp();
@override
diff --git a/lib/flauncher_channel.dart b/lib/flauncher_channel.dart
index 31eaa13a..e2a2cfbe 100644
--- a/lib/flauncher_channel.dart
+++ b/lib/flauncher_channel.dart
@@ -24,6 +24,7 @@ class FLauncherChannel {
static const _methodChannel = MethodChannel('me.efesser.flauncher/method');
static const _appsEventChannel = EventChannel('me.efesser.flauncher/event_apps');
static const _networkEventChannel = EventChannel('me.efesser.flauncher/event_network');
+ static const _notificationsEventChannel = EventChannel('me.efesser.flauncher/event_notifications');
Future>> getApplications() async {
List>? applications = await _methodChannel.invokeListMethod("getApplications");
@@ -96,6 +97,25 @@ class FLauncherChannel {
Future openWifiSettings() async =>
await _methodChannel.invokeMethod("openWifiSettings");
+ Future openVpnSettings() async {
+ try {
+ await _methodChannel.invokeMethod("openVpnSettings");
+ } catch (_) {
+ // Ignore error in non-Android or test environments
+ }
+ }
+
+ Future openDefaultLauncherSettings() async =>
+ await _methodChannel.invokeMethod("openDefaultLauncherSettings");
+
+ Future playClickSound() async {
+ try {
+ await _methodChannel.invokeMethod("playClickSound");
+ } catch (_) {
+ // Ignore error in non-Android or test environments
+ }
+ }
+
Future startAmbientMode() async => await _methodChannel.invokeMethod("startAmbientMode");
void addAppsChangedListener(void Function(Map) listener) =>
@@ -109,4 +129,124 @@ class FLauncherChannel {
Map eventMap = event;
listener(eventMap.cast());
});
+
+ Future>> getTvInputs() async {
+ try {
+ final List inputs = await _methodChannel.invokeMethod("getTvInputs");
+ return inputs.cast>();
+ } catch (_) {
+ return [];
+ }
+ }
+
+ Future launchTvInput(String inputId) async {
+ try {
+ final bool success = await _methodChannel.invokeMethod("launchTvInput", inputId);
+ return success;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ Future checkNotificationListenerPermission() async =>
+ await _methodChannel.invokeMethod("checkNotificationListenerPermission");
+
+ Future requestNotificationListenerPermission() async {
+ final bool? success = await _methodChannel.invokeMethod("requestNotificationListenerPermission");
+ return success ?? false;
+ }
+
+ Future checkOverlayPermission() async =>
+ await _methodChannel.invokeMethod("checkOverlayPermission");
+
+ Future requestOverlayPermission() async {
+ final bool? success = await _methodChannel.invokeMethod("requestOverlayPermission");
+ return success ?? false;
+ }
+
+ Future checkAccessibilityPermission() async =>
+ await _methodChannel.invokeMethod("checkAccessibilityPermission");
+
+ Future requestAccessibilityPermission() async {
+ final bool? success = await _methodChannel.invokeMethod("requestAccessibilityPermission");
+ return success ?? false;
+ }
+
+ Future>> getActiveNotifications() async {
+ try {
+ final List list = await _methodChannel.invokeMethod("getActiveNotifications");
+ return list.cast>();
+ } catch (_) {
+ return [];
+ }
+ }
+
+ StreamSubscription addNotificationsChangedListener(void Function(List>) listener) =>
+ _notificationsEventChannel.receiveBroadcastStream().listen((event) {
+ final List eventList = event;
+ listener(eventList.cast>());
+ });
+
+ Future dismissNotification(String key) async {
+ try {
+ final bool success = await _methodChannel.invokeMethod("dismissNotification", {"key": key});
+ return success;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ Future dismissAllNotifications() async {
+ try {
+ final bool success = await _methodChannel.invokeMethod("dismissAllNotifications");
+ return success;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ Future>> getWatchNextPrograms() async {
+ try {
+ final List? list = await _methodChannel.invokeMethod("getWatchNextPrograms");
+ return list?.cast>() ?? [];
+ } catch (_) {
+ return [];
+ }
+ }
+
+ Future getWatchNextPoster(String posterArtUri) async {
+ try {
+ final Uint8List? bytes = await _methodChannel.invokeMethod("getWatchNextPoster", {"posterArtUri": posterArtUri});
+ return bytes;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ Future launchWatchNextProgram(String intentUri) async {
+ try {
+ final bool success = await _methodChannel.invokeMethod("launchWatchNextProgram", {"intentUri": intentUri});
+ return success;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ Future checkWatchNextPermission() async {
+ try {
+ final bool? allowed = await _methodChannel.invokeMethod("checkWatchNextPermission");
+ return allowed ?? false;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ Future requestWatchNextPermission() async {
+ try {
+ final bool? allowed = await _methodChannel.invokeMethod("requestWatchNextPermission");
+ return allowed ?? false;
+ } catch (_) {
+ return false;
+ }
+ }
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index a7317b4f..522fc330 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -106,5 +106,60 @@
"withEllipsisAddTo": "Add to...",
"timeBasedWallpaper": "Time based wallpaper",
"pickDayWallpaper": "Pick day wallpaper",
- "pickNightWallpaper": "Pick night wallpaper"
+ "pickNightWallpaper": "Pick night wallpaper",
+ "accessibility": "Accessibility",
+ "defaultLauncherIsDefault": "LTvLauncher is the default launcher",
+ "defaultLauncherNotDefault": "LTvLauncher is not the default launcher",
+ "setAsDefaultLauncher": "Set as default launcher",
+ "defaultLauncherDescription": "When set as the default launcher, the Home button will always return to LTvLauncher. The TV will also boot directly into LTvLauncher.",
+ "inputs": "Inputs",
+ "inputSources": "Input Sources",
+ "backupAndRestore": "Backup & Restore",
+ "exportBackup": "Export Backup",
+ "importBackup": "Import Backup",
+ "exportSuccess": "Backup exported successfully to {path}",
+ "@exportSuccess": {
+ "placeholders": {
+ "path": {
+ "type": "String"
+ }
+ }
+ },
+ "importSuccess": "Backup imported successfully",
+ "importConfirm": "Are you sure you want to import the backup? This will overwrite your current settings and layout.",
+ "importError": "Failed to import backup: {error}",
+ "@importError": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "exportError": "Failed to export backup: {error}",
+ "@exportError": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "shareBackup": "Share Backup",
+ "shareBackupDescription": "Share backup with other devices on local network",
+ "stopSharing": "Stop Sharing",
+ "localNetworkSharingActive": "Local network sharing is active!",
+ "localNetworkSharingInstructions": "Connect another device to the same Wi-Fi network and open the following URL in a web browser:",
+ "localNetworkSharingDetails": "Here you can download your TV settings/layout or upload a backup file back to this TV.",
+ "failedToStartServer": "Failed to start sharing server: {error}",
+ "@failedToStartServer": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "notificationBell": "Notification Bell",
+ "autoHideNotificationBell": "Auto-hide Notification Bell",
+ "continueWatching": "Continue Watching",
+ "showContinueWatchingOnHome": "Show Continue Watching on Home",
+ "permissionDeniedContinueWatching": "Permission required to show Continue Watching"
}
\ No newline at end of file
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index 065302f1..45dcceda 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -1,5 +1,5 @@
{
- "aboutFlauncher": "Acerca de FLauncher",
+ "aboutFlauncher": "Acerca de LTvLauncher",
"addCategory": "Agregar categoría",
"addSection": "Agregar sección",
"alphabetical": "Alfabético",
@@ -45,6 +45,7 @@
}
},
"gradient": "Gradiente",
+ "favoriteApps": "Apps Favoritas",
"grid": "Cuadrícula",
"height": "Altura",
"hide": "Ocultar",
@@ -63,7 +64,7 @@
"open": "Abrir",
"orSelectFormatSpecifiers": "O seleccione especificadores de formato",
"picture": "Imagen",
- "removeFrom": "Remover de {name}",
+ "removeFrom": "Eliminar de {name}",
"@removeFrom": {
"placeholders": {
"name": { "type": "String" }
@@ -71,8 +72,8 @@
},
"renameCategory": "Renombrar categoría",
"reorder": "Reordenar",
- "row": "Row",
- "rowHeight": "Row height",
+ "row": "Fila",
+ "rowHeight": "Altura de fila",
"save": "Guardar",
"spacer": "Espaciador",
"spacerMaxHeightRequirement": "Debe ser mayor a cero y menor o igual a 500",
@@ -102,5 +103,63 @@
"typeInTheHourFormat": "Escriba el formato de hora",
"uninstall": "Desinstalar",
"wallpaper": "Fondo de pantalla",
- "withEllipsisAddTo": "Añadir a..."
-}
\ No newline at end of file
+ "withEllipsisAddTo": "Añadir a...",
+ "timeBasedWallpaper": "Fondo de pantalla según hora",
+ "pickDayWallpaper": "Elegir fondo de pantalla diurno",
+ "pickNightWallpaper": "Elegir fondo de pantalla nocturno",
+ "accessibility": "Accesibilidad",
+ "defaultLauncherIsDefault": "LTvLauncher es el lanzador predeterminado",
+ "defaultLauncherNotDefault": "LTvLauncher no es el lanzador predeterminado",
+ "setAsDefaultLauncher": "Establecer como lanzador predeterminado",
+ "defaultLauncherDescription": "Cuando se establece como lanzador predeterminado, el botón de inicio siempre regresará a LTvLauncher. El TV también iniciará directamente en LTvLauncher.",
+ "inputs": "Entradas",
+ "inputSources": "Fuentes de Entrada",
+ "backupAndRestore": "Copia de seguridad y restauración",
+ "exportBackup": "Exportar copia de seguridad",
+ "importBackup": "Importar copia de seguridad",
+ "exportSuccess": "Copia de seguridad exportada con éxito a {path}",
+ "@exportSuccess": {
+ "placeholders": {
+ "path": {
+ "type": "String"
+ }
+ }
+ },
+ "importSuccess": "Copia de seguridad importada con éxito",
+ "importConfirm": "¿Está seguro de que desea importar la copia de seguridad? Esto sobrescribirá su configuración y diseño actuales.",
+ "importError": "Error al importar la copia de seguridad: {error}",
+ "@importError": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "exportError": "Error al exportar la copia de seguridad: {error}",
+ "@exportError": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "shareBackup": "Compartir copia",
+ "shareBackupDescription": "Comparta la copia de seguridad con otros dispositivos en la red local",
+ "stopSharing": "Detener uso compartido",
+ "localNetworkSharingActive": "¡El uso compartido en red local está activo!",
+ "localNetworkSharingInstructions": "Conecte otro dispositivo a la misma red Wi-Fi y abra la siguiente URL en un navegador web:",
+ "localNetworkSharingDetails": "Aquí puede descargar la configuración/diseño de su TV o subir un archivo de copia de seguridad a esta TV.",
+ "failedToStartServer": "Error al iniciar el servidor de uso compartido: {error}",
+ "@failedToStartServer": {
+ "placeholders": {
+ "error": {
+ "type": "String"
+ }
+ }
+ },
+ "notificationBell": "Campana de notificaciones",
+ "autoHideNotificationBell": "Ocultar campana de notificaciones automáticamente",
+ "continueWatching": "Continuar viendo",
+ "showContinueWatchingOnHome": "Mostrar Continuar viendo en Inicio",
+ "permissionDeniedContinueWatching": "Se requiere permiso para mostrar Continuar viendo"
+}
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index bd1a1d9a..14e606ad 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -62,8 +62,7 @@ import 'app_localizations_es.dart';
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
- AppLocalizations(String locale)
- : localeName = intl.Intl.canonicalizedLocale(locale.toString());
+ AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
@@ -71,8 +70,7 @@ abstract class AppLocalizations {
return Localizations.of(context, AppLocalizations);
}
- static const LocalizationsDelegate delegate =
- _AppLocalizationsDelegate();
+ static const LocalizationsDelegate delegate = _AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
@@ -84,8 +82,7 @@ abstract class AppLocalizations {
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
- static const List> localizationsDelegates =
- >[
+ static const List> localizationsDelegates = >[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
@@ -547,10 +544,171 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Pick night wallpaper'**
String get pickNightWallpaper;
+
+ /// No description provided for @accessibility.
+ ///
+ /// In en, this message translates to:
+ /// **'Accessibility'**
+ String get accessibility;
+
+ /// No description provided for @defaultLauncherIsDefault.
+ ///
+ /// In en, this message translates to:
+ /// **'LTvLauncher is the default launcher'**
+ String get defaultLauncherIsDefault;
+
+ /// No description provided for @defaultLauncherNotDefault.
+ ///
+ /// In en, this message translates to:
+ /// **'LTvLauncher is not the default launcher'**
+ String get defaultLauncherNotDefault;
+
+ /// No description provided for @setAsDefaultLauncher.
+ ///
+ /// In en, this message translates to:
+ /// **'Set as default launcher'**
+ String get setAsDefaultLauncher;
+
+ /// No description provided for @defaultLauncherDescription.
+ ///
+ /// In en, this message translates to:
+ /// **'When set as the default launcher, the Home button will always return to LTvLauncher. The TV will also boot directly into LTvLauncher.'**
+ String get defaultLauncherDescription;
+
+ /// No description provided for @inputs.
+ ///
+ /// In en, this message translates to:
+ /// **'Inputs'**
+ String get inputs;
+
+ /// No description provided for @inputSources.
+ ///
+ /// In en, this message translates to:
+ /// **'Input Sources'**
+ String get inputSources;
+
+ /// No description provided for @backupAndRestore.
+ ///
+ /// In en, this message translates to:
+ /// **'Backup & Restore'**
+ String get backupAndRestore;
+
+ /// No description provided for @exportBackup.
+ ///
+ /// In en, this message translates to:
+ /// **'Export Backup'**
+ String get exportBackup;
+
+ /// No description provided for @importBackup.
+ ///
+ /// In en, this message translates to:
+ /// **'Import Backup'**
+ String get importBackup;
+
+ /// No description provided for @exportSuccess.
+ ///
+ /// In en, this message translates to:
+ /// **'Backup exported successfully to {path}'**
+ String exportSuccess(String path);
+
+ /// No description provided for @importSuccess.
+ ///
+ /// In en, this message translates to:
+ /// **'Backup imported successfully'**
+ String get importSuccess;
+
+ /// No description provided for @importConfirm.
+ ///
+ /// In en, this message translates to:
+ /// **'Are you sure you want to import the backup? This will overwrite your current settings and layout.'**
+ String get importConfirm;
+
+ /// No description provided for @importError.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to import backup: {error}'**
+ String importError(String error);
+
+ /// No description provided for @exportError.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to export backup: {error}'**
+ String exportError(String error);
+
+ /// No description provided for @shareBackup.
+ ///
+ /// In en, this message translates to:
+ /// **'Share Backup'**
+ String get shareBackup;
+
+ /// No description provided for @shareBackupDescription.
+ ///
+ /// In en, this message translates to:
+ /// **'Share backup with other devices on local network'**
+ String get shareBackupDescription;
+
+ /// No description provided for @stopSharing.
+ ///
+ /// In en, this message translates to:
+ /// **'Stop Sharing'**
+ String get stopSharing;
+
+ /// No description provided for @localNetworkSharingActive.
+ ///
+ /// In en, this message translates to:
+ /// **'Local network sharing is active!'**
+ String get localNetworkSharingActive;
+
+ /// No description provided for @localNetworkSharingInstructions.
+ ///
+ /// In en, this message translates to:
+ /// **'Connect another device to the same Wi-Fi network and open the following URL in a web browser:'**
+ String get localNetworkSharingInstructions;
+
+ /// No description provided for @localNetworkSharingDetails.
+ ///
+ /// In en, this message translates to:
+ /// **'Here you can download your TV settings/layout or upload a backup file back to this TV.'**
+ String get localNetworkSharingDetails;
+
+ /// No description provided for @failedToStartServer.
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to start sharing server: {error}'**
+ String failedToStartServer(String error);
+
+ /// No description provided for @notificationBell.
+ ///
+ /// In en, this message translates to:
+ /// **'Notification Bell'**
+ String get notificationBell;
+
+ /// No description provided for @autoHideNotificationBell.
+ ///
+ /// In en, this message translates to:
+ /// **'Auto-hide Notification Bell'**
+ String get autoHideNotificationBell;
+
+ /// No description provided for @continueWatching.
+ ///
+ /// In en, this message translates to:
+ /// **'Continue Watching'**
+ String get continueWatching;
+
+ /// No description provided for @showContinueWatchingOnHome.
+ ///
+ /// In en, this message translates to:
+ /// **'Show Continue Watching on Home'**
+ String get showContinueWatchingOnHome;
+
+ /// No description provided for @permissionDeniedContinueWatching.
+ ///
+ /// In en, this message translates to:
+ /// **'Permission required to show Continue Watching'**
+ String get permissionDeniedContinueWatching;
}
-class _AppLocalizationsDelegate
- extends LocalizationsDelegate {
+class _AppLocalizationsDelegate extends LocalizationsDelegate {
const _AppLocalizationsDelegate();
@override
@@ -559,25 +717,25 @@ class _AppLocalizationsDelegate
}
@override
- bool isSupported(Locale locale) =>
- ['en', 'es'].contains(locale.languageCode);
+ bool isSupported(Locale locale) => ['en', 'es'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
+
+
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
- case 'en':
- return AppLocalizationsEn();
- case 'es':
- return AppLocalizationsEs();
+ case 'en': return AppLocalizationsEn();
+ case 'es': return AppLocalizationsEs();
}
throw FlutterError(
- 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
- 'an issue with the localizations generation tool. Please file an issue '
- 'on GitHub with a reproducible sample app and the gen-l10n configuration '
- 'that was used.');
+ 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
+ 'an issue with the localizations generation tool. Please file an issue '
+ 'on GitHub with a reproducible sample app and the gen-l10n configuration '
+ 'that was used.'
+ );
}
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index df1e1211..2c4907b6 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -1,5 +1,3 @@
-// ignore: unused_import
-import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -66,8 +64,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get dialogOptionBackButtonActionShowClock => 'Show clock';
@override
- String get dialogTextNoFileExplorer =>
- 'Please install a file explorer in order to pick a picture.';
+ String get dialogTextNoFileExplorer => 'Please install a file explorer in order to pick a picture.';
@override
String get dialogTitleBackButtonAction => 'Choose the back button action';
@@ -171,8 +168,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get spacer => 'Spacer';
@override
- String get spacerMaxHeightRequirement =>
- 'Must be greater than 0 and less than or equal to 500';
+ String get spacerMaxHeightRequirement => 'Must be greater than 0 and less than or equal to 500';
@override
String get statusBar => 'Status bar';
@@ -190,12 +186,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get themes => 'Themes';
@override
- String get hideHighlightOutlineOnHomescreen =>
- 'Hide highlight outline on homescreen';
+ String get hideHighlightOutlineOnHomescreen => 'Hide highlight outline on homescreen';
@override
- String get appSelectorTransitionAnimation =>
- 'App selector transition animation';
+ String get appSelectorTransitionAnimation => 'App selector transition animation';
@override
String get sort => 'Sort';
@@ -215,8 +209,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get time => 'Time';
@override
- String get titleStatusBarSettingsPage =>
- 'Choose what to display in the status bar';
+ String get titleStatusBarSettingsPage => 'Choose what to display in the status bar';
@override
String get tvApplications => 'TV Apps';
@@ -247,4 +240,93 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get pickNightWallpaper => 'Pick night wallpaper';
+
+ @override
+ String get accessibility => 'Accessibility';
+
+ @override
+ String get defaultLauncherIsDefault => 'LTvLauncher is the default launcher';
+
+ @override
+ String get defaultLauncherNotDefault => 'LTvLauncher is not the default launcher';
+
+ @override
+ String get setAsDefaultLauncher => 'Set as default launcher';
+
+ @override
+ String get defaultLauncherDescription => 'When set as the default launcher, the Home button will always return to LTvLauncher. The TV will also boot directly into LTvLauncher.';
+
+ @override
+ String get inputs => 'Inputs';
+
+ @override
+ String get inputSources => 'Input Sources';
+
+ @override
+ String get backupAndRestore => 'Backup & Restore';
+
+ @override
+ String get exportBackup => 'Export Backup';
+
+ @override
+ String get importBackup => 'Import Backup';
+
+ @override
+ String exportSuccess(String path) {
+ return 'Backup exported successfully to $path';
+ }
+
+ @override
+ String get importSuccess => 'Backup imported successfully';
+
+ @override
+ String get importConfirm => 'Are you sure you want to import the backup? This will overwrite your current settings and layout.';
+
+ @override
+ String importError(String error) {
+ return 'Failed to import backup: $error';
+ }
+
+ @override
+ String exportError(String error) {
+ return 'Failed to export backup: $error';
+ }
+
+ @override
+ String get shareBackup => 'Share Backup';
+
+ @override
+ String get shareBackupDescription => 'Share backup with other devices on local network';
+
+ @override
+ String get stopSharing => 'Stop Sharing';
+
+ @override
+ String get localNetworkSharingActive => 'Local network sharing is active!';
+
+ @override
+ String get localNetworkSharingInstructions => 'Connect another device to the same Wi-Fi network and open the following URL in a web browser:';
+
+ @override
+ String get localNetworkSharingDetails => 'Here you can download your TV settings/layout or upload a backup file back to this TV.';
+
+ @override
+ String failedToStartServer(String error) {
+ return 'Failed to start sharing server: $error';
+ }
+
+ @override
+ String get notificationBell => 'Notification Bell';
+
+ @override
+ String get autoHideNotificationBell => 'Auto-hide Notification Bell';
+
+ @override
+ String get continueWatching => 'Continue Watching';
+
+ @override
+ String get showContinueWatchingOnHome => 'Show Continue Watching on Home';
+
+ @override
+ String get permissionDeniedContinueWatching => 'Permission required to show Continue Watching';
}
diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart
index 43df3341..b3a307b3 100644
--- a/lib/l10n/app_localizations_es.dart
+++ b/lib/l10n/app_localizations_es.dart
@@ -1,5 +1,3 @@
-// ignore: unused_import
-import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
@@ -9,7 +7,7 @@ class AppLocalizationsEs extends AppLocalizations {
AppLocalizationsEs([String locale = 'es']) : super(locale);
@override
- String get aboutFlauncher => 'Acerca de FLauncher';
+ String get aboutFlauncher => 'Acerca de LTvLauncher';
@override
String get addCategory => 'Agregar categoría';
@@ -60,19 +58,16 @@ class AppLocalizationsEs extends AppLocalizations {
String get dialogOptionBackButtonActionDoNothing => 'Nada';
@override
- String get dialogOptionBackButtonActionShowScreensaver =>
- 'Mostrar salvapantallas';
+ String get dialogOptionBackButtonActionShowScreensaver => 'Mostrar salvapantallas';
@override
String get dialogOptionBackButtonActionShowClock => 'Mostrar reloj';
@override
- String get dialogTextNoFileExplorer =>
- 'Por favor, instale un gestor de archivos para seleccionar una imagen.';
+ String get dialogTextNoFileExplorer => 'Por favor, instale un gestor de archivos para seleccionar una imagen.';
@override
- String get dialogTitleBackButtonAction =>
- 'Elegir la acción del botón \'Atrás\'';
+ String get dialogTitleBackButtonAction => 'Elegir la acción del botón \'Atrás\'';
@override
String disambiguateCategoryTitle(String title) {
@@ -93,7 +88,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get gradient => 'Gradiente';
@override
- String get favoriteApps => 'Favorite Apps';
+ String get favoriteApps => 'Apps Favoritas';
@override
String get grid => 'Cuadrícula';
@@ -144,15 +139,14 @@ class AppLocalizationsEs extends AppLocalizations {
String get open => 'Abrir';
@override
- String get orSelectFormatSpecifiers =>
- 'O seleccione especificadores de formato';
+ String get orSelectFormatSpecifiers => 'O seleccione especificadores de formato';
@override
String get picture => 'Imagen';
@override
String removeFrom(String name) {
- return 'Remover de $name';
+ return 'Eliminar de $name';
}
@override
@@ -162,10 +156,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get reorder => 'Reordenar';
@override
- String get row => 'Row';
+ String get row => 'Fila';
@override
- String get rowHeight => 'Row height';
+ String get rowHeight => 'Altura de fila';
@override
String get save => 'Guardar';
@@ -174,8 +168,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get spacer => 'Espaciador';
@override
- String get spacerMaxHeightRequirement =>
- 'Debe ser mayor a cero y menor o igual a 500';
+ String get spacerMaxHeightRequirement => 'Debe ser mayor a cero y menor o igual a 500';
@override
String get statusBar => 'Barra de estado';
@@ -193,12 +186,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get themes => 'Temas';
@override
- String get hideHighlightOutlineOnHomescreen =>
- 'Ocultar el contorno de resaltado en la pantalla de inicio';
+ String get hideHighlightOutlineOnHomescreen => 'Ocultar el contorno de resaltado en la pantalla de inicio';
@override
- String get appSelectorTransitionAnimation =>
- 'Animación de transición del selector de aplicaciones';
+ String get appSelectorTransitionAnimation => 'Animación de transición del selector de aplicaciones';
@override
String get sort => 'Orden';
@@ -218,8 +209,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get time => 'Hora';
@override
- String get titleStatusBarSettingsPage =>
- 'Elija la información a mostrar en la barra de estado';
+ String get titleStatusBarSettingsPage => 'Elija la información a mostrar en la barra de estado';
@override
String get tvApplications => 'Aplicaciones del televisor';
@@ -243,11 +233,100 @@ class AppLocalizationsEs extends AppLocalizations {
String get withEllipsisAddTo => 'Añadir a...';
@override
- String get timeBasedWallpaper => 'Time based wallpaper';
+ String get timeBasedWallpaper => 'Fondo de pantalla según hora';
@override
- String get pickDayWallpaper => 'Pick day wallpaper';
+ String get pickDayWallpaper => 'Elegir fondo de pantalla diurno';
@override
- String get pickNightWallpaper => 'Pick night wallpaper';
+ String get pickNightWallpaper => 'Elegir fondo de pantalla nocturno';
+
+ @override
+ String get accessibility => 'Accesibilidad';
+
+ @override
+ String get defaultLauncherIsDefault => 'LTvLauncher es el lanzador predeterminado';
+
+ @override
+ String get defaultLauncherNotDefault => 'LTvLauncher no es el lanzador predeterminado';
+
+ @override
+ String get setAsDefaultLauncher => 'Establecer como lanzador predeterminado';
+
+ @override
+ String get defaultLauncherDescription => 'Cuando se establece como lanzador predeterminado, el botón de inicio siempre regresará a LTvLauncher. El TV también iniciará directamente en LTvLauncher.';
+
+ @override
+ String get inputs => 'Entradas';
+
+ @override
+ String get inputSources => 'Fuentes de Entrada';
+
+ @override
+ String get backupAndRestore => 'Copia de seguridad y restauración';
+
+ @override
+ String get exportBackup => 'Exportar copia de seguridad';
+
+ @override
+ String get importBackup => 'Importar copia de seguridad';
+
+ @override
+ String exportSuccess(String path) {
+ return 'Copia de seguridad exportada con éxito a $path';
+ }
+
+ @override
+ String get importSuccess => 'Copia de seguridad importada con éxito';
+
+ @override
+ String get importConfirm => '¿Está seguro de que desea importar la copia de seguridad? Esto sobrescribirá su configuración y diseño actuales.';
+
+ @override
+ String importError(String error) {
+ return 'Error al importar la copia de seguridad: $error';
+ }
+
+ @override
+ String exportError(String error) {
+ return 'Error al exportar la copia de seguridad: $error';
+ }
+
+ @override
+ String get shareBackup => 'Compartir copia';
+
+ @override
+ String get shareBackupDescription => 'Comparta la copia de seguridad con otros dispositivos en la red local';
+
+ @override
+ String get stopSharing => 'Detener uso compartido';
+
+ @override
+ String get localNetworkSharingActive => '¡El uso compartido en red local está activo!';
+
+ @override
+ String get localNetworkSharingInstructions => 'Conecte otro dispositivo a la misma red Wi-Fi y abra la siguiente URL en un navegador web:';
+
+ @override
+ String get localNetworkSharingDetails => 'Aquí puede descargar la configuración/diseño de su TV o subir un archivo de copia de seguridad a esta TV.';
+
+ @override
+ String failedToStartServer(String error) {
+ return 'Error al iniciar el servidor de uso compartido: $error';
+ }
+
+ @override
+ String get notificationBell => 'Campana de notificaciones';
+
+ @override
+ String get autoHideNotificationBell => 'Ocultar campana de notificaciones automáticamente';
+
+ @override
+ String get continueWatching => 'Continuar viendo';
+
+ @override
+ String get showContinueWatchingOnHome => 'Mostrar Continuar viendo en Inicio';
+
+ @override
+ String get permissionDeniedContinueWatching => 'Se requiere permiso para mostrar Continuar viendo';
}
diff --git a/lib/main.dart b/lib/main.dart
index 406282b7..06b48a82 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -26,6 +26,10 @@ import 'package:flauncher/providers/network_service.dart';
import 'package:flauncher/providers/settings_service.dart';
import 'package:flauncher/providers/brightness_service.dart';
import 'package:flauncher/providers/wallpaper_service.dart';
+import 'package:flauncher/providers/tv_inputs_service.dart';
+import 'package:flauncher/providers/notifications_service.dart';
+import 'package:flauncher/providers/watch_next_service.dart';
+import 'package:flauncher/providers/backup_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -43,6 +47,9 @@ Future main() async {
runApp(MultiProvider(
providers: [
+ Provider(
+ create: (_) => BackupService(fLauncherDatabase, sharedPreferences),
+ ),
ChangeNotifierProvider(
create: (_) => SettingsService(sharedPreferences),
lazy: false),
@@ -58,6 +65,9 @@ Future main() async {
ChangeNotifierProvider(
create: (_) => BrightnessService(sharedPreferences),
lazy: false),
+ ChangeNotifierProvider(create: (_) => TvInputsService(fLauncherChannel)),
+ ChangeNotifierProvider(create: (_) => NotificationsService(fLauncherChannel)),
+ ChangeNotifierProvider(create: (_) => WatchNextService(fLauncherChannel)),
],
child: FLauncherApp()
)
diff --git a/lib/models/category_with_apps.dart b/lib/models/category_with_apps.dart
deleted file mode 100644
index 994b4279..00000000
--- a/lib/models/category_with_apps.dart
+++ /dev/null
@@ -1,9 +0,0 @@
-import 'category.dart';
-import 'app.dart';
-
-class CategoryWithApps {
- final Category category;
- final List applications;
-
- CategoryWithApps(this.category, this.applications);
-}
diff --git a/lib/models/tv_input.dart b/lib/models/tv_input.dart
new file mode 100644
index 00000000..89722530
--- /dev/null
+++ b/lib/models/tv_input.dart
@@ -0,0 +1,38 @@
+enum TvInputType {
+ tuner,
+ hdmi,
+ av,
+ other
+}
+
+class TvInput {
+ final String id;
+ final String label;
+ final TvInputType type;
+
+ TvInput({
+ required this.id,
+ required this.label,
+ required this.type,
+ });
+
+ factory TvInput.fromMap(Map map) {
+ final int typeInt = map['type'] as int? ?? 1000;
+ TvInputType type;
+ if (typeInt == 1007) {
+ type = TvInputType.hdmi;
+ } else if (typeInt == 0 || typeInt == 2 || typeInt == 3) {
+ type = TvInputType.tuner;
+ } else if (typeInt >= 1001 && typeInt <= 1004) {
+ type = TvInputType.av;
+ } else {
+ type = TvInputType.other;
+ }
+
+ return TvInput(
+ id: map['id'] as String? ?? '',
+ label: map['label'] as String? ?? '',
+ type: type,
+ );
+ }
+}
diff --git a/lib/models/watch_next_program.dart b/lib/models/watch_next_program.dart
new file mode 100644
index 00000000..dd253515
--- /dev/null
+++ b/lib/models/watch_next_program.dart
@@ -0,0 +1,44 @@
+import 'dart:typed_data';
+
+class WatchNextProgram {
+ final int id;
+ final String packageName;
+ final String title;
+ final String description;
+ final int watchNextType;
+ final int lastEngagementTime;
+ final int playbackPosition;
+ final int duration;
+ final String intentUri;
+ final String posterArtUri;
+ Uint8List? posterBytes;
+
+ WatchNextProgram({
+ required this.id,
+ required this.packageName,
+ required this.title,
+ required this.description,
+ required this.watchNextType,
+ required this.lastEngagementTime,
+ required this.playbackPosition,
+ required this.duration,
+ required this.intentUri,
+ required this.posterArtUri,
+ this.posterBytes,
+ });
+
+ factory WatchNextProgram.fromMap(Map map) {
+ return WatchNextProgram(
+ id: map['id'] as int? ?? 0,
+ packageName: map['packageName'] as String? ?? '',
+ title: map['title'] as String? ?? '',
+ description: map['description'] as String? ?? '',
+ watchNextType: map['watchNextType'] as int? ?? 0,
+ lastEngagementTime: map['lastEngagementTime'] as int? ?? 0,
+ playbackPosition: map['playbackPosition'] as int? ?? 0,
+ duration: map['duration'] as int? ?? 0,
+ intentUri: map['intentUri'] as String? ?? '',
+ posterArtUri: map['posterArtUri'] as String? ?? '',
+ );
+ }
+}
diff --git a/lib/providers/apps_service.dart b/lib/providers/apps_service.dart
index bd215ddf..3eaf53e1 100644
--- a/lib/providers/apps_service.dart
+++ b/lib/providers/apps_service.dart
@@ -63,16 +63,20 @@ class AppsService extends ChangeNotifier {
String? _pendingReorderFocusPackage;
int? _pendingReorderFocusCategoryId;
+ int? _pendingReorderFocusIndex;
String? get pendingReorderFocusPackage => _pendingReorderFocusPackage;
int? get pendingReorderFocusCategoryId => _pendingReorderFocusCategoryId;
+ int? get pendingReorderFocusIndex => _pendingReorderFocusIndex;
void clearPendingReorderFocusPackage() {
_pendingReorderFocusPackage = null;
_pendingReorderFocusCategoryId = null;
+ _pendingReorderFocusIndex = null;
}
- void setPendingReorderFocus(String packageName, int categoryId) {
+ void setPendingReorderFocus(String packageName, int categoryId, int index) {
_pendingReorderFocusPackage = packageName;
_pendingReorderFocusCategoryId = categoryId;
+ _pendingReorderFocusIndex = index;
}
List get applications => UnmodifiableListView(
@@ -259,6 +263,8 @@ class AppsService extends ChangeNotifier {
});
}
+ Future refreshState() => _refreshState(shouldNotifyListeners: true);
+
Future _refreshState({bool shouldNotifyListeners = true}) async {
Future> appsFromDatabaseFuture = _database.getApplications();
Future> appsCategoriesFuture =
@@ -274,17 +280,9 @@ class AppsService extends ChangeNotifier {
Map appsFromSystemByPackageName =
Map.fromEntries(appEntries);
- List appsFromDatabase = await appsFromDatabaseFuture;
- final Iterable appsRemovedFromSystem = appsFromDatabase.where(
- (app) => !appsFromSystemByPackageName.containsKey(app.packageName));
-
- final List uninstalledApplications =
- appsRemovedFromSystem.map((app) => app.packageName).toList();
-
await _database.transaction(() async {
await _database.persistApps(
appsFromSystemByPackageName.values.map((record) => record.$2));
- await _database.deleteApps(uninstalledApplications);
});
appsFromDatabaseFuture = _database.getApplications();
@@ -296,7 +294,7 @@ class AppsService extends ChangeNotifier {
spacersFuture
]);
- appsFromDatabase = await appsFromDatabaseFuture;
+ List appsFromDatabase = await appsFromDatabaseFuture;
List appsCategories = await appsCategoriesFuture;
List categories = await categoriesFuture;
List spacers = await spacersFuture;
@@ -305,6 +303,8 @@ class AppsService extends ChangeNotifier {
categories.map((category) => MapEntry(category.id, category)));
_invalidateCategoryCache();
_applications = Map.fromEntries(appsFromDatabase
+ .where((application) =>
+ appsFromSystemByPackageName.containsKey(application.packageName))
.map((application) => MapEntry(application.packageName, application)));
_launcherSections.clear();
@@ -333,7 +333,7 @@ class AppsService extends ChangeNotifier {
}
}
- if (appsCategories.isNotEmpty && !application.hidden) {
+ if (appsCategories.isNotEmpty) {
List? currentApplicationCategories =
appsCategoriesByPackage[application.packageName];
@@ -342,7 +342,9 @@ class AppsService extends ChangeNotifier {
final category = _categoriesById[appCategory.categoryId];
if (category != null) {
application.categoryOrders[category.id] = appCategory.order;
- category.applications.add(application);
+ if (!application.hidden) {
+ category.applications.add(application);
+ }
}
}
}
@@ -640,6 +642,7 @@ class AppsService extends ChangeNotifier {
List orderedAppCategories = [];
for (int i = 0; i < applications.length; ++i) {
+ applications[i].categoryOrders[categoryFound.id] = i;
orderedAppCategories.add(AppsCategoriesCompanion(
categoryId: Value(categoryFound.id),
appPackageName: Value(applications[i].packageName),
@@ -683,9 +686,6 @@ class AppsService extends ChangeNotifier {
// Remove from current
await removeFromCategory(app, currentCategory);
- // Set pending focus package so AppCard can reclaim focus and reorder mode
- _pendingReorderFocusPackage = app.packageName;
-
// Add to target
// DB Insert Logic
// 1. Get current items in target
@@ -698,6 +698,9 @@ class AppsService extends ChangeNotifier {
targetApps.add(app); // Insert at bottom
}
+ int newIndex = direction == AxisDirection.down ? 0 : targetApps.length - 1;
+ setPendingReorderFocus(app.packageName, targetCategory.id, newIndex);
+
// 3. Update orders for all items in target category
List orderedAppCategories = [];
for (int i = 0; i < targetApps.length; ++i) {
@@ -734,23 +737,16 @@ class AppsService extends ChangeNotifier {
int columnsCount = Category.ColumnsCount,
int rowHeight = Category.RowHeight,
bool shouldNotifyListeners = true}) async {
- List orderedCategories = [];
- int categoryOrder = 1, newCategoryId = -1;
- for (Category category in _categoriesById.values) {
- orderedCategories.add(CategoriesCompanion(
- id: Value(category.id), order: Value(categoryOrder++)));
- }
+ int order = _launcherSections.length;
+ int newCategoryId = -1;
try {
newCategoryId = await _database.transaction(() async {
int newCategoryId = await _database.insertCategory(
- CategoriesCompanion.insert(name: categoryName, order: 0));
- await _database.updateCategories(orderedCategories);
-
+ CategoriesCompanion.insert(name: categoryName, order: order));
return newCategoryId;
});
- Map newCategories = Map();
Category newCategory = Category(
id: newCategoryId,
name: categoryName,
@@ -758,16 +754,9 @@ class AppsService extends ChangeNotifier {
type: type,
columnsCount: columnsCount,
rowHeight: rowHeight,
- order: 0);
- newCategories[newCategoryId] = newCategory;
-
- categoryOrder = 1;
- for (Category category in _categoriesById.values) {
- newCategories[category.id] = category;
- category.order = categoryOrder++;
- }
+ order: order);
- _categoriesById = newCategories;
+ _categoriesById[newCategoryId] = newCategory;
_invalidateCategoryCache();
_launcherSections.add(newCategory);
diff --git a/lib/providers/backup_service.dart b/lib/providers/backup_service.dart
new file mode 100644
index 00000000..41591d39
--- /dev/null
+++ b/lib/providers/backup_service.dart
@@ -0,0 +1,272 @@
+import 'dart:convert';
+import 'dart:io';
+import 'package:drift/drift.dart';
+import 'package:flauncher/database.dart';
+import 'package:flauncher/models/app.dart';
+import 'package:flauncher/models/category.dart';
+import 'package:path/path.dart' as path;
+import 'package:path_provider/path_provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class BackupService {
+ final FLauncherDatabase _database;
+ final SharedPreferences _sharedPreferences;
+
+ BackupService(this._database, this._sharedPreferences);
+
+ /// Gets the path to the backup file.
+ /// Tries Downloads folder first, falls back to external files directory.
+ Future getBackupFile() async {
+ Directory? dir;
+ try {
+ dir = await getDownloadsDirectory();
+ } catch (_) {
+ // Ignored, fallback below
+ }
+ if (dir == null) {
+ try {
+ dir = await getExternalStorageDirectory();
+ } catch (_) {
+ // Ignored, fallback below
+ }
+ }
+ if (dir == null) {
+ try {
+ dir = await getApplicationDocumentsDirectory();
+ } catch (_) {
+ // Ignored
+ }
+ }
+ if (dir == null) {
+ throw const FileSystemException("Could not find any suitable directory for backup");
+ }
+ return File(path.join(dir.path, 'ltv_backup.json'));
+ }
+
+ Future getBackupDirectory() async {
+ final file = await getBackupFile();
+ return file.parent;
+ }
+
+ /// Gets the list of available backup files.
+ Future> getBackupFiles() async {
+ final Directory dir = await getBackupDirectory();
+ if (!await dir.exists()) {
+ return [];
+ }
+ final List entries = [];
+ await for (final entity in dir.list()) {
+ if (entity is File) {
+ final name = path.basename(entity.path);
+ if (name.startsWith('ltv_backup') && name.endsWith('.json')) {
+ final lastModified = await entity.lastModified();
+ final size = await entity.length();
+ entries.add(BackupFileEntry(
+ file: entity,
+ name: name,
+ lastModified: lastModified,
+ size: size,
+ ));
+ }
+ }
+ }
+ // Sort by modification date (newest first)
+ entries.sort((a, b) => b.lastModified.compareTo(a.lastModified));
+ return entries;
+ }
+
+ /// Exports categories, apps, spacers, and settings to a JSON file.
+ Future exportBackup() async {
+ final Directory dir = await getBackupDirectory();
+ final now = DateTime.now();
+ final timestamp = "${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}_"
+ "${now.hour.toString().padLeft(2, '0')}${now.minute.toString().padLeft(2, '0')}${now.second.toString().padLeft(2, '0')}";
+ final File file = File(path.join(dir.path, 'ltv_backup_$timestamp.json'));
+
+ // 1. Fetch SharedPreferences settings
+ final Map settingsMap = {};
+ final Set keys = _sharedPreferences.getKeys();
+ for (final key in keys) {
+ final value = _sharedPreferences.get(key);
+ if (value != null) {
+ settingsMap[key] = value;
+ }
+ }
+
+ // 2. Fetch database tables
+ final List categories = await _database.getCategories();
+ final List apps = await _database.getApplications();
+ final List appsCategories = await _database.getAppsCategories();
+ final List spacers = await _database.getLauncherSpacers();
+
+ // 3. Serialize everything
+ final Map backupData = {
+ "version": 1,
+ "settings": settingsMap,
+ "apps": apps.map((a) => {
+ "packageName": a.packageName,
+ "name": a.name,
+ "version": a.version,
+ "hidden": a.hidden,
+ "lastLaunchedAt": a.lastLaunchedAt?.millisecondsSinceEpoch,
+ }).toList(),
+ "categories": categories.map((c) => {
+ "id": c.id,
+ "name": c.name,
+ "sort": c.sort.index,
+ "type": c.type.index,
+ "rowHeight": c.rowHeight,
+ "columnsCount": c.columnsCount,
+ "order": c.order,
+ }).toList(),
+ "appsCategories": appsCategories.map((ac) => {
+ "categoryId": ac.categoryId,
+ "appPackageName": ac.appPackageName,
+ "order": ac.order,
+ }).toList(),
+ "spacers": spacers.map((s) => {
+ "id": s.id,
+ "height": s.height,
+ "order": s.order,
+ }).toList(),
+ };
+
+ final String jsonStr = const JsonEncoder.withIndent(' ').convert(backupData);
+ await file.writeAsString(jsonStr);
+ return file.path;
+ }
+
+ /// Imports categories, apps, spacers, and settings from the JSON file.
+ /// If [file] is not provided, tries to import the latest backup file.
+ Future importBackup([File? file]) async {
+ File? backupFile = file;
+ if (backupFile == null) {
+ final List backups = await getBackupFiles();
+ if (backups.isEmpty) {
+ throw FileNotFoundException("No backup files found");
+ }
+ backupFile = backups.first.file;
+ }
+ if (!await backupFile.exists()) {
+ throw FileNotFoundException("Backup file not found at ${backupFile.path}");
+ }
+
+ final String jsonStr = await backupFile.readAsString();
+ final Map backupData = json.decode(jsonStr) as Map;
+
+ if (backupData["version"] != 1) {
+ throw FormatException("Invalid backup file version");
+ }
+
+ // 1. Restore SharedPreferences
+ final Map settingsMap = Map.from(backupData["settings"] as Map);
+ for (final entry in settingsMap.entries) {
+ final key = entry.key;
+ final value = entry.value;
+ if (value is bool) {
+ await _sharedPreferences.setBool(key, value);
+ } else if (value is int) {
+ await _sharedPreferences.setInt(key, value);
+ } else if (value is double) {
+ await _sharedPreferences.setDouble(key, value);
+ } else if (value is String) {
+ await _sharedPreferences.setString(key, value);
+ } else if (value is List) {
+ await _sharedPreferences.setStringList(key, value.cast());
+ }
+ }
+
+ // 2. Restore database tables in a single transaction
+ await _database.transaction(() async {
+ // Clear existing records
+ await _database.customStatement('DELETE FROM apps_categories;');
+ await _database.customStatement('DELETE FROM launcher_spacers;');
+ await _database.customStatement('DELETE FROM categories;');
+ await _database.customStatement('DELETE FROM apps;');
+
+ // Insert Apps
+ final List appsJson = backupData["apps"] as List;
+ final List appsCompanions = appsJson.map((a) {
+ final Map map = Map.from(a as Map);
+ final int? lla = map["lastLaunchedAt"] as int?;
+ return AppsCompanion(
+ packageName: Value(map["packageName"] as String),
+ name: Value(map["name"] as String),
+ version: Value(map["version"] as String),
+ hidden: Value(map["hidden"] as bool),
+ lastLaunchedAt: Value(lla != null ? DateTime.fromMillisecondsSinceEpoch(lla) : null),
+ );
+ }).toList();
+ await _database.batch((batch) {
+ batch.insertAll(_database.apps, appsCompanions);
+ });
+
+ // Insert Categories
+ final List categoriesJson = backupData["categories"] as List;
+ final List categoriesCompanions = categoriesJson.map((c) {
+ final Map map = Map.from(c as Map);
+ return CategoriesCompanion(
+ id: Value(map["id"] as int),
+ name: Value(map["name"] as String),
+ sort: Value(CategorySort.values[map["sort"] as int]),
+ type: Value(CategoryType.values[map["type"] as int]),
+ rowHeight: Value(map["rowHeight"] as int),
+ columnsCount: Value(map["columnsCount"] as int),
+ order: Value(map["order"] as int),
+ );
+ }).toList();
+ await _database.batch((batch) {
+ batch.insertAll(_database.categories, categoriesCompanions);
+ });
+
+ // Insert AppsCategories
+ final List appsCategoriesJson = backupData["appsCategories"] as List;
+ final List appsCategoriesCompanions = appsCategoriesJson.map((ac) {
+ final Map map = Map.from(ac as Map);
+ return AppsCategoriesCompanion(
+ categoryId: Value(map["categoryId"] as int),
+ appPackageName: Value(map["appPackageName"] as String),
+ order: Value(map["order"] as int),
+ );
+ }).toList();
+ await _database.batch((batch) {
+ batch.insertAll(_database.appsCategories, appsCategoriesCompanions);
+ });
+
+ // Insert Spacers
+ final List spacersJson = backupData["spacers"] as List;
+ final List spacersCompanions = spacersJson.map((s) {
+ final Map map = Map.from(s as Map);
+ return LauncherSpacersCompanion(
+ id: Value(map["id"] as int),
+ height: Value(map["height"] as int),
+ order: Value(map["order"] as int),
+ );
+ }).toList();
+ await _database.batch((batch) {
+ batch.insertAll(_database.launcherSpacers, spacersCompanions);
+ });
+ });
+ }
+}
+
+class BackupFileEntry {
+ final File file;
+ final String name;
+ final DateTime lastModified;
+ final int size;
+
+ BackupFileEntry({
+ required this.file,
+ required this.name,
+ required this.lastModified,
+ required this.size,
+ });
+}
+
+class FileNotFoundException implements Exception {
+ final String message;
+ FileNotFoundException(this.message);
+ @override
+ String toString() => message;
+}
diff --git a/lib/providers/network_service.dart b/lib/providers/network_service.dart
index e6592393..69e270b8 100644
--- a/lib/providers/network_service.dart
+++ b/lib/providers/network_service.dart
@@ -57,7 +57,7 @@ enum CellularNetworkType
Nr, // 20
}
-class NetworkService extends ChangeNotifier
+class NetworkService extends ChangeNotifier with WidgetsBindingObserver
{
final FLauncherChannel _channel;
@@ -67,7 +67,10 @@ class NetworkService extends ChangeNotifier
int _wirelessNetworkSignalLevel;
int _dailyDataUsage; // In bytes
bool _hasUsageStatsPermission;
+ bool _vpnActive;
Timer? _usageTimer;
+ int _callCount = 0;
+ int _usageCallCount = 0;
NetworkService(this._channel) :
@@ -76,7 +79,8 @@ class NetworkService extends ChangeNotifier
_networkType = NetworkType.Unknown,
_wirelessNetworkSignalLevel = 0,
_dailyDataUsage = 0,
- _hasUsageStatsPermission = false
+ _hasUsageStatsPermission = false,
+ _vpnActive = false
{
_channel.addNetworkChangedListener(_onNetworkChanged);
@@ -92,46 +96,56 @@ class NetworkService extends ChangeNotifier
});
_checkPermissionAndStartPolling();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (state == AppLifecycleState.resumed) {
+ refreshPermissionAndUsage();
+ }
}
void _checkPermissionAndStartPolling() async {
- _hasUsageStatsPermission = await _channel.checkUsageStatsPermission();
+ final localCallCount = ++_callCount;
+ final bool allowed = await _channel.checkUsageStatsPermission();
+ if (localCallCount != _callCount) return;
+
+ _hasUsageStatsPermission = allowed;
if (_hasUsageStatsPermission) {
_fetchUsage();
- _usageTimer = Timer.periodic(const Duration(minutes: 5), (_) => _fetchUsage());
+ if (_usageTimer == null || !_usageTimer!.isActive) {
+ _usageTimer = Timer.periodic(const Duration(minutes: 5), (_) => _fetchUsage());
+ }
+ } else {
+ _usageTimer?.cancel();
+ _usageTimer = null;
}
notifyListeners();
}
Future requestPermission() async {
await _channel.requestUsageStatsPermission();
- // Wait a bit for user to return, or just rely on Lifecycle, but here we can't easily hook into lifecycle.
- // We can assume if they clicked it, they might come back.
- // Ideally we recheck when app resumes. For now let's just recheck after a delay or expose a method to recheck.
}
// Call this when app resumes
Future refreshPermissionAndUsage() async {
- _hasUsageStatsPermission = await _channel.checkUsageStatsPermission();
- if (_hasUsageStatsPermission) {
- _fetchUsage();
- if (_usageTimer == null || !_usageTimer!.isActive) {
- _usageTimer = Timer.periodic(const Duration(minutes: 5), (_) => _fetchUsage());
- }
- } else {
- _usageTimer?.cancel();
- }
- notifyListeners();
+ _checkPermissionAndStartPolling();
}
Future openWifiSettings() async {
await _channel.openWifiSettings();
}
+ Future openVpnSettings() async {
+ await _channel.openVpnSettings();
+ }
+
Future _fetchUsage() async {
- // This will be called with the current period from the widget
- // For now, default to daily
+ final localCallCount = ++_usageCallCount;
int usage = await _channel.getDailyDataUsage();
+ if (localCallCount != _usageCallCount) return;
+
if (usage != -1) {
_dailyDataUsage = usage;
notifyListeners();
@@ -153,6 +167,7 @@ class NetworkService extends ChangeNotifier
@override
void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
_usageTimer?.cancel();
super.dispose();
}
@@ -163,6 +178,7 @@ class NetworkService extends ChangeNotifier
int get wirelessNetworkSignalLevel => _wirelessNetworkSignalLevel;
int get dailyDataUsage => _dailyDataUsage;
bool get hasUsageStatsPermission => _hasUsageStatsPermission;
+ bool get vpnActive => _vpnActive;
CellularNetworkType _getCellularNetworkType(int index)
{
@@ -181,11 +197,12 @@ class NetworkService extends ChangeNotifier
int networkTypeInt = map["networkType"] as int;
_hasInternetAccess = map["internetAccess"] as bool;
_networkType = NetworkType.values[networkTypeInt];
+ _vpnActive = map["vpnActive"] as bool? ?? false;
if (_networkType == NetworkType.Cellular || _networkType == NetworkType.Wifi) {
_wirelessNetworkSignalLevel = map["wirelessSignalLevel"] as int;
}
- log("NetworkService: parsed type $_networkType, signal $_wirelessNetworkSignalLevel");
+ log("NetworkService: parsed type $_networkType, signal $_wirelessNetworkSignalLevel, vpn $_vpnActive");
} catch (e) {
log("NetworkService error parsing: $e");
}
@@ -201,6 +218,7 @@ class NetworkService extends ChangeNotifier
case "NETWORK_UNAVAILABLE":
_hasInternetAccess = false;
_networkType = NetworkType.Unknown;
+ _vpnActive = false;
break;
case "CAPABILITIES_CHANGED":
Map map = event["arguments"];
diff --git a/lib/providers/notifications_service.dart b/lib/providers/notifications_service.dart
new file mode 100644
index 00000000..f4e0f37f
--- /dev/null
+++ b/lib/providers/notifications_service.dart
@@ -0,0 +1,232 @@
+import 'dart:async';
+
+import 'package:flutter/widgets.dart';
+import 'package:flauncher/flauncher_channel.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class NotificationItem {
+ final String key;
+ final String packageName;
+ final String title;
+ final String text;
+ final bool isClearable;
+
+ NotificationItem({
+ required this.key,
+ required this.packageName,
+ required this.title,
+ required this.text,
+ required this.isClearable,
+ });
+
+ factory NotificationItem.fromMap(Map map) {
+ return NotificationItem(
+ key: map['key'] as String? ?? '',
+ packageName: map['packageName'] as String? ?? '',
+ title: map['title'] as String? ?? '',
+ text: map['text'] as String? ?? '',
+ isClearable: map['isClearable'] as bool? ?? false,
+ );
+ }
+}
+
+class NotificationsService extends ChangeNotifier with WidgetsBindingObserver {
+ final FLauncherChannel _channel;
+ Map _notificationCounts = {};
+ List _notifications = [];
+ bool _hasPermission = false;
+ bool _hasOverlayPermission = false;
+ bool _systemPopupEnabled = false;
+ bool _initialized = false;
+ StreamSubscription? _subscription;
+ int _callCount = 0;
+ SharedPreferences? _prefs;
+
+ NotificationsService(this._channel) {
+ _init();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ Map get notificationCounts => Map.unmodifiable(_notificationCounts);
+ List get notifications => List.unmodifiable(_notifications);
+ bool get hasPermission => _hasPermission;
+ bool get hasOverlayPermission => _hasOverlayPermission;
+ bool get systemPopupEnabled => _systemPopupEnabled;
+ bool get initialized => _initialized;
+
+ int getNotificationCount(String packageName) {
+ return _notificationCounts[packageName] ?? 0;
+ }
+
+ Future _init() async {
+ final localCallCount = ++_callCount;
+ _prefs = await SharedPreferences.getInstance();
+ if (localCallCount != _callCount) return;
+
+ _systemPopupEnabled = _prefs?.getBool('system_notifications_popup') ?? false;
+
+ final bool allowed = await _channel.checkNotificationListenerPermission();
+ if (localCallCount != _callCount) return;
+
+ _hasPermission = allowed;
+
+ final bool overlayAllowed = await _channel.checkOverlayPermission();
+ if (localCallCount != _callCount) return;
+
+ _hasOverlayPermission = overlayAllowed;
+
+ if (_hasPermission) {
+ final List> list = await _channel.getActiveNotifications();
+ if (localCallCount != _callCount) return;
+
+ _updateNotificationCounts(list);
+
+ _subscription = _channel.addNotificationsChangedListener((eventList) {
+ _updateNotificationCounts(eventList);
+ });
+ }
+
+ _initialized = true;
+ notifyListeners();
+ }
+
+ Future checkPermission() async {
+ final localCallCount = ++_callCount;
+ final bool allowed = await _channel.checkNotificationListenerPermission();
+ if (localCallCount != _callCount) return;
+
+ if (_hasPermission != allowed) {
+ _hasPermission = allowed;
+ notifyListeners();
+ }
+ }
+
+ Future refreshNotifications() async {
+ if (!_hasPermission) return;
+
+ final localCallCount = ++_callCount;
+ final List> list = await _channel.getActiveNotifications();
+ if (localCallCount != _callCount) return;
+
+ _updateNotificationCounts(list);
+ }
+
+ void _updateNotificationCounts(List> list) {
+ final Map newCounts = {};
+ final List newNotifications = [];
+
+ for (final item in list) {
+ final String? pkg = item['packageName'] as String?;
+ final int? count = item['count'] as int?;
+ if (pkg != null) {
+ if (count != null) {
+ newCounts[pkg] = count;
+ for (int i = 0; i < count; i++) {
+ newNotifications.add(NotificationItem(
+ key: '${pkg}_$i',
+ packageName: pkg,
+ title: 'Notification',
+ text: 'Content',
+ isClearable: true,
+ ));
+ }
+ } else {
+ final notification = NotificationItem.fromMap(item);
+ newNotifications.add(notification);
+ if (notification.isClearable) {
+ newCounts[pkg] = (newCounts[pkg] ?? 0) + 1;
+ }
+ }
+ }
+ }
+
+ // Direct comparison to avoid unnecessary notifies
+ bool changed = false;
+ if (_notificationCounts.length != newCounts.length) {
+ changed = true;
+ } else {
+ for (final key in newCounts.keys) {
+ if (_notificationCounts[key] != newCounts[key]) {
+ changed = true;
+ break;
+ }
+ }
+ }
+
+ if (!changed) {
+ if (_notifications.length != newNotifications.length) {
+ changed = true;
+ } else {
+ for (int i = 0; i < _notifications.length; i++) {
+ if (_notifications[i].key != newNotifications[i].key ||
+ _notifications[i].title != newNotifications[i].title ||
+ _notifications[i].text != newNotifications[i].text ||
+ _notifications[i].isClearable != newNotifications[i].isClearable) {
+ changed = true;
+ break;
+ }
+ }
+ }
+ }
+
+ if (changed) {
+ _notificationCounts = newCounts;
+ _notifications = newNotifications;
+ notifyListeners();
+ }
+ }
+
+ Future requestPermission() async {
+ return await _channel.requestNotificationListenerPermission();
+ }
+
+ Future checkOverlayPermission() async {
+ final localCallCount = ++_callCount;
+ final bool allowed = await _channel.checkOverlayPermission();
+ if (localCallCount != _callCount) return;
+
+ if (_hasOverlayPermission != allowed) {
+ _hasOverlayPermission = allowed;
+ notifyListeners();
+ }
+ }
+
+ Future requestOverlayPermission() async {
+ return await _channel.requestOverlayPermission();
+ }
+
+ Future setSystemPopupEnabled(bool enabled) async {
+ _systemPopupEnabled = enabled;
+ await _prefs?.setBool('system_notifications_popup', enabled);
+ notifyListeners();
+ }
+
+ Future dismiss(String key) async {
+ final bool success = await _channel.dismissNotification(key);
+ if (success) {
+ await refreshNotifications();
+ }
+ }
+
+ Future dismissAll() async {
+ final bool success = await _channel.dismissAllNotifications();
+ if (success) {
+ await refreshNotifications();
+ }
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (state == AppLifecycleState.resumed) {
+ checkPermission();
+ checkOverlayPermission();
+ }
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ _subscription?.cancel();
+ super.dispose();
+ }
+}
diff --git a/lib/providers/settings_service.dart b/lib/providers/settings_service.dart
index 10418f3b..8540abb0 100644
--- a/lib/providers/settings_service.dart
+++ b/lib/providers/settings_service.dart
@@ -43,6 +43,10 @@ const String _showNetworkIndicatorInStatusBarKey = "show_network_indicator_in_st
const String _accentColorKey = "accent_color";
const String _screensaverClockStyleKey = "screensaver_clock_style";
const String _timeBasedWallpaperEnabledKey = "time_based_wallpaper_enabled";
+const String _showInputsWidgetInStatusBarKey = "show_inputs_widget_in_status_bar";
+const String _showContinueWatchingKey = "show_continue_watching";
+const String _showNotificationsWidgetInStatusBarKey = "show_notifications_widget_in_status_bar";
+const String _autoHideNotificationsWidgetKey = "auto_hide_notifications_widget";
// WiFi usage period options
const String DATA_USAGE_DAILY = "daily";
@@ -91,6 +95,10 @@ class SettingsService extends ChangeNotifier {
late String _accentColorHex;
late String _screensaverClockStyle;
late bool _timeBasedWallpaperEnabled;
+ late bool _showInputsWidgetInStatusBar;
+ late bool _showContinueWatching;
+ late bool _showNotificationsWidgetInStatusBar;
+ late bool _autoHideNotificationsWidget;
bool get appHighlightAnimationEnabled => _appHighlightAnimationEnabled;
@@ -126,6 +134,11 @@ class SettingsService extends ChangeNotifier {
bool get showNetworkIndicatorInStatusBar => _showNetworkIndicatorInStatusBar;
+ bool get showInputsWidgetInStatusBar => _showInputsWidgetInStatusBar;
+ bool get showContinueWatching => _showContinueWatching;
+ bool get showNotificationsWidgetInStatusBar => _showNotificationsWidgetInStatusBar;
+ bool get autoHideNotificationsWidget => _autoHideNotificationsWidget;
+
String get accentColorHex => _accentColorHex;
String get screensaverClockStyle => _screensaverClockStyle;
@@ -136,6 +149,10 @@ class SettingsService extends ChangeNotifier {
}
SettingsService(this._sharedPreferences) {
+ reload();
+ }
+
+ void reload() {
_appHighlightAnimationEnabled = _sharedPreferences.getBool(_appHighlightAnimationEnabledKey) ?? true;
_appKeyClickEnabled = _sharedPreferences.getBool(_appKeyClickEnabledKey) ?? true;
_autoHideAppBarEnabled = _sharedPreferences.getBool(_autoHideAppBarKey) ?? false;
@@ -156,6 +173,11 @@ class SettingsService extends ChangeNotifier {
_accentColorHex = _sharedPreferences.getString(_accentColorKey) ?? ACCENT_COLOR_PURPLE;
_screensaverClockStyle = _sharedPreferences.getString(_screensaverClockStyleKey) ?? "minimal";
_timeBasedWallpaperEnabled = _sharedPreferences.getBool(_timeBasedWallpaperEnabledKey) ?? false;
+ _showInputsWidgetInStatusBar = _sharedPreferences.getBool(_showInputsWidgetInStatusBarKey) ?? true;
+ _showContinueWatching = _sharedPreferences.getBool(_showContinueWatchingKey) ?? true;
+ _showNotificationsWidgetInStatusBar = _sharedPreferences.getBool(_showNotificationsWidgetInStatusBarKey) ?? true;
+ _autoHideNotificationsWidget = _sharedPreferences.getBool(_autoHideNotificationsWidgetKey) ?? false;
+ notifyListeners();
}
Future setAppHighlightAnimationEnabled(bool value) async {
@@ -277,4 +299,28 @@ class SettingsService extends ChangeNotifier {
_timeBasedWallpaperEnabled = enabled;
notifyListeners();
}
+
+ Future setShowInputsWidgetInStatusBar(bool show) async {
+ await _sharedPreferences.setBool(_showInputsWidgetInStatusBarKey, show);
+ _showInputsWidgetInStatusBar = show;
+ notifyListeners();
+ }
+
+ Future setShowContinueWatching(bool show) async {
+ await _sharedPreferences.setBool(_showContinueWatchingKey, show);
+ _showContinueWatching = show;
+ notifyListeners();
+ }
+
+ Future setShowNotificationsWidgetInStatusBar(bool show) async {
+ await _sharedPreferences.setBool(_showNotificationsWidgetInStatusBarKey, show);
+ _showNotificationsWidgetInStatusBar = show;
+ notifyListeners();
+ }
+
+ Future setAutoHideNotificationsWidget(bool value) async {
+ await _sharedPreferences.setBool(_autoHideNotificationsWidgetKey, value);
+ _autoHideNotificationsWidget = value;
+ notifyListeners();
+ }
}
diff --git a/lib/providers/tv_inputs_service.dart b/lib/providers/tv_inputs_service.dart
new file mode 100644
index 00000000..57d45892
--- /dev/null
+++ b/lib/providers/tv_inputs_service.dart
@@ -0,0 +1,33 @@
+import 'package:flutter/foundation.dart';
+import 'package:flauncher/flauncher_channel.dart';
+import 'package:flauncher/models/tv_input.dart';
+
+class TvInputsService extends ChangeNotifier {
+ final FLauncherChannel _channel;
+ List _inputs = [];
+ bool _initialized = false;
+
+ TvInputsService(this._channel) {
+ _init();
+ }
+
+ List get inputs => List.unmodifiable(_inputs);
+ bool get hasInputs => _inputs.isNotEmpty;
+ bool get initialized => _initialized;
+
+ Future _init() async {
+ await refreshInputs();
+ _initialized = true;
+ notifyListeners();
+ }
+
+ Future refreshInputs() async {
+ final List> rawInputs = await _channel.getTvInputs();
+ _inputs = rawInputs.map((map) => TvInput.fromMap(map)).toList();
+ notifyListeners();
+ }
+
+ Future switchInput(String id) async {
+ return await _channel.launchTvInput(id);
+ }
+}
diff --git a/lib/providers/watch_next_service.dart b/lib/providers/watch_next_service.dart
new file mode 100644
index 00000000..8fb41fec
--- /dev/null
+++ b/lib/providers/watch_next_service.dart
@@ -0,0 +1,168 @@
+import 'dart:async';
+import 'dart:developer';
+import 'dart:io' show Platform;
+import 'package:flutter/foundation.dart';
+import 'package:flauncher/flauncher_channel.dart';
+import '../models/watch_next_program.dart';
+
+class WatchNextService extends ChangeNotifier {
+ final FLauncherChannel _channel;
+ List _programs = [];
+ bool _initialized = false;
+ Timer? _refreshTimer;
+ int _callCount = 0;
+
+ bool get _isTest => Platform.environment.containsKey('FLUTTER_TEST');
+
+ WatchNextService(this._channel) {
+ _init();
+ }
+
+ List get programs => List.unmodifiable(_programs);
+ bool get initialized => _initialized;
+
+ Future _init() async {
+ await refresh();
+ _initialized = true;
+ notifyListeners();
+
+ // Refresh every 30 seconds to keep EPG/Playback progress up to date
+ _refreshTimer = Timer.periodic(const Duration(seconds: 30), (_) => refresh());
+ }
+
+ Future refresh() async {
+ final int callSnapshot = ++_callCount;
+ try {
+ final bool hasPermission = await checkPermission();
+ if (callSnapshot != _callCount) return;
+
+ if (!hasPermission) {
+ if (_programs.isNotEmpty) {
+ _programs = [];
+ if (callSnapshot == _callCount) notifyListeners();
+ }
+ return;
+ }
+
+ List> list;
+ try {
+ list = await _channel.getWatchNextPrograms();
+ } catch (e) {
+ if (kReleaseMode) {
+ rethrow;
+ } else {
+ list = const [];
+ }
+ }
+ if (callSnapshot != _callCount) return;
+
+ if (!kReleaseMode && !_isTest && list.isEmpty) {
+ list = _getMockPrograms();
+ }
+
+ // Phase 1: Emit programs immediately with cached posters where available
+ final List newPrograms = [];
+ for (final map in list) {
+ final program = WatchNextProgram.fromMap(map);
+ // Reuse existing poster bytes if same program already loaded
+ final existing = _findExisting(program.id);
+ if (existing != null && existing.posterBytes != null) {
+ program.posterBytes = existing.posterBytes;
+ }
+ newPrograms.add(program);
+ }
+ _programs = newPrograms;
+ if (callSnapshot == _callCount) notifyListeners();
+
+ // Phase 2: Fetch missing posters concurrently, then notify again
+ final needsPoster = newPrograms.where(
+ (p) => p.posterArtUri.isNotEmpty && p.posterBytes == null
+ ).toList();
+ if (needsPoster.isNotEmpty) {
+ await Future.wait(
+ needsPoster.map((p) async {
+ try {
+ p.posterBytes = await _channel.getWatchNextPoster(p.posterArtUri);
+ } catch (e) {
+ log('Failed to fetch poster for ${p.title}', name: 'WatchNextService', error: e);
+ }
+ }),
+ );
+ if (callSnapshot == _callCount) notifyListeners();
+ }
+ } catch (e) {
+ log('Failed to refresh watch next programs', name: 'WatchNextService', error: e);
+ }
+ }
+
+ List> _getMockPrograms() {
+ return [
+ {
+ 'id': 9991,
+ 'packageName': 'com.google.android.youtube',
+ 'title': 'Mock Video 1 (YouTube)',
+ 'description': 'Description for mock video 1',
+ 'watchNextType': 0,
+ 'lastEngagementTime': DateTime.now().millisecondsSinceEpoch,
+ 'playbackPosition': 500000,
+ 'duration': 1000000,
+ 'intentUri': 'https://www.youtube.com',
+ 'posterArtUri': '',
+ },
+ {
+ 'id': 9992,
+ 'packageName': 'com.netflix.mediaclient',
+ 'title': 'Mock Show 2 (Netflix)',
+ 'description': 'S1 E2 • Episode Title',
+ 'watchNextType': 1,
+ 'lastEngagementTime': DateTime.now().millisecondsSinceEpoch - 100000,
+ 'playbackPosition': 200000,
+ 'duration': 1800000,
+ 'intentUri': 'https://www.netflix.com',
+ 'posterArtUri': '',
+ },
+ ];
+ }
+
+ WatchNextProgram? _findExisting(int id) {
+ for (final p in _programs) {
+ if (p.id == id) return p;
+ }
+ return null;
+ }
+
+ Future checkPermission() async {
+ if (!kReleaseMode && !_isTest) return true;
+ return await _channel.checkWatchNextPermission();
+ }
+
+ Future requestPermission() async {
+ if (!kReleaseMode && !_isTest) return true;
+ final bool granted = await _channel.requestWatchNextPermission();
+ if (granted) {
+ await refresh();
+ }
+ return granted;
+ }
+
+ Future launch(WatchNextProgram program) async {
+ if (program.intentUri.isNotEmpty) {
+ return await _channel.launchWatchNextProgram(program.intentUri);
+ } else if (program.packageName.isNotEmpty) {
+ try {
+ await _channel.launchApp(program.packageName);
+ return true;
+ } catch (e) {
+ log('Failed to launch app ${program.packageName}', name: 'WatchNextService', error: e);
+ return false;
+ }
+ }
+ return false;
+ }
+
+ @override
+ void dispose() {
+ _refreshTimer?.cancel();
+ super.dispose();
+ }
+}
diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart
index 6416cd31..9b62eb5f 100644
--- a/lib/widgets/app_card.dart
+++ b/lib/widgets/app_card.dart
@@ -24,6 +24,7 @@ import 'package:flauncher/providers/apps_service.dart';
import 'package:flauncher/providers/settings_service.dart';
import 'package:flauncher/widgets/application_info_panel.dart';
import 'package:flauncher/widgets/focus_keyboard_listener.dart';
+import 'package:flauncher/providers/notifications_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -40,6 +41,7 @@ class AppCard extends StatefulWidget
final bool autofocus;
final void Function(AxisDirection) onMove;
final VoidCallback onMoveEnd;
+ final int index;
final bool handleUpNavigationToSettings;
final bool isFirstInRow;
final bool isLastInRow;
@@ -51,6 +53,7 @@ class AppCard extends StatefulWidget
required this.autofocus,
required this.onMove,
required this.onMoveEnd,
+ this.index = 0,
this.handleUpNavigationToSettings = false,
this.isFirstInRow = false,
this.isLastInRow = false,
@@ -98,9 +101,11 @@ class _AppCardState extends State with TickerProviderStateMixin {
// Check if we need to restore focus/reorder mode after a move
WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
final appsService = Provider.of(context, listen: false);
if (appsService.pendingReorderFocusPackage == widget.application.packageName &&
- appsService.pendingReorderFocusCategoryId == widget.category.id) {
+ appsService.pendingReorderFocusCategoryId == widget.category.id &&
+ widget.index == appsService.pendingReorderFocusIndex) {
appsService.clearPendingReorderFocusPackage();
_focusNode.requestFocus();
@@ -117,9 +122,11 @@ class _AppCardState extends State with TickerProviderStateMixin {
// Check for pending focus on update as well
WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
final appsService = Provider.of(context, listen: false);
if (appsService.pendingReorderFocusPackage == widget.application.packageName &&
- appsService.pendingReorderFocusCategoryId == widget.category.id) {
+ appsService.pendingReorderFocusCategoryId == widget.category.id &&
+ widget.index == appsService.pendingReorderFocusIndex) {
appsService.clearPendingReorderFocusPackage();
_focusNode.requestFocus();
@@ -226,6 +233,47 @@ class _AppCardState extends State with TickerProviderStateMixin {
child: Stack(
fit: StackFit.expand,
children: [
+ Selector(
+ selector: (_, service) => service.getNotificationCount(widget.application.packageName),
+ builder: (context, count, _) {
+ if (count <= 0) return const SizedBox.shrink();
+ return Positioned(
+ top: 8,
+ right: 8,
+ child: IgnorePointer(
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ decoration: BoxDecoration(
+ color: Colors.red,
+ borderRadius: BorderRadius.circular(10),
+ boxShadow: const [
+ BoxShadow(
+ color: Colors.black45,
+ blurRadius: 4,
+ offset: Offset(0, 2),
+ ),
+ ],
+ ),
+ constraints: const BoxConstraints(
+ minWidth: 20,
+ minHeight: 20,
+ ),
+ child: Center(
+ child: Text(
+ '$count',
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ height: 1.0,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
InkWell(
focusNode: _focusNode,
autofocus: widget.autofocus,
@@ -466,13 +514,16 @@ class _AppCardState extends State with TickerProviderStateMixin {
else {
return const Padding(
padding: EdgeInsets.all(8),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- CircularProgressIndicator(),
- SizedBox(height: 0, width: 16),
- Text("Loading")
- ],
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ CircularProgressIndicator(),
+ SizedBox(height: 0, width: 16),
+ Text("Loading")
+ ],
+ ),
),
);
}
@@ -589,7 +640,10 @@ class _AppCardState extends State with TickerProviderStateMixin {
} else if (key == LogicalKeyboardKey.arrowDown) {
widget.onMove(AxisDirection.down);
- } else if (_validationKeys.contains(key) || key == LogicalKeyboardKey.escape) {
+ } else if (_validationKeys.contains(key) ||
+ key == LogicalKeyboardKey.escape ||
+ key == LogicalKeyboardKey.goBack ||
+ key == LogicalKeyboardKey.gameButtonB) {
setState(() => _moving = false);
widget.onMoveEnd();
diff --git a/lib/widgets/application_info_panel.dart b/lib/widgets/application_info_panel.dart
index 753b9205..33d09fbd 100644
--- a/lib/widgets/application_info_panel.dart
+++ b/lib/widgets/application_info_panel.dart
@@ -24,8 +24,8 @@ import 'package:flauncher/providers/apps_service.dart';
import 'package:flauncher/widgets/add_to_category_dialog.dart';
import 'package:flauncher/widgets/side_panel_dialog.dart';
import 'package:provider/provider.dart';
-import 'package:flutter_gen/gen_l10n/app_localizations.dart';
-import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
+import 'package:flauncher/l10n/app_localizations.dart';
+import 'package:flauncher/l10n/app_localizations_en.dart';
import '../models/app.dart';
import '../models/category.dart';
diff --git a/lib/widgets/apps_grid.dart b/lib/widgets/apps_grid.dart
index b104bc67..2e8a07af 100644
--- a/lib/widgets/apps_grid.dart
+++ b/lib/widgets/apps_grid.dart
@@ -64,13 +64,14 @@ class AppsGrid extends StatelessWidget
return AppCard(
key: Key(applications[index].packageName),
+ index: index,
category: category,
application: applications[index],
autofocus: index == 0,
handleUpNavigationToSettings: isFirstSection && index < category.columnsCount,
isFirstInRow: isFirstInRow,
isLastInRow: isLastInRow,
- onMove: (direction) => _onMove(context, direction, index),
+ onMove: (direction) => _onMove(context, direction, applications[index]),
onMoveEnd: () => _saveOrder(context)
);
}
@@ -111,7 +112,10 @@ class AppsGrid extends StatelessWidget
return index >= 0 ? index : null;
}
- void _onMove(BuildContext context, AxisDirection direction, int index) {
+ void _onMove(BuildContext context, AxisDirection direction, App movingApp) {
+ final index = applications.indexOf(movingApp);
+ if (index == -1) return;
+
final currentRow = (index / category.columnsCount).floor();
final totalRows = ((applications.length - 1) / category.columnsCount).floor();
@@ -142,12 +146,11 @@ class AppsGrid extends StatelessWidget
}
if (newIndex != null) {
final appsService = context.read();
- final movingApp = applications[index];
appsService.reorderApplication(category, index, newIndex);
// Set pending focus so the app at the new position will request focus
- appsService.setPendingReorderFocus(movingApp.packageName, category.id);
+ appsService.setPendingReorderFocus(movingApp.packageName, category.id, newIndex);
}
}
diff --git a/lib/widgets/category_row.dart b/lib/widgets/category_row.dart
index d28d34d0..b6a1a306 100644
--- a/lib/widgets/category_row.dart
+++ b/lib/widgets/category_row.dart
@@ -60,13 +60,14 @@ class CategoryRow extends StatelessWidget
key: Key(applications[index].packageName),
padding: const EdgeInsets.symmetric(horizontal: 8),
child: AppCard(
+ index: index,
category: category,
application: applications[index],
autofocus: index == 0,
handleUpNavigationToSettings: isFirstSection,
isFirstInRow: index == 0,
isLastInRow: index == applications.length - 1,
- onMove: (direction) => _onMove(context, direction, index),
+ onMove: (direction) => _onMove(context, direction, applications[index]),
onMoveEnd: () => _onMoveEnd(context)
)
)
@@ -104,7 +105,10 @@ class CategoryRow extends StatelessWidget
int _findChildIndex(Key key) =>
applications.indexWhere((app) => app.packageName == (key as ValueKey).value);
- void _onMove(BuildContext context, AxisDirection direction, int index) {
+ void _onMove(BuildContext context, AxisDirection direction, App movingApp) {
+ final index = applications.indexOf(movingApp);
+ if (index == -1) return;
+
int newIndex = 0;
if (direction == AxisDirection.right && index < applications.length - 1) {
@@ -117,11 +121,10 @@ class CategoryRow extends StatelessWidget
}
final appsService = context.read();
- final movingApp = applications[index];
appsService.reorderApplication(category, index, newIndex);
// Set pending focus so the app at the new position will request focus
- appsService.setPendingReorderFocus(movingApp.packageName, category.id);
+ appsService.setPendingReorderFocus(movingApp.packageName, category.id, newIndex);
}
void _onMoveEnd(BuildContext context) {
diff --git a/lib/widgets/continue_watching_row.dart b/lib/widgets/continue_watching_row.dart
new file mode 100644
index 00000000..4f18e831
--- /dev/null
+++ b/lib/widgets/continue_watching_row.dart
@@ -0,0 +1,379 @@
+import 'package:flauncher/models/watch_next_program.dart';
+import 'package:flauncher/providers/apps_service.dart';
+import 'package:flauncher/providers/settings_service.dart';
+import 'package:flauncher/providers/watch_next_service.dart';
+import 'package:flauncher/actions.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+import 'package:flauncher/l10n/app_localizations.dart';
+
+class ContinueWatchingRow extends StatelessWidget {
+ const ContinueWatchingRow({Key? key}) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ final settingsService = Provider.of(context);
+ if (!settingsService.showContinueWatching) {
+ return const SizedBox.shrink();
+ }
+
+ return Consumer2(
+ builder: (context, watchNextService, appsService, _) {
+ final List programs = watchNextService.programs;
+ if (programs.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.only(left: 16, bottom: 8),
+ child: Text(
+ AppLocalizations.of(context)!.continueWatching,
+ style: Theme.of(context).textTheme.titleLarge!.copyWith(
+ shadows: [
+ const Shadow(
+ color: Colors.black54,
+ offset: Offset(1, 1),
+ blurRadius: 8,
+ )
+ ],
+ ),
+ ),
+ ),
+ SizedBox(
+ height: 170, // Increased for larger cards
+ child: ListView.builder(
+ clipBehavior: Clip.none,
+ padding: const EdgeInsets.all(8),
+ scrollDirection: Axis.horizontal,
+ itemCount: programs.length,
+ itemBuilder: (context, index) {
+ final program = programs[index];
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8),
+ child: WatchNextCard(
+ program: program,
+ appsService: appsService,
+ watchNextService: watchNextService,
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+}
+
+class WatchNextCard extends StatefulWidget {
+ final WatchNextProgram program;
+ final AppsService appsService;
+ final WatchNextService watchNextService;
+
+ const WatchNextCard({
+ Key? key,
+ required this.program,
+ required this.appsService,
+ required this.watchNextService,
+ }) : super(key: key);
+
+ @override
+ State createState() => _WatchNextCardState();
+}
+
+class _WatchNextCardState extends State {
+ late final FocusNode _focusNode;
+ bool _focused = false;
+ Future? _iconFuture;
+
+ @override
+ void initState() {
+ super.initState();
+ _focusNode = FocusNode();
+ _focusNode.addListener(_onFocusChange);
+ _iconFuture = widget.appsService.getAppIcon(widget.program.packageName);
+ }
+
+ @override
+ void didUpdateWidget(covariant WatchNextCard oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.program.packageName != widget.program.packageName) {
+ _iconFuture = widget.appsService.getAppIcon(widget.program.packageName);
+ }
+ }
+
+ void _onFocusChange() {
+ setState(() {
+ _focused = _focusNode.hasFocus;
+ });
+ if (_focusNode.hasFocus) {
+ Scrollable.ensureVisible(
+ context,
+ alignment: 0.5,
+ curve: Curves.easeInOut,
+ duration: const Duration(milliseconds: 100),
+ );
+ }
+ }
+
+ @override
+ void dispose() {
+ _focusNode.removeListener(_onFocusChange);
+ _focusNode.dispose();
+ super.dispose();
+ }
+
+ void _onPressed() {
+ widget.watchNextService.launch(widget.program);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final settingsService = Provider.of(context, listen: false);
+ final accentColor = settingsService.accentColor;
+
+ const double cardWidth = 260;
+ const double cardHeight = 146;
+
+ // Progress percentage
+ double progress = 0;
+ if (widget.program.duration > 0 && widget.program.playbackPosition >= 0) {
+ progress = widget.program.playbackPosition / widget.program.duration;
+ if (progress > 1.0) progress = 1.0;
+ }
+
+ return Focus(
+ focusNode: _focusNode,
+ onKeyEvent: (node, event) {
+ if (event is KeyDownEvent) {
+ if (event.logicalKey == LogicalKeyboardKey.select ||
+ event.logicalKey == LogicalKeyboardKey.enter ||
+ event.logicalKey == LogicalKeyboardKey.gameButtonA) {
+ _onPressed();
+ return KeyEventResult.handled;
+ } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
+ Actions.invoke(context, const MoveFocusToSettingsIntent());
+ return KeyEventResult.handled;
+ }
+ }
+ // Handle key repeats for directional navigation only
+ if (event is KeyRepeatEvent) {
+ if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
+ Actions.invoke(context, const MoveFocusToSettingsIntent());
+ return KeyEventResult.handled;
+ }
+ }
+ return KeyEventResult.ignored;
+ },
+ child: GestureDetector(
+ onTap: _onPressed,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
+ curve: Curves.easeOutCubic,
+ width: cardWidth,
+ height: cardHeight,
+ transform: _focused
+ ? (Matrix4.identity()..scale(1.05, 1.05))
+ : Matrix4.identity(),
+ transformAlignment: Alignment.center,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: _focused ? accentColor : Colors.white10,
+ width: _focused ? 2.5 : 1.0,
+ ),
+ boxShadow: _focused
+ ? [
+ BoxShadow(
+ color: accentColor.withOpacity(0.35),
+ blurRadius: 16,
+ spreadRadius: 2,
+ ),
+ BoxShadow(
+ color: accentColor.withOpacity(0.15),
+ blurRadius: 32,
+ spreadRadius: 4,
+ ),
+ ]
+ : [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.3),
+ blurRadius: 6,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: Stack(
+ children: [
+ // Poster background
+ Positioned.fill(
+ child: widget.program.posterBytes != null
+ ? Image.memory(
+ widget.program.posterBytes!,
+ fit: BoxFit.cover,
+ )
+ : _emptyPosterFallback(theme),
+ ),
+ // App icon badge (top-right, glass effect)
+ Positioned(
+ top: 8,
+ right: 8,
+ child: FutureBuilder(
+ future: _iconFuture,
+ builder: (context, snapshot) {
+ if (snapshot.hasData) {
+ return Container(
+ width: 28,
+ height: 28,
+ decoration: BoxDecoration(
+ color: Colors.black45,
+ borderRadius: BorderRadius.circular(6),
+ border: Border.all(
+ color: Colors.white.withOpacity(0.15),
+ width: 0.5,
+ ),
+ ),
+ padding: const EdgeInsets.all(3),
+ child: Image.memory(snapshot.data!),
+ );
+ }
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ // Title + progress overlay (bottom)
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: Container(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.bottomCenter,
+ end: Alignment.topCenter,
+ colors: [
+ Colors.black.withOpacity(0.9),
+ Colors.black.withOpacity(0.5),
+ Colors.transparent,
+ ],
+ stops: const [0.0, 0.6, 1.0],
+ ),
+ ),
+ padding: const EdgeInsets.fromLTRB(10, 28, 10, 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ widget.program.title,
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: Colors.white,
+ fontWeight: FontWeight.w600,
+ shadows: [
+ const Shadow(
+ color: Colors.black87,
+ blurRadius: 4,
+ offset: Offset(0, 1),
+ )
+ ],
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ if (widget.program.description.isNotEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: Text(
+ widget.program.description,
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: Colors.white60,
+ fontSize: 10,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ if (progress > 0)
+ Padding(
+ padding: const EdgeInsets.only(top: 6),
+ child: Row(
+ children: [
+ Expanded(
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(2),
+ child: LinearProgressIndicator(
+ value: progress,
+ backgroundColor: Colors.white.withOpacity(0.15),
+ valueColor: AlwaysStoppedAnimation(accentColor),
+ minHeight: 4,
+ ),
+ ),
+ ),
+ const SizedBox(width: 6),
+ Text(
+ '${(progress * 100).round()}%',
+ style: theme.textTheme.bodySmall?.copyWith(
+ color: Colors.white54,
+ fontSize: 9,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _emptyPosterFallback(ThemeData theme) {
+ return Container(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ Colors.grey.shade900,
+ Colors.grey.shade800,
+ ],
+ ),
+ ),
+ child: Center(
+ child: FutureBuilder(
+ future: _iconFuture,
+ builder: (context, snapshot) {
+ if (snapshot.hasData) {
+ return Opacity(
+ opacity: 0.4,
+ child: Image.memory(snapshot.data!, width: 48, height: 48),
+ );
+ }
+ return Icon(
+ Icons.play_circle_outline,
+ size: 48,
+ color: Colors.white.withOpacity(0.15),
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/focus_aware_app_bar.dart b/lib/widgets/focus_aware_app_bar.dart
index 6b3e3f8d..3ae97df6 100644
--- a/lib/widgets/focus_aware_app_bar.dart
+++ b/lib/widgets/focus_aware_app_bar.dart
@@ -1,4 +1,8 @@
import 'package:flauncher/widgets/settings/settings_panel.dart';
+import 'package:flauncher/widgets/settings/inputs_panel.dart';
+import 'package:flauncher/widgets/settings/notifications_panel.dart';
+import 'package:flauncher/providers/tv_inputs_service.dart';
+import 'package:flauncher/providers/notifications_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -24,16 +28,26 @@ class FocusAwareAppBarState extends State
{
bool focused = false;
late FocusNode _settingsFocusNode;
+ late FocusNode _inputsFocusNode;
+ late FocusNode _notificationsFocusNode;
+
+ FocusNode get settingsFocusNode => _settingsFocusNode;
+ FocusNode get inputsFocusNode => _inputsFocusNode;
+ FocusNode get notificationsFocusNode => _notificationsFocusNode;
@override
void initState() {
super.initState();
_settingsFocusNode = FocusNode();
+ _inputsFocusNode = FocusNode();
+ _notificationsFocusNode = FocusNode();
}
@override
void dispose() {
_settingsFocusNode.dispose();
+ _inputsFocusNode.dispose();
+ _notificationsFocusNode.dispose();
super.dispose();
}
@@ -80,6 +94,69 @@ class FocusAwareAppBarState extends State
focusNode: _settingsFocusNode,
onPressed: () => showDialog(context: context, builder: (_) => const SettingsPanel()),
),
+ Selector(
+ selector: (_, settings) => settings.showInputsWidgetInStatusBar,
+ builder: (context, showInputs, _) => showInputs
+ ? Consumer(
+ builder: (context, tvInputsService, _) {
+ if (tvInputsService.hasInputs) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const SizedBox(width: 16),
+ _FocusableIconButton(
+ icon: Icons.tv_outlined,
+ focusNode: _inputsFocusNode,
+ onPressed: () => showDialog(
+ context: context,
+ builder: (_) => const InputsPanel(),
+ ),
+ ),
+ ],
+ );
+ }
+ return const SizedBox.shrink();
+ },
+ )
+ : const SizedBox.shrink(),
+ ),
+ Selector(
+ selector: (_, settings) => (
+ settings.showNotificationsWidgetInStatusBar,
+ settings.autoHideNotificationsWidget
+ ),
+ builder: (context, settingsState, _) {
+ final (showNotifications, autoHide) = settingsState;
+ return showNotifications
+ ? Consumer