diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml
index 87c8e4fb67..73017f1b61 100644
--- a/.github/ISSUE_TEMPLATE/bug-issue.yml
+++ b/.github/ISSUE_TEMPLATE/bug-issue.yml
@@ -1,120 +1,61 @@
-name: 🐞 Bug report
-description: Report a very clearly broken issue.
-title: 'bug:
'
-labels: [bug]
-body:
- - type: markdown
- attributes:
- value: |
- # ReVanced Manager bug report
-
- Important to note that your issue may have already been reported before. Please check for existing issues [here](https://github.com/revanced/revanced-manager/labels/bug).
-
- - type: dropdown
- attributes:
- label: Type
- options:
- - Error while running the manager
- - Error at runtime
- - Cosmetic
- - Other
- validations:
- required: true
- - type: textarea
- attributes:
- label: Bug description
- description: How did you find the bug? Any additional details that might help?
- validations:
- required: true
- - type: textarea
- attributes:
- label: Steps to reproduce
- description: Add the steps to reproduce this bug, including your environment.
- placeholder: Step 1. Download some files. Step 2. ...
- validations:
- required: true
- - type: textarea
- attributes:
- label: Android version
- description: Android version used.
- validations:
- required: true
- - type: textarea
- attributes:
- label: Manager version
- description: Manager version used.
- validations:
- required: true
- - type: textarea
- attributes:
- label: Target package name
- description: App you tried to patch.
- validations:
- required: true
- - type: textarea
- attributes:
- label: Target package version.
- description: Version of the app you tried to patch.
- validations:
- required: true
- - type: dropdown
- attributes:
- label: Installation type
- options:
- - Non-root
- - Root
- validations:
- required: true
- - type: textarea
- attributes:
- label: Patches selected.
- description: Patches you selected for the app.
- validations:
- required: true
- - type: textarea
- attributes:
- label: Device logs (exported using Manager settings).
- description: Please copy and paste any relevant log output. This will be automatically formatted into code, so there is no need for backticks.
- render: shell
- validations:
- required: true
- - type: textarea
- attributes:
- label: Installer logs (exported using Installer menu option) [unneeded if the issue is not during patching].
- description: Please copy and paste any relevant log output. This will be automatically formatted into code, so there is no need for backticks.
- render: shell
- validations:
- required: false
- - type: textarea
- attributes:
- label: Screenshots or video
- description: Add screenshots or videos that show the bug here.
- placeholder: Drag and drop the screenshots/videos into this box.
- validations:
- required: false
- - type: textarea
- attributes:
- label: Solution
- description: If applicable, add a possible solution.
- validations:
- required: false
- - type: textarea
- attributes:
- label: Additional context
- description: Add additional context here.
- validations:
- required: false
- - type: checkboxes
- id: acknowledgments
- attributes:
- label: Acknowledgments
- description: Your issue will be closed if you haven't done these steps.
- options:
- - label: I have searched the existing issues; this is new and no duplicate or related to another open issue.
- required: true
- - label: I have written a short but informative title.
- required: true
- - label: I properly filled out all of the requested information in this issue.
- required: true
- - label: The issue is solely related to ReVanced Manager and not caused by patches.
- required: true
+name: 🐞 Bug report
+description: Create a new bug report.
+title: 'bug: '
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ # ReVanced Manager bug report
+
+ Please check for existing issues [here](https://github.com/revanced/revanced-manager/labels/bug) before creating a new one.
+ - type: textarea
+ attributes:
+ label: Bug description
+ description: |
+ - Describe your bug in detail
+ - Add steps to reproduce the bug if possible (Step 1. Download some files. Step 2. ...)
+ - Add images and videos if possible
+ - List selected patches if applicable
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Version of ReVanced Manager and version & name of application you tried to patch
+ validations:
+ required: true
+ - type: dropdown
+ attributes:
+ label: Installation type
+ options:
+ - Non-root
+ - Root
+ validations:
+ required: false
+ - type: textarea
+ attributes:
+ label: Device logs
+ description: Export logs in ReVanced Manager settings.
+ render: shell
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Patcher logs
+ description: Export logs in "Patcher" screen.
+ render: shell
+ validations:
+ required: false
+ - type: checkboxes
+ attributes:
+ label: Acknowledgements
+ description: Your issue will be closed if you don't follow the checklist below!
+ options:
+ - label: This request is not a duplicate of an existing issue.
+ required: true
+ - label: I have chosen an appropriate title.
+ required: true
+ - label: All requested information has been provided properly.
+ required: true
+ - label: The issue is solely related to the ReVanced Manager
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/feature-issue.yml b/.github/ISSUE_TEMPLATE/feature-issue.yml
index 8df4eab2d7..ca76ef0020 100644
--- a/.github/ISSUE_TEMPLATE/feature-issue.yml
+++ b/.github/ISSUE_TEMPLATE/feature-issue.yml
@@ -1,52 +1,42 @@
-name: ⭐ Feature request
-description: Create a detailed feature request.
-title: 'feat: '
-labels: [feature-request]
-body:
- - type: dropdown
- attributes:
- label: Type
- options:
- - Functionality
- - Cosmetic
- - Other
- validations:
- required: true
- - type: textarea
- attributes:
- label: Issue
- description: What is the current problem. Why does it require a feature request?
- validations:
- required: true
- - type: textarea
- attributes:
- label: Feature
- description: Describe your feature in detail. How does it solve the issue?
- validations:
- required: true
- - type: textarea
- attributes:
- label: Motivation
- description: Why should your feature should be considered?
- validations:
- required: true
- - type: textarea
- attributes:
- label: Additional context
- description: Add additional context here.
- validations:
- required: false
- - type: checkboxes
- id: acknowledgements
- attributes:
- label: Acknowledgements
- description: Your issue will be closed if you haven't done these steps.
- options:
- - label: I have searched the existing issues and this is a new and no duplicate or related to another open issue.
- required: true
- - label: I have written a short but informative title.
- required: true
- - label: I filled out all of the requested information in this issue properly.
- required: true
- - label: The issue is related solely to the ReVanced Manager
- required: true
+name: ⭐ Feature request
+description: Create a new feature request.
+title: 'feat: '
+labels: [feature request]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ # ReVanced Manager feature request
+
+ Please check for existing feature requests [here](https://github.com/revanced/revanced-manager/labels/bug) before creating a new one.
+ - type: textarea
+ attributes:
+ label: Feature description
+ description: Describe your feature in detail.
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Motivation
+ description: Explain why the lack of it is a problem.
+ validations:
+ required: true
+ - type: textarea
+ attributes:
+ label: Additional context
+ description: In case there is something else you want to add.
+ validations:
+ required: false
+ - type: checkboxes
+ attributes:
+ label: Acknowledgements
+ description: Your issue will be closed if you don't follow the checklist below!
+ options:
+ - label: This request is not a duplicate of an existing issue.
+ required: true
+ - label: I have chosen an appropriate title.
+ required: true
+ - label: All requested information has been provided properly.
+ required: true
+ - label: The issue is solely related to the ReVanced Manager
+ required: true
diff --git a/.github/config.yaml b/.github/config.yaml
index 650941e517..aaeba6e2da 100644
--- a/.github/config.yaml
+++ b/.github/config.yaml
@@ -1,2 +1,2 @@
firstPRMergeComment: >
- Thank you for contributing to ReVanced. Join us on [Discord](https://revanced.app/discord) if you want to receive a contributor role.
\ No newline at end of file
+ ❤️ Thank you for contributing to ReVanced Manager. Join us on [Discord](https://revanced.app/discord) if you want to receive a contributor role.
diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml
deleted file mode 100644
index e3f0f0638b..0000000000
--- a/.github/workflows/analyze.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-name: Analyze Code
-
-on:
- push:
- branches: [ "dev" ]
- paths:
- - "**.dart"
- - ".github/workflows/analyze.yml"
- pull_request:
- branches: [ "main", "dev" ]
- types:
- - opened
- - reopened
- - synchronize
- - ready_for_review
- paths:
- - "**.dart"
- - ".github/workflows/analyze.yml"
-
-jobs:
- build:
- name: "Static analysis & format check"
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v3
- - name: Setup Flutter
- uses: subosito/flutter-action@v2
- with:
- channel: 'stable'
- cache: true
- - name: Install Flutter dependencies
- run: flutter pub get
- - name: Generate files with Builder
- run: flutter packages pub run build_runner build --delete-conflicting-outputs
- - name: Analyze code
- uses: ValentinVignal/action-dart-analyze@v0.15
- with:
- fail-on: warning
diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml
index 0bb9bdb11f..4ca4a56580 100644
--- a/.github/workflows/pr-build.yml
+++ b/.github/workflows/pr-build.yml
@@ -1,45 +1,44 @@
-name: PR Build
+name: Build pull request
on:
pull_request:
- paths:
+ paths:
- ".github/workflows/pr-build.yml"
- - "android/**"
- - "assets/**"
- - "lib/**"
-
+ - "app/**"
+ - "gradle/**"
+ - "*.properties"
+ - ".kts"
+
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
- with:
- # Make sure the release step uses its own credentials:
- # https://github.com/cycjimmy/semantic-release-action#private-packages
- persist-credentials: false
- fetch-depth: 0
- - name: Setup JDK
- uses: actions/setup-java@v3
- with:
- java-version: '11'
- distribution: 'zulu'
- - name: Setup Flutter
- uses: subosito/flutter-action@v2
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
with:
- channel: 'stable'
- cache: true
- - name: Install Flutter dependencies
- run: flutter pub get
- - name: Generate files with Builder
- run: flutter packages pub run build_runner build --delete-conflicting-outputs
- - name: Build with Flutter
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Build with Gradle
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: flutter build apk --debug
+ run: ./gradlew assembleRelease --no-daemon -PnoProguard -PsignAsDebug
+
+ - name: Set env
+ run: echo "COMMIT_HASH=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
+
+ - name: Add hash to APK
+ run: mv app/build/outputs/apk/release/app-release.apk revanced-manager-${{ env.COMMIT_HASH }}.apk
+
- name: Upload build
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: revanced-manager
- path: build/app/outputs/flutter-apk/app-debug.apk
+ path: revanced-manager-${{ env.COMMIT_HASH }}.apk
diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml
index 4b414eb7ef..c057a644ca 100644
--- a/.github/workflows/release-build.yml
+++ b/.github/workflows/release-build.yml
@@ -1,4 +1,4 @@
-name: "Release Build"
+name: Release Build
on:
push:
@@ -6,45 +6,44 @@ on:
- "v*"
jobs:
- release:
+ build:
+ name: Build
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- - name: Set up JDK 11
- uses: actions/setup-java@v3
- with:
- java-version: "11"
- distribution: "zulu"
- - uses: subosito/flutter-action@v2
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
with:
- channel: "stable"
- - name: Set up Flutter
- run: flutter pub get
- - name: Generate files with Builder
- run: flutter packages pub run build_runner build --delete-conflicting-outputs
- - name: Build with Flutter
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Build with Gradle
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
- SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
- SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_KEYSTORE_PASSWORD }}
- run: flutter build apk
+ run: ./gradlew assembleRelease --no-daemon
+
- name: Sign APK
id: sign_apk
uses: ilharp/sign-android-release@v1
with:
- releaseDir: build/app/outputs/apk/release
+ releaseDir: ./app/build/outputs/apk/release/
signingKey: ${{ secrets.SIGNING_KEYSTORE }}
keyStorePassword: ${{ secrets.SIGNING_KEYSTORE_PASSWORD }}
keyAlias: ${{ secrets.SIGNING_KEY_ALIAS }}
keyPassword: ${{ secrets.SIGNING_KEY_PASSWORD }}
+
- name: Add version to APK
- run: mv ${{steps.sign_apk.outputs.signedFile}} revanced-manager-${{ env.RELEASE_VERSION }}.apk
+ run: mv ${{ steps.sign_apk.outputs.signedFile }} revanced-manager-${{ env.RELEASE_VERSION }}.apk
+
- name: Publish release APK
uses: "marvinpinto/action-automatic-releases@latest"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: false
- files: revanced-manager-${{ env.RELEASE_VERSION }}.apk
\ No newline at end of file
+ files: revanced-manager-${{ env.RELEASE_VERSION }}.apk
diff --git a/.github/workflows/update-documentation.yml b/.github/workflows/update-documentation.yml
index 77097e2fe6..541a7aa5b5 100644
--- a/.github/workflows/update-documentation.yml
+++ b/.github/workflows/update-documentation.yml
@@ -11,7 +11,7 @@ jobs:
name: Dispatch event to documentation repository
if: github.ref == 'refs/heads/main'
steps:
- - uses: peter-evans/repository-dispatch@v2
+ - uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.DOCUMENTATION_REPO_ACCESS_TOKEN }}
repository: revanced/revanced-documentation
diff --git a/.gitignore b/.gitignore
index 2f30200232..c0af92ebad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,144 +1,12 @@
-# Miscellaneous
-*.class
-*.lock
-*.log
-*.pyc
-*.swp
-.DS_Store
-.atom/
-.buildlog/
-.history
-.svn/
-
-# IntelliJ related
*.iml
-*.ipr
-*.iws
-.idea/
-
-# Visual Studio Code related
-.classpath
-.project
-.settings/
-
-# Flutter repo-specific
-/bin/cache/
-/bin/mingit/
-/dev/benchmarks/mega_gallery/
-/dev/bots/.recipe_deps
-/dev/bots/android_tools/
-/dev/docs/doc/
-/dev/docs/flutter.docs.zip
-/dev/docs/lib/
-/dev/docs/pubspec.yaml
-/dev/integration_tests/**/xcuserdata
-/dev/integration_tests/**/Pods
-/packages/flutter/coverage/
-version
-
-# packages file containing multi-root paths
-.packages.generated
-
-# Flutter/Dart/Pub related
-**/doc/api/
-**/*.g.dart
-**/*.locator.dart
-**/*.router.dart
-.dart_tool/
-.flutter-plugins
-.flutter-plugins-dependencies
-**/generated_plugin_registrant.dart
-.packages
-.pub-cache/
-.pub/
-build/
-flutter_*.png
-linked_*.ds
-unlinked.ds
-unlinked_spec.ds
-
-# Android related
-**/android/**/gradle-wrapper.jar
-**/android/.gradle
-**/android/captures/
-**/android/gradlew
-**/android/gradlew.bat
-**/android/local.properties
-**/android/**/GeneratedPluginRegistrant.java
-**/android/key.properties
-*.jks
-
-# iOS/XCode related
-**/ios/**/*.mode1v3
-**/ios/**/*.mode2v3
-**/ios/**/*.moved-aside
-**/ios/**/*.pbxuser
-**/ios/**/*.perspectivev3
-**/ios/**/*sync/
-**/ios/**/.sconsign.dblite
-**/ios/**/.tags*
-**/ios/**/.vagrant/
-**/ios/**/DerivedData/
-**/ios/**/Icon?
-**/ios/**/Pods/
-**/ios/**/.symlinks/
-**/ios/**/profile
-**/ios/**/xcuserdata
-**/ios/.generated/
-**/ios/Flutter/.last_build_id
-**/ios/Flutter/App.framework
-**/ios/Flutter/Flutter.framework
-**/ios/Flutter/Flutter.podspec
-**/ios/Flutter/Generated.xcconfig
-**/ios/Flutter/app.flx
-**/ios/Flutter/app.zip
-**/ios/Flutter/flutter_assets/
-**/ios/Flutter/flutter_export_environment.sh
-**/ios/ServiceDefinitions.json
-**/ios/Runner/GeneratedPluginRegistrant.*
-
-# macOS related
-**/macos/Flutter/GeneratedPluginRegistrant.swift
-**/macos/Flutter/Flutter-Debug.xcconfig
-**/macos/Flutter/Flutter-Release.xcconfig
-**/macos/Flutter/Flutter-Profile.xcconfig
-
-# Windows related
-**/windows/flutter/ephemeral/
-**/windows/**/*.suo
-**/windows/**/*.user
-**/windows/**/*.userosscache
-**/windows/**/*.sln.docstates
-**/windows/x64/
-**/windows/x86/
-**/windows/**/*.[Cc]ache
-**/windows/**/!*.[Cc]ache/
-
-# Web related
-lib/generated_plugin_registrant.dart
-
-# Coverage
-coverage/
-
-# Symbolication related
-app.*.symbols
-
-# Obfuscation related
-app.*.map.json
-
-# Exceptions to above rules.
-!**/ios/**/default.mode1v3
-!**/ios/**/default.mode2v3
-!**/ios/**/default.pbxuser
-!**/ios/**/default.perspectivev3
-!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
-!/dev/ci/**/Gemfile.lock
-
-# Firebase related
-.firebase
-
-# Dependency directories
-node_modules/
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
-# FVM
-.fvm
\ No newline at end of file
+.kotlin/
diff --git a/.metadata b/.metadata
deleted file mode 100644
index e7c1001054..0000000000
--- a/.metadata
+++ /dev/null
@@ -1,45 +0,0 @@
-# This file tracks properties of this Flutter project.
-# Used by Flutter tool to assess capabilities and perform upgrades etc.
-#
-# This file should be version controlled.
-
-version:
- revision: 85684f9300908116a78138ea4c6036c35c9a1236
- channel: stable
-
-project_type: app
-
-# Tracks metadata for the flutter migrate command
-migration:
- platforms:
- - platform: root
- create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- - platform: android
- create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- - platform: ios
- create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- - platform: linux
- create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- - platform: macos
- create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- - platform: web
- create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- - platform: windows
- create_revision: 85684f9300908116a78138ea4c6036c35c9a1236
- base_revision: 85684f9300908116a78138ea4c6036c35c9a1236
-
- # User provided section
-
- # List of Local paths (relative to this file) that should be
- # ignored by the migrate tool.
- #
- # Files that are not part of the templates will be ignored by default.
- unmanaged_files:
- - 'lib/main.dart'
- - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/.releaserc b/.releaserc
deleted file mode 100644
index a10173d648..0000000000
--- a/.releaserc
+++ /dev/null
@@ -1,75 +0,0 @@
-{
- "branches": [
- "main",
- {
- "name": "dev",
- "prerelease": true
- }
- ],
- "plugins": [
- "semantic-release-export-data",
- "@semantic-release/commit-analyzer",
- [
- "@semantic-release/release-notes-generator",
- {
- "presetConfig": {
- "types": [
- {
- "type": "build",
- "section": "Dependency Updates"
- },
- {
- "type": "chore",
- "section": "Other Changes",
- "hidden": false
- },
- {
- "type": "perf",
- "section": "Performance Improvements",
- "hidden": false
- },
- {
- "type": "refactor",
- "section": "Code Improvements",
- "hidden": false
- }
- ]
- }
- }
- ],
- "@semantic-release/changelog",
- "semantic-release-flutter-plugin",
- [
- "@semantic-release/git",
- {
- "assets": [
- "CHANGELOG.md",
- "pubspec.yaml"
- ]
- }
- ],
- [
- "@semantic-release/github",
- {
- "assets": [
- {
- "path": "build/app/outputs/apk/release/revanced-manager-*.apk"
- }
- ],
- "successComment": false
- }
- ],
- [
- "@saithodev/semantic-release-backmerge",
- {
- "backmergeBranches": [
- {
- "from": "main",
- "to": "dev"
- }
- ],
- "clearWorkspace": true
- }
- ]
- ]
-}
diff --git a/.run/main.dart.run.xml b/.run/main.dart.run.xml
deleted file mode 100644
index 4767aff814..0000000000
--- a/.run/main.dart.run.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
deleted file mode 100644
index a09c28f5e6..0000000000
--- a/.vscode/tasks.json
+++ /dev/null
@@ -1,91 +0,0 @@
-{
- "version": "2.0.0",
- "tasks": [
- {
- "label": "Generate (Builder)",
- "type": "shell",
- "command": "flutter packages pub run build_runner build --delete-conflicting-outputs",
- "problemMatcher": []
- },
- {
- "label": "Build (Android)",
- "type": "shell",
- "command": "flutter build apk",
- "problemMatcher": [],
- "group": {
- "kind": "build",
- "isDefault": true
- }
- },
- {
- "label": "Install (Android)",
- "type": "shell",
- "command": "adb install build\\app\\outputs\\flutter-apk\\app-release.apk",
- "problemMatcher": []
- },
- {
- "label": "Clean (Flutter)",
- "type": "shell",
- "command": "flutter clean && flutter pub get",
- "problemMatcher": []
- },
- {
- "label": "Clean (Builder)",
- "type": "shell",
- "command": "flutter packages pub run build_runner clean",
- "problemMatcher": []
- },
- {
- "label": "Build all (Android)",
- "dependsOrder": "sequence",
- "dependsOn": [
- "Generate (Builder)",
- "Build (Android)"
- ],
- "problemMatcher": []
- },
- {
- "label": "Clean all",
- "dependsOrder": "sequence",
- "dependsOn": [
- "Clean (Flutter)",
- "Clean (Builder)"
- ],
- "problemMatcher": []
- },
- {
- "label": "Clean all & Build all (Android)",
- "dependsOrder": "sequence",
- "dependsOn": [
- "Clean all",
- "Build all (Android)"
- ],
- "problemMatcher": []
- },
- {
- "label": "Clean all & Install (Android)",
- "dependsOrder": "sequence",
- "dependsOn": [
- "Clean all",
- "Build all (Android)",
- "Install (Android)",
- ],
- "problemMatcher": []
- },
- {
- "label": "Build & Install (Android)",
- "dependsOrder": "sequence",
- "dependsOn": [
- "Build (Android)",
- "Install (Android)"
- ],
- "problemMatcher": []
- },
- {
- "label": "Validate translations",
- "type": "shell",
- "command": "flutter pub run flutter_i18n diff en.json pt.json",
- "problemMatcher": []
- }
- ]
-}
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 8b13789179..0000000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000..a230c8d4f0
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Continuing the legacy of Vanced
+
+
+# 👋 Contribution guidelines
+
+Welcome to contribution guidelines, this document contains
+everything you'll need to contribute to ReVanced Manager (and might even possibly apply to our other project like ReVanced Patches!)
+
+There are many ways to contribute like writing docs and code, opening issues,
+helping people out in our community, translating or sponsoring our project, etc.
+
+## 📖 Resources to help you get started
+
+* The [documentation](/docs/developer/README.md) provides steps to build ReVanced Manager from source
+* Our [backlog](https://github.com/orgs/ReVanced/projects/12) is where we keep track of what we're working on
+* [Issues](https://github.com/ReVanced/revanced-manager/issues) are where we keep track of bugs and feature requests
+
+## 🙏 Submitting a feature request
+
+Features can be requested by opening an issue using the
+[feature request issue template](https://github.com/ReVanced/revanced-manager/issues/new?assignees=&labels=feature-request&projects=&template=feature-issue.yml&title=feat%3A+%3Ctitle%3E).
+
+> [!NOTE]
+> Requests can be accepted or rejected at the discretion of maintainers of ReVanced Manager.
+> Good motivation has to be provided for a request to be accepted.
+
+## 🐞 Submitting a bug report
+
+If you encounter a bug while using the ReVanced Manager app, open an issue using the
+[bug report issue template](https://github.com/ReVanced/revanced-manager/issues/new?assignees=&labels=bug&projects=&template=bug-issue.yml&title=bug%3A+%3Ctitle%3E).
+
+## 🌐 Submitting translations
+
+You can contribute translations at translate.revanced.app
+
+> [!TIP]
+> * Try to keep the translated text roughly the same length as the original.
+> * Consider opting for gender-neutral words for language with variations of words based on gender.
+
+## 🧑💻 Developing for ReVanced Manager
+
+See the guidelines for developing for ReVanced Manager [here](/docs/developer/README.md)
+
+## 🔒 Submitting a vulnerability
+
+See the guideline for reporting security vulnerability [here](/SECURITY.md)
+
+## 🤚 I don't want to do any of that but I want to contribute either way
+
+You can still contribute by spreading positive word about us or if you'd like to sponsor us, checkout https://revanced.app/donate
+to learn more about how you can sponsor via GitHub, Open Collective, and cryptocurrencies.
+
+❤️ Thank you for considering contributing to ReVanced Manager,
+ReVanced
diff --git a/README.md b/README.md
index 07ed4a791f..95db690a47 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Continuing the legacy of Vanced
+
+
# 💊 ReVanced Manager
-The official ReVanced Manager based on Flutter.
+[](https://github.com/ReVanced/revanced-manager/actions/workflows/release.yml)
+[](#️-license)
+
+Application to use ReVanced on Android
+
+## ❓ About
+
+ReVanced Manager is an application that uses [ReVanced Patcher](https://github.com/revanced/revanced-patcher) to patch Android apps.
+
+## 💪 Features
+
+Some of the features ReVanced Manager provides are:
+
+- 💉 **Patch apps**: Apply any patch of your choice to Android apps
+- 📱 **Portable**: ReVanced Patcher that fits in your pocket
+- 🤗 **Simple UI**: Quickly understand the ins and outs of ReVanced Manager
+- 🛠️ **Customization**: Configurable API, custom sources, language, signing keystore, theme and more
## 🔽 Download
-To download latest Manager, go [here](https://github.com/revanced/revanced-manager/releases/latest) and install the provided APK file.
-## 📝 Prerequisites
-1. Android 8 or higher
-2. Does not work on some armv7 devices
+You can get the most recent version of ReVanced Manager by downloading from
+the [ReVanced site](https://revanced.app/download).
+
+Learn how to use ReVanced Manager by following the [documentation](/docs).
+
+## 📚 Everything else
+
+### 📙 Contributing
-## 🔴 Issues
-For suggestions and bug reports, open an issue [here](https://github.com/revanced/revanced-manager/issues/new/choose).
+Thank you for considering contributing to ReVanced Manager.
-## 💭 Discussion
-If you wish to discuss the Manager, a thread has been made under the [#development](https://discord.com/channels/952946952348270622/1002922226443632761) channel in the Discord server, please note that this thread may be temporary and may be removed in the future.
+The [contribution guidelines](CONTRIBUTING.md) provides information you'll need to open an issue, develop for ReVanced Manager and translations.
+### 📄 Documentation
-## 🌐 Translation
-[](https://crowdin.com/project/revanced)
+You can find the documentation for ReVanced Manager [here](/docs).
-If you wish to translate ReVanced Manager, we're accepting translations on [Crowdin](https://translate.revanced.app)
+## ⚖️ License
-## 🛠️ Building Manager from source
-1. Setup flutter environment for your [platform](https://docs.flutter.dev/get-started/install)
-2. Clone the repository locally
-3. Add your github token in gradle.properties like [this](/docs/4_building.md)
-4. Open the project in terminal
-5. Run `flutter pub get` in terminal
-6. Then `flutter packages pub run build_runner build --delete-conflicting-outputs` (Must be done on each git pull)
-7. To build release apk run `flutter build apk`
+ReVanced Manager is licensed under the GPLv3 license. Please see the [license file](LICENSE) for more information.
+[tl;dr](https://www.tldrlegal.com/license/gnu-general-public-license-v3-gpl-3) you may copy, distribute and modify ReVanced Manager as long as you track changes/dates in source files.
+Any modifications to ReVanced Manager must also be made available under the GPL, along with build & install instructions.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000000..4833f35725
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Continuing the legacy of Vanced
+
+
+# 🔒 Security Policy
+
+This document describes how to report security vulnerabilities for ReVanced Manager.
+
+## 🚨 Reporting a Vulnerability
+
+Please open an issue in our [advisory tracker](https://github.com/ReVanced/revanced-manager/security/advisories/new)
+or reach out privately to us on [Discord](https://discord.gg/revanced).
+
+If a vulnerability is confirmed and accepted, they will be published and
+you can join our [Discord](https://discord.gg/revanced) server to receive a
+special contributor role.
+
+### ⏳ Supported Versions
+
+| Version | Branch | Supported |
+|------------------------------------------|---------------|--------------------|
+| ![Latest stable release][LatestRelBadge] | `main` | :white_check_mark: |
+| ![Latest version][LatestVerBadge] | `dev` | :white_check_mark: |
+| ![Latest version][LatestVerBadge] | `compose-dev` | :white_check_mark: |
+
+[LatestRelBadge]: https://img.shields.io/github/v/release/ReVanced/revanced-manager?style=for-the-badge "Latest stable release"
+[LatestVerBadge]: https://img.shields.io/badge/version-latest-brightgreen?style=for-the-badge "Latest version"
diff --git a/analysis_options.yaml b/analysis_options.yaml
deleted file mode 100644
index 1c02c24c3a..0000000000
--- a/analysis_options.yaml
+++ /dev/null
@@ -1,163 +0,0 @@
-# This file configures the analyzer, which statically analyzes Dart code to
-# check for errors, warnings, and lints.
-#
-# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
-# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
-# invoked from the command line by running `flutter analyze`.
-
-# The following line activates a set of recommended lints for Flutter apps,
-# packages, and plugins designed to encourage good coding practices.
-include: package:flutter_lints/flutter.yaml
-
-analyzer:
- exclude:
- - lib/app/app.locator.dart
- - lib/app/app.router.dart
- - lib/models/patch.g.dart
- - lib/models/patched_application.g.dart
-
-linter:
- rules:
- - always_declare_return_types
- - require_trailing_commas
- - always_put_control_body_on_new_line
- - always_require_non_null_named_parameters
- - always_use_package_imports # we do this commonly
- - annotate_overrides
- - avoid_bool_literals_in_conditional_expressions
- - avoid_double_and_int_checks
- - avoid_empty_else
- - avoid_equals_and_hash_code_on_mutable_classes
- - avoid_escaping_inner_quotes
- - avoid_field_initializers_in_const_classes
- - avoid_function_literals_in_foreach_calls
- - avoid_implementing_value_types
- - avoid_init_to_null
- - avoid_js_rounded_ints
- - avoid_null_checks_in_equality_operators
- - avoid_print
- - avoid_redundant_argument_values
- - avoid_relative_lib_imports
- - avoid_renaming_method_parameters
- - avoid_return_types_on_setters
- - avoid_returning_null
- - avoid_returning_null_for_future
- - avoid_returning_null_for_void
- - avoid_setters_without_getters
- - avoid_shadowing_type_parameters
- - avoid_single_cascade_in_expression_statements
- - avoid_type_to_string
- - avoid_types_as_parameter_names
- - avoid_unnecessary_containers
- - avoid_void_async
- - avoid_web_libraries_in_flutter # we use web libraries in web-specific code, and our tests prevent us from using them elsewhere
- - await_only_futures
- - camel_case_extensions
- - camel_case_types
- - cancel_subscriptions
- - cast_nullable_to_non_nullable
- - close_sinks # not reliable enough
- - control_flow_in_finally
- - curly_braces_in_flow_control_structures
- - depend_on_referenced_packages
- - deprecated_consistency
- - directives_ordering
- - empty_catches
- - empty_constructor_bodies
- - empty_statements
- - eol_at_end_of_file
- - exhaustive_cases
- - file_names
- - flutter_style_todos
- - hash_and_equals
- - implementation_imports
- - collection_methods_unrelated_type
- - leading_newlines_in_multiline_strings
- - library_names
- - library_prefixes
- - library_private_types_in_public_api
- - missing_whitespace_between_adjacent_strings
- - no_adjacent_strings_in_list
- - no_duplicate_case_values
- - no_logic_in_create_state
- - non_constant_identifier_names
- - noop_primitive_operations
- - null_check_on_nullable_type_parameter
- - null_closures
- - overridden_fields
- - package_api_docs
- - package_names
- - package_prefixed_library_names
- - prefer_adjacent_string_concatenation
- - prefer_asserts_in_initializer_lists
- - prefer_collection_literals
- - prefer_conditional_assignment
- - prefer_const_constructors
- - prefer_const_constructors_in_immutables
- - prefer_const_declarations
- - prefer_const_literals_to_create_immutables
- - prefer_contains
- - prefer_final_fields
- - prefer_final_in_for_each
- - prefer_final_locals
- - prefer_for_elements_to_map_fromIterable
- - prefer_foreach
- - prefer_function_declarations_over_variables
- - prefer_generic_function_type_aliases
- - prefer_if_elements_to_conditional_expressions
- - prefer_if_null_operators
- - prefer_initializing_formals
- - prefer_inlined_adds
- - prefer_interpolation_to_compose_strings
- - prefer_is_empty
- - prefer_is_not_empty
- - prefer_is_not_operator
- - prefer_iterable_whereType
- - prefer_mixin # Has false positives, see https://github.com/dart-lang/linter/issues/3018
- - prefer_null_aware_method_calls # "call()" is confusing to people new to the language since it's not documented anywhere
- - prefer_null_aware_operators
- - prefer_single_quotes
- - prefer_spread_collections
- - prefer_typing_uninitialized_variables
- - prefer_void_to_null
- - provide_deprecation_message
- - recursive_getters
- - sized_box_for_whitespace
- - slash_for_doc_comments
- - sort_child_properties_last
- - sort_constructors_first
- - sort_unnamed_constructors_first
- - test_types_in_equals
- - throw_in_finally
- - tighten_type_of_initializing_formals
- - type_init_formals
- - unnecessary_brace_in_string_interps
- - unnecessary_const
- - unnecessary_getters_setters
- - unnecessary_new
- - unnecessary_null_aware_assignments
- - unnecessary_null_checks
- - unnecessary_null_in_if_null_operators
- - unnecessary_nullable_for_final_variable_declarations
- - unnecessary_overrides
- - unnecessary_parenthesis
- - unnecessary_statements
- - unnecessary_string_escapes
- - unnecessary_string_interpolations
- - unnecessary_this
- - unrelated_type_equality_checks
- - unsafe_html
- - use_build_context_synchronously
- - use_full_hex_values_for_flutter_colors
- - use_function_type_syntax_for_parameters
- - use_if_null_to_convert_nulls_to_bools
- - use_is_even_rather_than_modulo
- - use_key_in_widget_constructors
- - use_late_for_private_fields_and_variables
- - use_named_constants
- - use_raw_strings
- - use_rethrow_when_possible
- - use_setters_to_change_properties
- - use_test_throws_matchers
- - valid_regexps
- - void_checks
diff --git a/android/.gitignore b/android/.gitignore
deleted file mode 100644
index 6f568019d3..0000000000
--- a/android/.gitignore
+++ /dev/null
@@ -1,13 +0,0 @@
-gradle-wrapper.jar
-/.gradle
-/captures/
-/gradlew
-/gradlew.bat
-/local.properties
-GeneratedPluginRegistrant.java
-
-# Remember to never publicly share your keystore.
-# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
-key.properties
-**/*.keystore
-**/*.jks
diff --git a/android/Gemfile b/android/Gemfile
deleted file mode 100644
index 7a118b49be..0000000000
--- a/android/Gemfile
+++ /dev/null
@@ -1,3 +0,0 @@
-source "https://rubygems.org"
-
-gem "fastlane"
diff --git a/android/app/build.gradle b/android/app/build.gradle
deleted file mode 100644
index 8663736a80..0000000000
--- a/android/app/build.gradle
+++ /dev/null
@@ -1,92 +0,0 @@
-def localProperties = new Properties()
-def localPropertiesFile = rootProject.file('local.properties')
-if (localPropertiesFile.exists()) {
- localPropertiesFile.withReader('UTF-8') { reader ->
- localProperties.load(reader)
- }
-}
-
-def flutterRoot = localProperties.getProperty('flutter.sdk')
-if (flutterRoot == null) {
- throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
-}
-
-def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
-if (flutterVersionCode == null) {
- flutterVersionCode = '1'
-}
-
-def flutterVersionName = localProperties.getProperty('flutter.versionName')
-if (flutterVersionName == null) {
- flutterVersionName = '1.0'
-}
-
-apply plugin: 'com.android.application'
-apply plugin: 'kotlin-android'
-apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
-
-android {
- compileSdkVersion flutter.compileSdkVersion
- ndkVersion flutter.ndkVersion
-
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_11
- targetCompatibility JavaVersion.VERSION_11
- }
-
- kotlinOptions {
- jvmTarget = '11'
- }
-
- sourceSets {
- main.java.srcDirs += 'src/main/kotlin'
- }
-
- defaultConfig {
- applicationId "app.revanced.manager.flutter"
- minSdkVersion 26
- targetSdkVersion 33
- versionCode flutterVersionCode.toInteger()
- versionName flutterVersionName
- }
-
- buildTypes {
- release {
- resValue "string", "app_name", "ReVanced Manager"
- signingConfig signingConfigs.debug
- ndk {
- abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
- }
- }
- debug {
- shrinkResources false
- minifyEnabled false
- resValue "string", "app_name", "ReVanced Manager Debug"
- applicationIdSuffix ".debug"
- signingConfig signingConfigs.debug
- ndk {
- abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
- }
- }
- }
-
- packagingOptions {
- exclude '/prebuilt/**'
- }
-}
-
-flutter {
- source '../..'
-}
-
-dependencies {
- implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
-
- // ReVanced
- implementation "app.revanced:revanced-patcher:11.0.4"
-
- // Signing & aligning
- implementation("org.bouncycastle:bcpkix-jdk15on:1.70")
- implementation("com.android.tools.build:apksig:7.2.2")
-
-}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
deleted file mode 100644
index 2abfc8e943..0000000000
--- a/android/app/src/debug/AndroidManifest.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
deleted file mode 100644
index 8548324123..0000000000
--- a/android/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/kotlin/app/revanced/manager/flutter/MainActivity.kt b/android/app/src/main/kotlin/app/revanced/manager/flutter/MainActivity.kt
deleted file mode 100644
index 2c8d77169e..0000000000
--- a/android/app/src/main/kotlin/app/revanced/manager/flutter/MainActivity.kt
+++ /dev/null
@@ -1,385 +0,0 @@
-package app.revanced.manager.flutter
-
-import android.os.Build
-import android.os.Handler
-import android.os.Looper
-import androidx.annotation.NonNull
-import app.revanced.manager.flutter.utils.Aapt
-import app.revanced.manager.flutter.utils.aligning.ZipAligner
-import app.revanced.manager.flutter.utils.signing.Signer
-import app.revanced.manager.flutter.utils.zip.ZipFile
-import app.revanced.manager.flutter.utils.zip.structures.ZipEntry
-import app.revanced.patcher.Patcher
-import app.revanced.patcher.PatcherOptions
-import app.revanced.patcher.extensions.PatchExtensions.compatiblePackages
-import app.revanced.patcher.extensions.PatchExtensions.patchName
-import app.revanced.patcher.logging.Logger
-import app.revanced.patcher.util.patch.PatchBundle
-import dalvik.system.DexClassLoader
-import io.flutter.embedding.android.FlutterActivity
-import io.flutter.embedding.engine.FlutterEngine
-import io.flutter.plugin.common.MethodChannel
-import java.io.File
-
-private const val PATCHER_CHANNEL = "app.revanced.manager.flutter/patcher"
-private const val INSTALLER_CHANNEL = "app.revanced.manager.flutter/installer"
-
-class MainActivity : FlutterActivity() {
- private val handler = Handler(Looper.getMainLooper())
- private lateinit var installerChannel: MethodChannel
- private var cancel: Boolean = false
- private var stopResult: MethodChannel.Result? = null
-
- override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
- super.configureFlutterEngine(flutterEngine)
- val mainChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PATCHER_CHANNEL)
- installerChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL)
- mainChannel.setMethodCallHandler { call, result ->
- when (call.method) {
- "runPatcher" -> {
- val patchBundleFilePath = call.argument("patchBundleFilePath")
- val originalFilePath = call.argument("originalFilePath")
- val inputFilePath = call.argument("inputFilePath")
- val patchedFilePath = call.argument("patchedFilePath")
- val outFilePath = call.argument("outFilePath")
- val integrationsPath = call.argument("integrationsPath")
- val selectedPatches = call.argument>("selectedPatches")
- val cacheDirPath = call.argument("cacheDirPath")
- val keyStoreFilePath = call.argument("keyStoreFilePath")
- val keystorePassword = call.argument("keystorePassword")
-
- if (patchBundleFilePath != null &&
- originalFilePath != null &&
- inputFilePath != null &&
- patchedFilePath != null &&
- outFilePath != null &&
- integrationsPath != null &&
- selectedPatches != null &&
- cacheDirPath != null &&
- keyStoreFilePath != null &&
- keystorePassword != null
- ) {
- cancel = false
- runPatcher(
- result,
- patchBundleFilePath,
- originalFilePath,
- inputFilePath,
- patchedFilePath,
- outFilePath,
- integrationsPath,
- selectedPatches,
- cacheDirPath,
- keyStoreFilePath,
- keystorePassword
- )
- } else {
- result.notImplemented()
- }
- }
- "stopPatcher" -> {
- cancel = true
- stopResult = result
- }
- else -> result.notImplemented()
- }
- }
- }
-
- private fun runPatcher(
- result: MethodChannel.Result,
- patchBundleFilePath: String,
- originalFilePath: String,
- inputFilePath: String,
- patchedFilePath: String,
- outFilePath: String,
- integrationsPath: String,
- selectedPatches: List,
- cacheDirPath: String,
- keyStoreFilePath: String,
- keystorePassword: String
- ) {
- val originalFile = File(originalFilePath)
- val inputFile = File(inputFilePath)
- val patchedFile = File(patchedFilePath)
- val outFile = File(outFilePath)
- val integrations = File(integrationsPath)
- val keyStoreFile = File(keyStoreFilePath)
-
- Thread {
- try {
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf(
- "progress" to 0.1,
- "header" to "",
- "log" to "Copying original apk"
- )
- )
- }
-
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
-
- originalFile.copyTo(inputFile, true)
-
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf(
- "progress" to 0.2,
- "header" to "Unpacking apk...",
- "log" to "Unpacking input apk"
- )
- )
- }
-
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
-
- val patcher =
- Patcher(
- PatcherOptions(
- inputFile,
- cacheDirPath,
- Aapt.binary(applicationContext).absolutePath,
- cacheDirPath,
- logger = ManagerLogger()
- )
- )
-
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
-
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf("progress" to 0.3, "header" to "", "log" to "")
- )
- }
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf(
- "progress" to 0.4,
- "header" to "Merging integrations...",
- "log" to "Merging integrations"
- )
- )
- }
-
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
-
- patcher.addIntegrations(listOf(integrations)) {}
-
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
-
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf(
- "progress" to 0.5,
- "header" to "Applying patches...",
- "log" to ""
- )
- )
- }
-
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
-
- val patches = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CUPCAKE) {
- PatchBundle.Dex(
- patchBundleFilePath,
- DexClassLoader(
- patchBundleFilePath,
- cacheDirPath,
- null,
- javaClass.classLoader
- )
- ).loadPatches().filter { patch ->
- (patch.compatiblePackages?.any { it.name == patcher.context.packageMetadata.packageName } == true || patch.compatiblePackages.isNullOrEmpty()) &&
- selectedPatches.any { it == patch.patchName }
- }
- } else {
- TODO("VERSION.SDK_INT < CUPCAKE")
- }
-
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
-
- patcher.addPatches(patches)
- patcher.executePatches().forEach { (patch, res) ->
- if (res.isSuccess) {
- val msg = "Applied $patch"
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf(
- "progress" to 0.5,
- "header" to "",
- "log" to msg
- )
- )
- }
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
- return@forEach
- }
- val msg =
- "Failed to apply $patch: " + "${res.exceptionOrNull()!!.message ?: res.exceptionOrNull()!!.cause!!::class.simpleName}"
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf("progress" to 0.5, "header" to "", "log" to msg)
- )
- }
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
- }
-
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf(
- "progress" to 0.7,
- "header" to "Repacking apk...",
- "log" to "Repacking patched apk"
- )
- )
- }
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
- val res = patcher.save()
- ZipFile(patchedFile).use { file ->
- res.dexFiles.forEach {
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
- file.addEntryCompressData(
- ZipEntry.createWithName(it.name),
- it.stream.readBytes()
- )
- }
- res.resourceFile?.let {
- file.copyEntriesFromFileAligned(
- ZipFile(it),
- ZipAligner::getEntryAlignment
- )
- }
- file.copyEntriesFromFileAligned(
- ZipFile(inputFile),
- ZipAligner::getEntryAlignment
- )
- }
- if(cancel) {
- handler.post { stopResult!!.success(null) }
- return@Thread
- }
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf(
- "progress" to 0.9,
- "header" to "Signing apk...",
- "log" to ""
- )
- )
- }
-
- try {
- Signer("ReVanced", keystorePassword).signApk(
- patchedFile,
- outFile,
- keyStoreFile
- )
- } catch (e: Exception) {
- //log to console
- print("Error signing apk: ${e.message}")
- e.printStackTrace()
- }
-
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf(
- "progress" to 1.0,
- "header" to "Finished!",
- "log" to "Finished!"
- )
- )
- }
- } catch (ex: Throwable) {
- val stack = ex.stackTraceToString()
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf(
- "progress" to -100.0,
- "header" to "Aborted...",
- "log" to "An error occurred! Aborted\nError:\n$stack"
- )
- )
- }
- }
- handler.post { result.success(null) }
- }.start()
- }
-
- inner class ManagerLogger : Logger {
- override fun error(msg: String) {
- handler.post {
- installerChannel
- .invokeMethod(
- "update",
- mapOf("progress" to -1.0, "header" to "", "log" to msg)
- )
- }
- }
-
- override fun warn(msg: String) {
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf("progress" to -1.0, "header" to "", "log" to msg)
- )
- }
- }
-
- override fun info(msg: String) {
- handler.post {
- installerChannel.invokeMethod(
- "update",
- mapOf("progress" to -1.0, "header" to "", "log" to msg)
- )
- }
- }
-
- override fun trace(_msg: String) { /* unused */
- }
- }
-}
diff --git a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/Aapt.kt b/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/Aapt.kt
deleted file mode 100644
index 72198e58fa..0000000000
--- a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/Aapt.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package app.revanced.manager.flutter.utils
-
-import android.content.Context
-import java.io.File
-
-object Aapt {
- fun binary(context: Context): File {
- return File(context.applicationInfo.nativeLibraryDir).resolveAapt()
- }
-}
-
-private fun File.resolveAapt() = resolve(list { _, f -> !File(f).isDirectory && f.contains("aapt") }!!.first())
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/aligning/ZipAligner.kt b/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/aligning/ZipAligner.kt
deleted file mode 100644
index 088aad5993..0000000000
--- a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/aligning/ZipAligner.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package app.revanced.manager.flutter.utils.aligning
-
-import app.revanced.manager.flutter.utils.zip.structures.ZipEntry
-
-internal object ZipAligner {
- private const val DEFAULT_ALIGNMENT = 4
- private const val LIBRARY_ALIGNMENT = 4096
-
- fun getEntryAlignment(entry: ZipEntry): Int? =
- if (entry.compression.toUInt() != 0u) null else if (entry.fileName.endsWith(".so")) LIBRARY_ALIGNMENT else DEFAULT_ALIGNMENT
-}
diff --git a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/signing/Signer.kt b/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/signing/Signer.kt
deleted file mode 100644
index 1e1a08a21c..0000000000
--- a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/signing/Signer.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package app.revanced.manager.flutter.utils.signing
-
-import com.android.apksig.ApkSigner
-import org.bouncycastle.asn1.x500.X500Name
-import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
-import org.bouncycastle.cert.X509v3CertificateBuilder
-import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
-import org.bouncycastle.jce.provider.BouncyCastleProvider
-import org.bouncycastle.operator.ContentSigner
-import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
-import java.io.File
-import java.io.FileInputStream
-import java.io.FileOutputStream
-import java.math.BigInteger
-import java.security.*
-import java.security.cert.X509Certificate
-import java.util.*
-
-internal class Signer(
- private val cn: String, password: String
-) {
- private val passwordCharArray = password.toCharArray()
- private fun newKeystore(out: File) {
- val (publicKey, privateKey) = createKey()
- val privateKS = KeyStore.getInstance("BKS", "BC")
- privateKS.load(null, passwordCharArray)
- privateKS.setKeyEntry("alias", privateKey, passwordCharArray, arrayOf(publicKey))
- privateKS.store(FileOutputStream(out), passwordCharArray)
- }
-
- private fun createKey(): Pair {
- val gen = KeyPairGenerator.getInstance("RSA")
- gen.initialize(2048)
- val pair = gen.generateKeyPair()
- var serialNumber: BigInteger
- do serialNumber =
- BigInteger.valueOf(SecureRandom().nextLong()) while (serialNumber < BigInteger.ZERO)
- val x500Name = X500Name("CN=$cn")
- val builder = X509v3CertificateBuilder(
- x500Name,
- serialNumber,
- Date(System.currentTimeMillis() - 1000L * 60L * 60L * 24L * 30L),
- Date(System.currentTimeMillis() + 1000L * 60L * 60L * 24L * 366L * 30L),
- Locale.ENGLISH,
- x500Name,
- SubjectPublicKeyInfo.getInstance(pair.public.encoded)
- )
- val signer: ContentSigner = JcaContentSignerBuilder("SHA256withRSA").build(pair.private)
- return JcaX509CertificateConverter().getCertificate(builder.build(signer)) to pair.private
- }
-
- fun signApk(input: File, output: File, ks: File) {
- Security.addProvider(BouncyCastleProvider())
-
- if (!ks.exists()) newKeystore(ks)
-
- val keyStore = KeyStore.getInstance("BKS", "BC")
- FileInputStream(ks).use { fis -> keyStore.load(fis, null) }
- val alias = keyStore.aliases().nextElement()
-
- val config = ApkSigner.SignerConfig.Builder(
- cn,
- keyStore.getKey(alias, passwordCharArray) as PrivateKey,
- listOf(keyStore.getCertificate(alias) as X509Certificate)
- ).build()
-
- val signer = ApkSigner.Builder(listOf(config))
- signer.setCreatedBy(cn)
- signer.setInputApk(input)
- signer.setOutputApk(output)
-
- signer.build().sign()
- }
-}
diff --git a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/Extensions.kt b/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/Extensions.kt
deleted file mode 100644
index 3ff0516de5..0000000000
--- a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/Extensions.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-@file:Suppress("unused")
-
-package app.revanced.manager.flutter.utils.zip
-
-import java.io.DataInput
-import java.io.DataOutput
-import java.nio.ByteBuffer
-
-fun UInt.toLittleEndian() =
- (((this.toInt() and 0xff000000.toInt()) shr 24) or ((this.toInt() and 0x00ff0000) shr 8) or ((this.toInt() and 0x0000ff00) shl 8) or (this.toInt() shl 24)).toUInt()
-
-fun UShort.toLittleEndian() = (this.toUInt() shl 16).toLittleEndian().toUShort()
-
-fun UInt.toBigEndian() = (((this.toInt() and 0xff) shl 24) or ((this.toInt() and 0xff00) shl 8)
- or ((this.toInt() and 0x00ff0000) ushr 8) or (this.toInt() ushr 24)).toUInt()
-
-fun UShort.toBigEndian() = (this.toUInt() shl 16).toBigEndian().toUShort()
-
-fun ByteBuffer.getUShort() = this.short.toUShort()
-fun ByteBuffer.getUInt() = this.int.toUInt()
-
-fun ByteBuffer.putUShort(ushort: UShort): ByteBuffer = this.putShort(ushort.toShort())
-fun ByteBuffer.putUInt(uint: UInt): ByteBuffer = this.putInt(uint.toInt())
-
-fun DataInput.readUShort() = this.readShort().toUShort()
-fun DataInput.readUInt() = this.readInt().toUInt()
-
-fun DataOutput.writeUShort(ushort: UShort) = this.writeShort(ushort.toInt())
-fun DataOutput.writeUInt(uint: UInt) = this.writeInt(uint.toInt())
-
-fun DataInput.readUShortLE() = this.readUShort().toBigEndian()
-fun DataInput.readUIntLE() = this.readUInt().toBigEndian()
-
-fun DataOutput.writeUShortLE(ushort: UShort) = this.writeUShort(ushort.toLittleEndian())
-fun DataOutput.writeUIntLE(uint: UInt) = this.writeUInt(uint.toLittleEndian())
diff --git a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/ZipFile.kt b/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/ZipFile.kt
deleted file mode 100644
index 2330938b3b..0000000000
--- a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/ZipFile.kt
+++ /dev/null
@@ -1,176 +0,0 @@
-package app.revanced.manager.flutter.utils.zip
-
-import app.revanced.manager.flutter.utils.zip.structures.ZipEndRecord
-import app.revanced.manager.flutter.utils.zip.structures.ZipEntry
-import java.io.Closeable
-import java.io.File
-import java.io.RandomAccessFile
-import java.nio.ByteBuffer
-import java.nio.channels.FileChannel
-import java.util.zip.CRC32
-import java.util.zip.Deflater
-
-class ZipFile(file: File) : Closeable {
- var entries: MutableList = mutableListOf()
-
- private val filePointer: RandomAccessFile = RandomAccessFile(file, "rw")
- private var CDNeedsRewrite = false
-
- private val compressionLevel = 5
-
- init {
- //if file isn't empty try to load entries
- if (file.length() > 0) {
- val endRecord = findEndRecord()
-
- if (endRecord.diskNumber > 0u || endRecord.totalEntries != endRecord.diskEntries)
- throw IllegalArgumentException("Multi-file archives are not supported")
-
- entries = readEntries(endRecord).toMutableList()
- }
-
- //seek back to start for writing
- filePointer.seek(0)
- }
-
- private fun findEndRecord(): ZipEndRecord {
- //look from end to start since end record is at the end
- for (i in filePointer.length() - 1 downTo 0) {
- filePointer.seek(i)
- //possible beginning of signature
- if (filePointer.readByte() == 0x50.toByte()) {
- //seek back to get the full int
- filePointer.seek(i)
- val possibleSignature = filePointer.readUIntLE()
- if (possibleSignature == ZipEndRecord.ECD_SIGNATURE) {
- filePointer.seek(i)
- return ZipEndRecord.fromECD(filePointer)
- }
- }
- }
-
- throw Exception("Couldn't find end record")
- }
-
- private fun readEntries(endRecord: ZipEndRecord): List {
- filePointer.seek(endRecord.centralDirectoryStartOffset.toLong())
-
- val numberOfEntries = endRecord.diskEntries.toInt()
-
- return buildList(numberOfEntries) {
- for (i in 1..numberOfEntries) {
- add(
- ZipEntry.fromCDE(filePointer).also
- {
- //for some reason the local extra field can be different from the central one
- it.readLocalExtra(
- filePointer.channel.map(
- FileChannel.MapMode.READ_ONLY,
- it.localHeaderOffset.toLong() + 28,
- 2
- )
- )
- })
- }
- }
- }
-
- private fun writeCD() {
- val CDStart = filePointer.channel.position().toUInt()
-
- entries.forEach {
- filePointer.channel.write(it.toCDE())
- }
-
- val entriesCount = entries.size.toUShort()
-
- val endRecord = ZipEndRecord(
- 0u,
- 0u,
- entriesCount,
- entriesCount,
- filePointer.channel.position().toUInt() - CDStart,
- CDStart,
- ""
- )
-
- filePointer.channel.write(endRecord.toECD())
- }
-
- private fun addEntry(entry: ZipEntry, data: ByteBuffer) {
- CDNeedsRewrite = true
-
- entry.localHeaderOffset = filePointer.channel.position().toUInt()
-
- filePointer.channel.write(entry.toLFH())
- filePointer.channel.write(data)
-
- entries.add(entry)
- }
-
- fun addEntryCompressData(entry: ZipEntry, data: ByteArray) {
- val compressor = Deflater(compressionLevel, true)
- compressor.setInput(data)
- compressor.finish()
-
- val uncompressedSize = data.size
- val compressedData =
- ByteArray(uncompressedSize) //i'm guessing compression won't make the data bigger
-
- val compressedDataLength = compressor.deflate(compressedData)
- val compressedBuffer =
- ByteBuffer.wrap(compressedData.take(compressedDataLength).toByteArray())
-
- compressor.end()
-
- val crc = CRC32()
- crc.update(data)
-
- entry.compression = 8u //deflate compression
- entry.uncompressedSize = uncompressedSize.toUInt()
- entry.compressedSize = compressedDataLength.toUInt()
- entry.crc32 = crc.value.toUInt()
-
- addEntry(entry, compressedBuffer)
- }
-
- private fun addEntryCopyData(entry: ZipEntry, data: ByteBuffer, alignment: Int? = null) {
- alignment?.let {
- //calculate where data would end up
- val dataOffset = filePointer.filePointer + entry.LFHSize
-
- val mod = dataOffset % alignment
-
- //wrong alignment
- if (mod != 0L) {
- //add padding at end of extra field
- entry.localExtraField =
- entry.localExtraField.copyOf((entry.localExtraField.size + (alignment - mod)).toInt())
- }
- }
-
- addEntry(entry, data)
- }
-
- fun getDataForEntry(entry: ZipEntry): ByteBuffer {
- return filePointer.channel.map(
- FileChannel.MapMode.READ_ONLY,
- entry.dataOffset.toLong(),
- entry.compressedSize.toLong()
- )
- }
-
- fun copyEntriesFromFileAligned(file: ZipFile, entryAlignment: (entry: ZipEntry) -> Int?) {
- for (entry in file.entries) {
- if (entries.any { it.fileName == entry.fileName }) continue //don't add duplicates
-
- val data = file.getDataForEntry(entry)
- addEntryCopyData(entry, data, entryAlignment(entry))
- }
- }
-
- override fun close() {
- if (CDNeedsRewrite) writeCD()
- filePointer.close()
- }
-}
diff --git a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/structures/ZipEndRecord.kt b/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/structures/ZipEndRecord.kt
deleted file mode 100644
index e7b9b58e26..0000000000
--- a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/structures/ZipEndRecord.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package app.revanced.manager.flutter.utils.zip.structures
-
-import app.revanced.manager.flutter.utils.zip.putUInt
-import app.revanced.manager.flutter.utils.zip.putUShort
-import app.revanced.manager.flutter.utils.zip.readUIntLE
-import app.revanced.manager.flutter.utils.zip.readUShortLE
-import java.io.DataInput
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
-
-data class ZipEndRecord(
- val diskNumber: UShort,
- val startingDiskNumber: UShort,
- val diskEntries: UShort,
- val totalEntries: UShort,
- val centralDirectorySize: UInt,
- val centralDirectoryStartOffset: UInt,
- val fileComment: String,
-) {
-
- companion object {
- const val ECD_HEADER_SIZE = 22
- const val ECD_SIGNATURE = 0x06054b50u
-
- fun fromECD(input: DataInput): ZipEndRecord {
- val signature = input.readUIntLE()
-
- if (signature != ECD_SIGNATURE)
- throw IllegalArgumentException("Input doesn't start with end record signature")
-
- val diskNumber = input.readUShortLE()
- val startingDiskNumber = input.readUShortLE()
- val diskEntries = input.readUShortLE()
- val totalEntries = input.readUShortLE()
- val centralDirectorySize = input.readUIntLE()
- val centralDirectoryStartOffset = input.readUIntLE()
- val fileCommentLength = input.readUShortLE()
- var fileComment = ""
-
- if (fileCommentLength > 0u) {
- val fileCommentBytes = ByteArray(fileCommentLength.toInt())
- input.readFully(fileCommentBytes)
- fileComment = fileCommentBytes.toString(Charsets.UTF_8)
- }
-
- return ZipEndRecord(
- diskNumber,
- startingDiskNumber,
- diskEntries,
- totalEntries,
- centralDirectorySize,
- centralDirectoryStartOffset,
- fileComment
- )
- }
- }
-
- fun toECD(): ByteBuffer {
- val commentBytes = fileComment.toByteArray(Charsets.UTF_8)
-
- val buffer = ByteBuffer.allocate(ECD_HEADER_SIZE + commentBytes.size)
- .also { it.order(ByteOrder.LITTLE_ENDIAN) }
-
- buffer.putUInt(ECD_SIGNATURE)
- buffer.putUShort(diskNumber)
- buffer.putUShort(startingDiskNumber)
- buffer.putUShort(diskEntries)
- buffer.putUShort(totalEntries)
- buffer.putUInt(centralDirectorySize)
- buffer.putUInt(centralDirectoryStartOffset)
- buffer.putUShort(commentBytes.size.toUShort())
-
- buffer.put(commentBytes)
-
- buffer.flip()
- return buffer
- }
-}
diff --git a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/structures/ZipEntry.kt b/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/structures/ZipEntry.kt
deleted file mode 100644
index bda1398e7c..0000000000
--- a/android/app/src/main/kotlin/app/revanced/manager/flutter/utils/zip/structures/ZipEntry.kt
+++ /dev/null
@@ -1,190 +0,0 @@
-package app.revanced.manager.flutter.utils.zip.structures
-
-import app.revanced.manager.flutter.utils.zip.*
-import java.io.DataInput
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
-
-data class ZipEntry(
- val version: UShort,
- val versionNeeded: UShort,
- val flags: UShort,
- var compression: UShort,
- val modificationTime: UShort,
- val modificationDate: UShort,
- var crc32: UInt,
- var compressedSize: UInt,
- var uncompressedSize: UInt,
- val diskNumber: UShort,
- val internalAttributes: UShort,
- val externalAttributes: UInt,
- var localHeaderOffset: UInt,
- val fileName: String,
- val extraField: ByteArray,
- val fileComment: String,
- var localExtraField: ByteArray = ByteArray(0), //separate for alignment
-) {
- val LFHSize: Int
- get() = LFH_HEADER_SIZE + fileName.toByteArray(Charsets.UTF_8).size + localExtraField.size
-
- val dataOffset: UInt
- get() = localHeaderOffset + LFHSize.toUInt()
-
- companion object {
- const val CDE_HEADER_SIZE = 46
- const val CDE_SIGNATURE = 0x02014b50u
-
- const val LFH_HEADER_SIZE = 30
- const val LFH_SIGNATURE = 0x04034b50u
-
- fun createWithName(fileName: String): ZipEntry {
- return ZipEntry(
- 0x1403u, //made by unix, version 20
- 0u,
- 0u,
- 0u,
- 0x0821u, //seems to be static time google uses, no idea
- 0x0221u, //same as above
- 0u,
- 0u,
- 0u,
- 0u,
- 0u,
- 0u,
- 0u,
- fileName,
- ByteArray(0),
- ""
- )
- }
-
- fun fromCDE(input: DataInput): ZipEntry {
- val signature = input.readUIntLE()
-
- if (signature != CDE_SIGNATURE)
- throw IllegalArgumentException("Input doesn't start with central directory entry signature")
-
- val version = input.readUShortLE()
- val versionNeeded = input.readUShortLE()
- var flags = input.readUShortLE()
- val compression = input.readUShortLE()
- val modificationTime = input.readUShortLE()
- val modificationDate = input.readUShortLE()
- val crc32 = input.readUIntLE()
- val compressedSize = input.readUIntLE()
- val uncompressedSize = input.readUIntLE()
- val fileNameLength = input.readUShortLE()
- var fileName = ""
- val extraFieldLength = input.readUShortLE()
- val extraField = ByteArray(extraFieldLength.toInt())
- val fileCommentLength = input.readUShortLE()
- var fileComment = ""
- val diskNumber = input.readUShortLE()
- val internalAttributes = input.readUShortLE()
- val externalAttributes = input.readUIntLE()
- val localHeaderOffset = input.readUIntLE()
-
- val variableFieldsLength =
- fileNameLength.toInt() + extraFieldLength.toInt() + fileCommentLength.toInt()
-
- if (variableFieldsLength > 0) {
- val fileNameBytes = ByteArray(fileNameLength.toInt())
- input.readFully(fileNameBytes)
- fileName = fileNameBytes.toString(Charsets.UTF_8)
-
- input.readFully(extraField)
-
- val fileCommentBytes = ByteArray(fileCommentLength.toInt())
- input.readFully(fileCommentBytes)
- fileComment = fileCommentBytes.toString(Charsets.UTF_8)
- }
-
- flags = (flags and 0b1000u.inv()
- .toUShort()) //disable data descriptor flag as they are not used
-
- return ZipEntry(
- version,
- versionNeeded,
- flags,
- compression,
- modificationTime,
- modificationDate,
- crc32,
- compressedSize,
- uncompressedSize,
- diskNumber,
- internalAttributes,
- externalAttributes,
- localHeaderOffset,
- fileName,
- extraField,
- fileComment,
- )
- }
- }
-
- fun readLocalExtra(buffer: ByteBuffer) {
- buffer.order(ByteOrder.LITTLE_ENDIAN)
- localExtraField = ByteArray(buffer.getUShort().toInt())
- }
-
- fun toLFH(): ByteBuffer {
- val nameBytes = fileName.toByteArray(Charsets.UTF_8)
-
- val buffer = ByteBuffer.allocate(LFH_HEADER_SIZE + nameBytes.size + localExtraField.size)
- .also { it.order(ByteOrder.LITTLE_ENDIAN) }
-
- buffer.putUInt(LFH_SIGNATURE)
- buffer.putUShort(versionNeeded)
- buffer.putUShort(flags)
- buffer.putUShort(compression)
- buffer.putUShort(modificationTime)
- buffer.putUShort(modificationDate)
- buffer.putUInt(crc32)
- buffer.putUInt(compressedSize)
- buffer.putUInt(uncompressedSize)
- buffer.putUShort(nameBytes.size.toUShort())
- buffer.putUShort(localExtraField.size.toUShort())
-
- buffer.put(nameBytes)
- buffer.put(localExtraField)
-
- buffer.flip()
- return buffer
- }
-
- fun toCDE(): ByteBuffer {
- val nameBytes = fileName.toByteArray(Charsets.UTF_8)
- val commentBytes = fileComment.toByteArray(Charsets.UTF_8)
-
- val buffer =
- ByteBuffer.allocate(CDE_HEADER_SIZE + nameBytes.size + extraField.size + commentBytes.size)
- .also { it.order(ByteOrder.LITTLE_ENDIAN) }
-
- buffer.putUInt(CDE_SIGNATURE)
- buffer.putUShort(version)
- buffer.putUShort(versionNeeded)
- buffer.putUShort(flags)
- buffer.putUShort(compression)
- buffer.putUShort(modificationTime)
- buffer.putUShort(modificationDate)
- buffer.putUInt(crc32)
- buffer.putUInt(compressedSize)
- buffer.putUInt(uncompressedSize)
- buffer.putUShort(nameBytes.size.toUShort())
- buffer.putUShort(extraField.size.toUShort())
- buffer.putUShort(commentBytes.size.toUShort())
- buffer.putUShort(diskNumber)
- buffer.putUShort(internalAttributes)
- buffer.putUInt(externalAttributes)
- buffer.putUInt(localHeaderOffset)
-
- buffer.put(nameBytes)
- buffer.put(extraField)
- buffer.put(commentBytes)
-
- buffer.flip()
- return buffer
- }
-}
-
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
deleted file mode 100644
index f74085f3f6..0000000000
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
deleted file mode 100644
index 304732f884..0000000000
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml
deleted file mode 100644
index 581c5fca71..0000000000
--- a/android/app/src/main/res/values-night-v31/styles.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
deleted file mode 100644
index 06952be745..0000000000
--- a/android/app/src/main/res/values-night/styles.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml
deleted file mode 100644
index 41f95cf120..0000000000
--- a/android/app/src/main/res/values-v31/styles.xml
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml
deleted file mode 100644
index 74008be87f..0000000000
--- a/android/app/src/main/res/values/ic_launcher_background.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- #1B1B1B
-
\ No newline at end of file
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
deleted file mode 100644
index cb1ef88056..0000000000
--- a/android/app/src/main/res/values/styles.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
deleted file mode 100644
index d2e2c8deaf..0000000000
--- a/android/app/src/main/res/xml/file_paths.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
deleted file mode 100644
index 2abfc8e943..0000000000
--- a/android/app/src/profile/AndroidManifest.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
diff --git a/android/build.gradle b/android/build.gradle
deleted file mode 100644
index 88ac491ced..0000000000
--- a/android/build.gradle
+++ /dev/null
@@ -1,37 +0,0 @@
-buildscript {
- ext.kotlin_version = '1.7.10'
- repositories {
- google()
- mavenCentral()
- }
-
- dependencies {
- classpath 'com.android.tools.build:gradle:7.1.3'
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- maven {
- url = uri("https://maven.pkg.github.com/revanced/revanced-patcher")
- credentials {
- username = (project.findProperty("gpr.user") ?: System.getenv("GITHUB_ACTOR")) as String
- password = (project.findProperty("gpr.key") ?: System.getenv("GITHUB_TOKEN")) as String
- }
- }
- mavenLocal()
- }
-}
-
-rootProject.buildDir = '../build'
-subprojects {
- project.buildDir = "${rootProject.buildDir}/${project.name}"
- project.evaluationDependsOn(':app')
-}
-
-tasks.register("clean", Delete) {
- delete rootProject.buildDir
-}
diff --git a/android/gradle.properties b/android/gradle.properties
deleted file mode 100644
index 4b11638cfe..0000000000
--- a/android/gradle.properties
+++ /dev/null
@@ -1,6 +0,0 @@
-org.gradle.jvmargs=-Xmx1536M -XX:+UseParallelGC
-org.gradle.parallel=true
-org.gradle.daemon=true
-org.gradle.caching=true
-android.useAndroidX=true
-android.enableJetifier=true
diff --git a/android/settings.gradle b/android/settings.gradle
deleted file mode 100644
index 44e62bcf06..0000000000
--- a/android/settings.gradle
+++ /dev/null
@@ -1,11 +0,0 @@
-include ':app'
-
-def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
-def properties = new Properties()
-
-assert localPropertiesFile.exists()
-localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
-
-def flutterSdkPath = properties.getProperty("flutter.sdk")
-assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
-apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000000..de997d72dc
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,208 @@
+import kotlin.random.Random
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.kotlin.parcelize)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.devtools)
+ alias(libs.plugins.about.libraries)
+}
+
+android {
+ namespace = "app.revanced.manager"
+ compileSdk = 35
+ buildToolsVersion = "35.0.0"
+
+ defaultConfig {
+ applicationId = "app.revanced.manager"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 1
+ versionName = "0.0.1"
+ vectorDrawables.useSupportLibrary = true
+ }
+
+ buildTypes {
+ debug {
+ applicationIdSuffix = ".debug"
+ resValue("string", "app_name", "ReVanced Manager (dev)")
+
+ buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L")
+ }
+
+ release {
+ if (!project.hasProperty("noProguard")) {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+
+ if (project.hasProperty("signAsDebug")) {
+ applicationIdSuffix = ".debug"
+ resValue("string", "app_name", "ReVanced Manager Debug")
+ signingConfig = signingConfigs.getByName("debug")
+ }
+
+ buildConfigField("long", "BUILD_ID", "0L")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ dependenciesInfo {
+ includeInApk = false
+ includeInBundle = false
+ }
+
+ packaging {
+ resources.excludes.addAll(listOf(
+ "/prebuilt/**",
+ "META-INF/DEPENDENCIES",
+ "META-INF/**.version",
+ "DebugProbesKt.bin",
+ "kotlin-tooling-metadata.json",
+ "org/bouncycastle/pqc/**.properties",
+ "org/bouncycastle/x509/**.properties",
+ ))
+ jniLibs {
+ useLegacyPackaging = true
+ }
+ }
+
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ compose = true
+ aidl = true
+ buildConfig = true
+ }
+
+ android {
+ androidResources {
+ generateLocaleConfig = true
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ path = file("src/main/cpp/CMakeLists.txt")
+ version = "3.22.1"
+ }
+ }
+}
+
+kotlin {
+ jvmToolchain(17)
+}
+
+dependencies {
+
+ // AndroidX Core
+ implementation(libs.androidx.ktx)
+ implementation(libs.runtime.ktx)
+ implementation(libs.runtime.compose)
+ implementation(libs.splash.screen)
+ implementation(libs.activity.compose)
+ implementation(libs.work.runtime.ktx)
+ implementation(libs.preferences.datastore)
+ implementation(libs.appcompat)
+
+ // Compose
+ implementation(platform(libs.compose.bom))
+ implementation(libs.compose.ui)
+ implementation(libs.compose.ui.preview)
+ implementation(libs.compose.ui.tooling)
+ implementation(libs.compose.livedata)
+ implementation(libs.compose.material.icons.extended)
+ implementation(libs.compose.material3)
+ implementation(libs.navigation.compose)
+
+ // Accompanist
+ implementation(libs.accompanist.drawablepainter)
+
+ // Placeholder
+ implementation(libs.placeholder.material3)
+
+ // HTML Scraper
+ implementation(libs.skrapeit.dsl)
+ implementation(libs.skrapeit.parser)
+
+ // Coil (async image loading, network image)
+ implementation(libs.coil.compose)
+ implementation(libs.coil.appiconloader)
+
+ // KotlinX
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.kotlinx.collection.immutable)
+ implementation(libs.kotlinx.datetime)
+
+ // Room
+ implementation(libs.room.runtime)
+ implementation(libs.room.ktx)
+ annotationProcessor(libs.room.compiler)
+ ksp(libs.room.compiler)
+
+ // ReVanced
+ implementation(libs.revanced.patcher)
+ implementation(libs.revanced.library)
+
+ // Downloader plugins
+ implementation(project(":downloader-plugin"))
+
+ // Native processes
+ implementation(libs.kotlin.process)
+
+ // HiddenAPI
+ compileOnly(libs.hidden.api.stub)
+
+ // LibSU
+ implementation(libs.libsu.core)
+ implementation(libs.libsu.service)
+ implementation(libs.libsu.nio)
+
+ // Koin
+ implementation(libs.koin.android)
+ implementation(libs.koin.compose)
+ implementation(libs.koin.compose.navigation)
+ implementation(libs.koin.workmanager)
+
+ // Licenses
+ implementation(libs.about.libraries)
+
+ // Ktor
+ implementation(libs.ktor.core)
+ implementation(libs.ktor.logging)
+ implementation(libs.ktor.okhttp)
+ implementation(libs.ktor.content.negotiation)
+ implementation(libs.ktor.serialization)
+
+ // Markdown
+ implementation(libs.markdown.renderer)
+
+ // Fading Edges
+ implementation(libs.fading.edges)
+
+ // Scrollbars
+ implementation(libs.scrollbars)
+
+ // EnumUtil
+ implementation(libs.enumutil)
+ ksp(libs.enumutil.ksp)
+
+ // Reorderable lists
+ implementation(libs.reorderable)
+
+ // Compose Icons
+ implementation(libs.compose.icons.fontawesome)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000000..b9b9c1aff0
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,63 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.kts.kts.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+-dontobfuscate
+
+# Required for serialization to work properly
+-if @kotlinx.serialization.Serializable class **
+-keepclassmembers class <1> {
+ static <1>$Companion Companion;
+}
+-if @kotlinx.serialization.Serializable class ** {
+ static **$* *;
+}
+-keepclassmembers class <2>$<3> {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+-if @kotlinx.serialization.Serializable class ** {
+ public static ** INSTANCE;
+}
+-keepclassmembers class <1> {
+ public static <1> INSTANCE;
+ kotlinx.serialization.KSerializer serializer(...);
+}
+
+# This required for the process runtime.
+-keep class app.revanced.manager.patcher.runtime.process.* {
+ *;
+}
+# Required for the patcher to function correctly
+-keep class app.revanced.patcher.** {
+ *;
+}
+-keep class brut.** {
+ *;
+}
+-keep class org.xmlpull.** {
+ *;
+}
+-keep class kotlin.** {
+ *;
+}
+-keep class org.jf.** {
+ *;
+}
+-keep class com.android.** {
+ *;
+}
+-keep class app.revanced.manager.plugin.** {
+ *;
+}
+
+-dontwarn com.google.auto.value.**
+-dontwarn java.awt.**
+-dontwarn javax.**
+-dontwarn org.slf4j.**
+-dontwarn it.skrape.fetcher.*
+-dontwarn com.google.j2objc.annotations.*
+
+-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
\ No newline at end of file
diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json
new file mode 100644
index 0000000000..fd83a51ed1
--- /dev/null
+++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json
@@ -0,0 +1,429 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "d0119047505da435972c5247181de675",
+ "entities": [
+ {
+ "tableName": "patch_bundles",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, PRIMARY KEY(`uid`))",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "source",
+ "columnName": "source",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "autoUpdate",
+ "columnName": "auto_update",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uid"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "patch_selections",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "patchBundle",
+ "columnName": "patch_bundle",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packageName",
+ "columnName": "package_name",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_patch_selections_patch_bundle_package_name",
+ "unique": true,
+ "columnNames": [
+ "patch_bundle",
+ "package_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_selections_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "patch_bundles",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "patch_bundle"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "selected_patches",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`selection` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`selection`, `patch_name`), FOREIGN KEY(`selection`) REFERENCES `patch_selections`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "selection",
+ "columnName": "selection",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "patchName",
+ "columnName": "patch_name",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "selection",
+ "patch_name"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "patch_selections",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "selection"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "downloaded_app",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, `last_used` INTEGER NOT NULL, PRIMARY KEY(`package_name`, `version`))",
+ "fields": [
+ {
+ "fieldPath": "packageName",
+ "columnName": "package_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "directory",
+ "columnName": "directory",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUsed",
+ "columnName": "last_used",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "package_name",
+ "version"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "installed_app",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`current_package_name` TEXT NOT NULL, `original_package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `install_type` TEXT NOT NULL, PRIMARY KEY(`current_package_name`))",
+ "fields": [
+ {
+ "fieldPath": "currentPackageName",
+ "columnName": "current_package_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "originalPackageName",
+ "columnName": "original_package_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "version",
+ "columnName": "version",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "installType",
+ "columnName": "install_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "current_package_name"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "applied_patch",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "packageName",
+ "columnName": "package_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "bundle",
+ "columnName": "bundle",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "patchName",
+ "columnName": "patch_name",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "package_name",
+ "bundle",
+ "patch_name"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_applied_patch_bundle",
+ "unique": false,
+ "columnNames": [
+ "bundle"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_applied_patch_bundle` ON `${TABLE_NAME}` (`bundle`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "installed_app",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "package_name"
+ ],
+ "referencedColumns": [
+ "current_package_name"
+ ]
+ },
+ {
+ "table": "patch_bundles",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "bundle"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "option_groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `patch_bundle` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`patch_bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "patchBundle",
+ "columnName": "patch_bundle",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packageName",
+ "columnName": "package_name",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_option_groups_patch_bundle_package_name",
+ "unique": true,
+ "columnNames": [
+ "patch_bundle",
+ "package_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_option_groups_patch_bundle_package_name` ON `${TABLE_NAME}` (`patch_bundle`, `package_name`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "patch_bundles",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "patch_bundle"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "options",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`group`, `patch_name`, `key`), FOREIGN KEY(`group`) REFERENCES `option_groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "group",
+ "columnName": "group",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "patchName",
+ "columnName": "patch_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "value",
+ "columnName": "value",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "group",
+ "patch_name",
+ "key"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "option_groups",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "group"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "trusted_downloader_plugins",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `signature` BLOB NOT NULL, PRIMARY KEY(`package_name`))",
+ "fields": [
+ {
+ "fieldPath": "packageName",
+ "columnName": "package_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "signature",
+ "columnName": "signature",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "package_name"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd0119047505da435972c5247181de675')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..0404d045d8
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/aidl/app/revanced/manager/IRootSystemService.aidl b/app/src/main/aidl/app/revanced/manager/IRootSystemService.aidl
new file mode 100644
index 0000000000..5dbb41c6d8
--- /dev/null
+++ b/app/src/main/aidl/app/revanced/manager/IRootSystemService.aidl
@@ -0,0 +1,8 @@
+// IRootService.aidl
+package app.revanced.manager;
+
+// Declare any non-default types here with import statements
+
+interface IRootSystemService {
+ IBinder getFileSystemService();
+}
\ No newline at end of file
diff --git a/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherEvents.aidl b/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherEvents.aidl
new file mode 100644
index 0000000000..27a4f61b2a
--- /dev/null
+++ b/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherEvents.aidl
@@ -0,0 +1,11 @@
+// IPatcherEvents.aidl
+package app.revanced.manager.patcher.runtime.process;
+
+// Interface for sending events back to the main app process.
+oneway interface IPatcherEvents {
+ void log(String level, String msg);
+ void patchSucceeded();
+ void progress(String name, String state, String msg);
+ // The patching process has ended. The exceptionStackTrace is null if it finished successfully.
+ void finished(String exceptionStackTrace);
+}
\ No newline at end of file
diff --git a/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherProcess.aidl b/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherProcess.aidl
new file mode 100644
index 0000000000..f938ca6235
--- /dev/null
+++ b/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/IPatcherProcess.aidl
@@ -0,0 +1,14 @@
+// IPatcherProcess.aidl
+package app.revanced.manager.patcher.runtime.process;
+
+import app.revanced.manager.patcher.runtime.process.Parameters;
+import app.revanced.manager.patcher.runtime.process.IPatcherEvents;
+
+interface IPatcherProcess {
+ // Returns BuildConfig.BUILD_ID, which is used to ensure the main app and runner process are running the same code.
+ long buildId();
+ // Makes the patcher process exit with code 0
+ oneway void exit();
+ // Starts patching.
+ oneway void start(in Parameters parameters, IPatcherEvents events);
+}
\ No newline at end of file
diff --git a/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/Parameters.aidl b/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/Parameters.aidl
new file mode 100644
index 0000000000..a1e8bee78d
--- /dev/null
+++ b/app/src/main/aidl/app/revanced/manager/patcher/runtime/process/Parameters.aidl
@@ -0,0 +1,4 @@
+// Parameters.aidl
+package app.revanced.manager.patcher.runtime.process;
+
+parcelable Parameters;
\ No newline at end of file
diff --git a/app/src/main/assets/root/module.prop b/app/src/main/assets/root/module.prop
new file mode 100644
index 0000000000..05a5a159dd
--- /dev/null
+++ b/app/src/main/assets/root/module.prop
@@ -0,0 +1,6 @@
+id=__PKG_NAME__-ReVanced
+name=__LABEL__ ReVanced
+version=__VERSION__
+versionCode=0
+author=ReVanced
+description=Mounts the patched APK on top of the original one
\ No newline at end of file
diff --git a/app/src/main/assets/root/service.sh b/app/src/main/assets/root/service.sh
new file mode 100644
index 0000000000..dc3bcb5f45
--- /dev/null
+++ b/app/src/main/assets/root/service.sh
@@ -0,0 +1,40 @@
+#!/system/bin/sh
+DIR=${0%/*}
+
+package_name="__PKG_NAME__"
+version="__VERSION__"
+
+rm "$DIR/log"
+
+{
+
+until [ "$(getprop sys.boot_completed)" = 1 ]; do sleep 5; done
+sleep 5
+
+base_path="$DIR/$package_name.apk"
+stock_path="$(pm path "$package_name" | grep base | sed 's/package://g')"
+stock_version="$(dumpsys package "$package_name" | grep versionName | cut -d "=" -f2)"
+
+echo "base_path: $base_path"
+echo "stock_path: $stock_path"
+echo "base_version: $version"
+echo "stock_version: $stock_version"
+
+if mount | grep -q "$stock_path" ; then
+ echo "Not mounting as stock path is already mounted"
+ exit 1
+fi
+
+if [ "$version" != "$stock_version" ]; then
+ echo "Not mounting as versions don't match"
+ exit 1
+fi
+
+if [ -z "$stock_path" ]; then
+ echo "Not mounting as app info could not be loaded"
+ exit 1
+fi
+
+mount -o bind "$base_path" "$stock_path"
+
+} >> "$DIR/log"
diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt
new file mode 100644
index 0000000000..64793f8fe5
--- /dev/null
+++ b/app/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,38 @@
+
+# For more information about using CMake with Android Studio, read the
+# documentation: https://d.android.com/studio/projects/add-native-code.html.
+# For more examples on how to use CMake, see https://github.com/android/ndk-samples.
+
+# Sets the minimum CMake version required for this project.
+cmake_minimum_required(VERSION 3.22.1)
+
+# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
+# Since this is the top level CMakeLists.txt, the project name is also accessible
+# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
+# build script scope).
+project("prop_override")
+
+# Creates and names a library, sets it as either STATIC
+# or SHARED, and provides the relative paths to its source code.
+# You can define multiple libraries, and CMake builds them for you.
+# Gradle automatically packages shared libraries with your APK.
+#
+# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
+# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
+# is preferred for the same purpose.
+#
+# In order to load a library into your app from Java/Kotlin, you must call
+# System.loadLibrary() and pass the name of the library defined here;
+# for GameActivity/NativeActivity derived applications, the same library name must be
+# used in the AndroidManifest.xml file.
+add_library(${CMAKE_PROJECT_NAME} SHARED
+ # List C/C++ source files with relative paths to this CMakeLists.txt.
+ prop_override.cpp)
+
+# Specifies libraries CMake should link to your target library. You
+# can link libraries from various origins, such as libraries defined in this
+# build script, prebuilt third-party libraries, or Android system libraries.
+target_link_libraries(${CMAKE_PROJECT_NAME}
+ # List libraries link to the target library
+ android
+ log)
diff --git a/app/src/main/cpp/prop_override.cpp b/app/src/main/cpp/prop_override.cpp
new file mode 100644
index 0000000000..b314ccd117
--- /dev/null
+++ b/app/src/main/cpp/prop_override.cpp
@@ -0,0 +1,62 @@
+// Library for overriding Android system properties via environment variables.
+//
+// Usage: LD_PRELOAD=prop_override.so PROP_dalvik.vm.heapsize=123M getprop dalvik.vm.heapsize
+// Output: 123M
+#include
+#include
+#include
+#include
+
+// Source: https://android.googlesource.com/platform/system/core/+/100b08a848d018eeb1caa5d5e7c7c2aaac65da15/libcutils/include/cutils/properties.h
+#define PROP_VALUE_MAX 92
+// This is the mangled name of "android::base::GetProperty".
+#define GET_PROPERTY_MANGLED_NAME "_ZN7android4base11GetPropertyERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES9_"
+
+extern "C" typedef int (*property_get_ptr)(const char *, char *, const char *);
+typedef std::string (*GetProperty_ptr)(const std::string &, const std::string &);
+
+char *GetPropOverride(const std::string &key) {
+ auto envKey = "PROP_" + key;
+
+ return getenv(envKey.c_str());
+}
+
+// See: https://android.googlesource.com/platform/system/core/+/100b08a848d018eeb1caa5d5e7c7c2aaac65da15/libcutils/properties.cpp
+extern "C" int property_get(const char *key, char *value, const char *default_value) {
+ auto replacement = GetPropOverride(std::string(key));
+ if (replacement) {
+ int len = strnlen(replacement, PROP_VALUE_MAX);
+
+ strncpy(value, replacement, len);
+ return len;
+ }
+
+ static property_get_ptr original = NULL;
+ if (!original) {
+ // Get the address of the original function.
+ original = reinterpret_cast(dlsym(RTLD_NEXT, "property_get"));
+ }
+
+ return original(key, value, default_value);
+}
+
+// Defining android::base::GetProperty ourselves won't work because std::string has a slightly different "path" in the NDK version of the C++ standard library.
+// We can get around this by forcing the function to adopt a specific name using the asm keyword.
+std::string GetProperty(const std::string &, const std::string &) asm(GET_PROPERTY_MANGLED_NAME);
+
+
+// See: https://android.googlesource.com/platform/system/libbase/+/1a34bb67c4f3ba0a1ea6f4f20ac9fe117ba4fe64/properties.cpp
+// This isn't used for the properties we want to override, but property_get is deprecated so that could change in the future.
+std::string GetProperty(const std::string &key, const std::string &default_value) {
+ auto replacement = GetPropOverride(key);
+ if (replacement) {
+ return std::string(replacement);
+ }
+
+ static GetProperty_ptr original = NULL;
+ if (!original) {
+ original = reinterpret_cast(dlsym(RTLD_NEXT, GET_PROPERTY_MANGLED_NAME));
+ }
+
+ return original(key, default_value);
+}
diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt
new file mode 100644
index 0000000000..2dfc1f589c
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/MainActivity.kt
@@ -0,0 +1,307 @@
+package app.revanced.manager
+
+import android.content.ActivityNotFoundException
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.navigation
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.toRoute
+import app.revanced.manager.ui.model.navigation.*
+import app.revanced.manager.ui.screen.*
+import app.revanced.manager.ui.screen.settings.*
+import app.revanced.manager.ui.screen.settings.update.ChangelogsScreen
+import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen
+import app.revanced.manager.ui.theme.ReVancedManagerTheme
+import app.revanced.manager.ui.theme.Theme
+import app.revanced.manager.ui.viewmodel.MainViewModel
+import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
+import app.revanced.manager.util.EventEffect
+import kotlinx.coroutines.launch
+import org.koin.androidx.compose.koinViewModel
+import org.koin.androidx.compose.navigation.koinNavViewModel
+import org.koin.core.parameter.parametersOf
+import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
+
+class MainActivity : ComponentActivity() {
+ @ExperimentalAnimationApi
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ enableEdgeToEdge()
+ installSplashScreen()
+
+ val vm: MainViewModel = getActivityViewModel()
+
+ setContent {
+ val launcher = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ onResult = vm::applyLegacySettings
+ )
+ val theme by vm.prefs.theme.getAsState()
+ val dynamicColor by vm.prefs.dynamicColor.getAsState()
+
+ EventEffect(vm.legacyImportActivityFlow) {
+ try {
+ launcher.launch(it)
+ } catch (_: ActivityNotFoundException) {
+ }
+ }
+
+ ReVancedManagerTheme(
+ darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK,
+ dynamicColor = dynamicColor
+ ) {
+ ReVancedManager(vm)
+ }
+ }
+ }
+}
+
+@Composable
+private fun ReVancedManager(vm: MainViewModel) {
+ val navController = rememberNavController()
+
+ EventEffect(vm.appSelectFlow) { app ->
+ navController.navigateComplex(
+ SelectedApplicationInfo,
+ SelectedApplicationInfo.ViewModelParams(app)
+ )
+ }
+
+ NavHost(
+ navController = navController,
+ startDestination = Dashboard,
+ ) {
+ composable {
+ DashboardScreen(
+ onSettingsClick = { navController.navigate(Settings) },
+ onAppSelectorClick = {
+ navController.navigate(AppSelector)
+ },
+ onUpdateClick = {
+ navController.navigate(Update())
+ },
+ onDownloaderPluginClick = {
+ navController.navigate(Settings.Downloads)
+ },
+ onAppClick = { packageName ->
+ navController.navigate(InstalledApplicationInfo(packageName))
+ }
+ )
+ }
+
+ composable {
+ val data = it.toRoute()
+
+ InstalledAppInfoScreen(
+ onPatchClick = vm::selectApp,
+ onBackClick = navController::popBackStack,
+ viewModel = koinViewModel { parametersOf(data.packageName) }
+ )
+ }
+
+ composable {
+ AppSelectorScreen(
+ onSelect = vm::selectApp,
+ onStorageSelect = vm::selectApp,
+ onBackClick = navController::popBackStack
+ )
+ }
+
+ composable {
+ PatcherScreen(
+ onBackClick = {
+ navController.navigate(route = Dashboard) {
+ launchSingleTop = true
+ popUpTo {
+ inclusive = false
+ }
+ }
+ },
+ vm = koinViewModel { parametersOf(it.getComplexArg()) }
+ )
+ }
+
+ composable {
+ val data = it.toRoute()
+
+ UpdateScreen(
+ onBackClick = navController::popBackStack,
+ vm = koinViewModel { parametersOf(data.downloadOnScreenEntry) }
+ )
+ }
+
+ navigation(startDestination = SelectedApplicationInfo.Main) {
+ composable {
+ val parentBackStackEntry = navController.navGraphEntry(it)
+ val data =
+ parentBackStackEntry.getComplexArg()
+ val viewModel =
+ koinNavViewModel(viewModelStoreOwner = parentBackStackEntry) {
+ parametersOf(data)
+ }
+
+ SelectedAppInfoScreen(
+ onBackClick = navController::popBackStack,
+ onPatchClick = {
+ it.lifecycleScope.launch {
+ navController.navigateComplex(
+ Patcher,
+ viewModel.getPatcherParams()
+ )
+ }
+ },
+ onPatchSelectorClick = { app, patches, options ->
+ navController.navigateComplex(
+ SelectedApplicationInfo.PatchesSelector,
+ SelectedApplicationInfo.PatchesSelector.ViewModelParams(
+ app,
+ patches,
+ options
+ )
+ )
+ },
+ onRequiredOptions = { app, patches, options ->
+ navController.navigateComplex(
+ SelectedApplicationInfo.RequiredOptions,
+ SelectedApplicationInfo.PatchesSelector.ViewModelParams(
+ app,
+ patches,
+ options
+ )
+ )
+ },
+ vm = viewModel
+ )
+ }
+
+ composable {
+ val data =
+ it.getComplexArg()
+ val selectedAppInfoVm = koinNavViewModel(
+ viewModelStoreOwner = navController.navGraphEntry(it)
+ )
+
+ PatchesSelectorScreen(
+ onBackClick = navController::popBackStack,
+ onSave = { patches, options ->
+ selectedAppInfoVm.updateConfiguration(patches, options)
+ navController.popBackStack()
+ },
+ vm = koinViewModel { parametersOf(data) }
+ )
+ }
+
+ composable {
+ val data =
+ it.getComplexArg()
+ val selectedAppInfoVm = koinNavViewModel(
+ viewModelStoreOwner = navController.navGraphEntry(it)
+ )
+
+ RequiredOptionsScreen(
+ onBackClick = navController::popBackStack,
+ onContinue = { patches, options ->
+ selectedAppInfoVm.updateConfiguration(patches, options)
+ it.lifecycleScope.launch {
+ navController.navigateComplex(
+ Patcher,
+ selectedAppInfoVm.getPatcherParams()
+ )
+ }
+ },
+ vm = koinViewModel { parametersOf(data) }
+ )
+ }
+ }
+
+ navigation(startDestination = Settings.Main) {
+ composable {
+ SettingsScreen(
+ onBackClick = navController::popBackStack,
+ navigate = navController::navigate
+ )
+ }
+
+ composable {
+ GeneralSettingsScreen(onBackClick = navController::popBackStack)
+ }
+
+ composable {
+ AdvancedSettingsScreen(onBackClick = navController::popBackStack)
+ }
+
+ composable {
+ UpdatesSettingsScreen(
+ onBackClick = navController::popBackStack,
+ onChangelogClick = { navController.navigate(Settings.Changelogs) },
+ onUpdateClick = { navController.navigate(Update()) }
+ )
+ }
+
+ composable {
+ DownloadsSettingsScreen(onBackClick = navController::popBackStack)
+ }
+
+ composable {
+ ImportExportSettingsScreen(onBackClick = navController::popBackStack)
+ }
+
+ composable {
+ AboutSettingsScreen(
+ onBackClick = navController::popBackStack,
+ navigate = navController::navigate
+ )
+ }
+
+ composable {
+ ChangelogsScreen(onBackClick = navController::popBackStack)
+ }
+
+ composable {
+ ContributorScreen(onBackClick = navController::popBackStack)
+ }
+
+ composable {
+ LicensesScreen(onBackClick = navController::popBackStack)
+ }
+
+ composable {
+ DeveloperOptionsScreen(onBackClick = navController::popBackStack)
+ }
+ }
+ }
+}
+
+@Composable
+private fun NavController.navGraphEntry(entry: NavBackStackEntry) =
+ remember(entry) { getBackStackEntry(entry.destination.parent!!.id) }
+
+// Androidx Navigation does not support storing complex types in route objects, so we have to store them inside the saved state handle of the back stack entry instead.
+private fun > NavController.navigateComplex(
+ route: R,
+ data: T
+) {
+ navigate(route)
+ getBackStackEntry(route).savedStateHandle["args"] = data
+}
+
+private fun NavBackStackEntry.getComplexArg() = savedStateHandle.get("args")!!
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/ManagerApplication.kt
new file mode 100644
index 0000000000..1d17e5ef61
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ManagerApplication.kt
@@ -0,0 +1,110 @@
+package app.revanced.manager
+
+import android.app.Activity
+import android.app.Application
+import android.os.Bundle
+import android.util.Log
+import app.revanced.manager.data.platform.Filesystem
+import app.revanced.manager.di.*
+import app.revanced.manager.domain.manager.PreferencesManager
+import app.revanced.manager.domain.repository.DownloaderPluginRepository
+import app.revanced.manager.domain.repository.PatchBundleRepository
+import app.revanced.manager.util.tag
+import kotlinx.coroutines.Dispatchers
+import coil.Coil
+import coil.ImageLoader
+import com.topjohnwu.superuser.Shell
+import com.topjohnwu.superuser.internal.BuilderImpl
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import me.zhanghai.android.appiconloader.coil.AppIconFetcher
+import me.zhanghai.android.appiconloader.coil.AppIconKeyer
+import org.koin.android.ext.android.inject
+import org.koin.android.ext.koin.androidContext
+import org.koin.android.ext.koin.androidLogger
+import org.koin.androidx.workmanager.koin.workManagerFactory
+import org.koin.core.context.startKoin
+
+class ManagerApplication : Application() {
+ private val scope = MainScope()
+ private val prefs: PreferencesManager by inject()
+ private val patchBundleRepository: PatchBundleRepository by inject()
+ private val downloaderPluginRepository: DownloaderPluginRepository by inject()
+ private val fs: Filesystem by inject()
+
+ override fun onCreate() {
+ super.onCreate()
+
+ startKoin {
+ androidContext(this@ManagerApplication)
+ androidLogger()
+ workManagerFactory()
+ modules(
+ httpModule,
+ preferencesModule,
+ repositoryModule,
+ serviceModule,
+ managerModule,
+ workerModule,
+ viewModelModule,
+ databaseModule,
+ rootModule
+ )
+ }
+
+ val pixels = 512
+ Coil.setImageLoader(
+ ImageLoader.Builder(this)
+ .components {
+ add(AppIconKeyer())
+ add(AppIconFetcher.Factory(pixels, true, this@ManagerApplication))
+ }
+ .build()
+ )
+
+ val shellBuilder = BuilderImpl.create().setFlags(Shell.FLAG_MOUNT_MASTER)
+ Shell.setDefaultBuilder(shellBuilder)
+
+ scope.launch {
+ prefs.preload()
+ }
+ scope.launch(Dispatchers.Default) {
+ downloaderPluginRepository.reload()
+ }
+ scope.launch(Dispatchers.Default) {
+ with(patchBundleRepository) {
+ reload()
+ updateCheck()
+ }
+ }
+ registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
+ private var firstActivityCreated = false
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+ if (firstActivityCreated) return
+ firstActivityCreated = true
+
+ // We do not want to call onFreshProcessStart() if there is state to restore.
+ // This can happen on system-initiated process death.
+ if (savedInstanceState == null) {
+ Log.d(tag, "Fresh process created")
+ onFreshProcessStart()
+ } else Log.d(tag, "System-initiated process death detected")
+ }
+
+ override fun onActivityStarted(activity: Activity) {}
+ override fun onActivityResumed(activity: Activity) {}
+ override fun onActivityPaused(activity: Activity) {}
+ override fun onActivityStopped(activity: Activity) {}
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
+ override fun onActivityDestroyed(activity: Activity) {}
+ })
+ }
+
+ private fun onFreshProcessStart() {
+ fs.uiTempDir.apply {
+ deleteRecursively()
+ mkdirs()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt b/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt
new file mode 100644
index 0000000000..7bad2debc3
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/platform/Filesystem.kt
@@ -0,0 +1,51 @@
+package app.revanced.manager.data.platform
+
+import android.Manifest
+import android.app.Application
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Environment
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.activity.result.contract.ActivityResultContracts
+import app.revanced.manager.util.RequestManageStorageContract
+import java.io.File
+import java.nio.file.Path
+
+class Filesystem(private val app: Application) {
+ val contentResolver = app.contentResolver // TODO: move Content Resolver operations to here.
+
+ /**
+ * A directory that gets cleared when the app restarts.
+ * Do not store paths to this directory in a parcel.
+ */
+ val tempDir: File = app.getDir("ephemeral", Context.MODE_PRIVATE).apply {
+ deleteRecursively()
+ mkdirs()
+ }
+
+ /**
+ * A directory for storing temporary files related to UI.
+ * This is the same as [tempDir], but does not get cleared on system-initiated process death.
+ * Paths to this directory can be safely stored in parcels.
+ */
+ val uiTempDir: File = app.getDir("ui_ephemeral", Context.MODE_PRIVATE)
+
+ fun externalFilesDir(): Path = Environment.getExternalStorageDirectory().toPath()
+
+ private fun usesManagePermission() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+
+ private val storagePermissionName =
+ if (usesManagePermission()) Manifest.permission.MANAGE_EXTERNAL_STORAGE else Manifest.permission.READ_EXTERNAL_STORAGE
+
+ fun permissionContract(): Pair, String> {
+ val contract =
+ if (usesManagePermission()) RequestManageStorageContract() else ActivityResultContracts.RequestPermission()
+ return contract to storagePermissionName
+ }
+
+ fun hasStoragePermission() =
+ if (usesManagePermission()) Environment.isExternalStorageManager() else app.checkSelfPermission(
+ storagePermissionName
+ ) == PackageManager.PERMISSION_GRANTED
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/platform/NetworkInfo.kt b/app/src/main/java/app/revanced/manager/data/platform/NetworkInfo.kt
new file mode 100644
index 0000000000..f5d3dd89bb
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/platform/NetworkInfo.kt
@@ -0,0 +1,19 @@
+package app.revanced.manager.data.platform
+
+import android.app.Application
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import androidx.core.content.getSystemService
+
+class NetworkInfo(app: Application) {
+ private val connectivityManager = app.getSystemService()!!
+
+ private fun getCapabilities() = connectivityManager.activeNetwork?.let { connectivityManager.getNetworkCapabilities(it) }
+ fun isConnected() = connectivityManager.activeNetwork != null
+ fun isUnmetered() = getCapabilities()?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) ?: true
+
+ /**
+ * Returns true if it is safe to download large files.
+ */
+ fun isSafe() = isConnected() && isUnmetered()
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt
new file mode 100644
index 0000000000..403bd1cf71
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt
@@ -0,0 +1,39 @@
+package app.revanced.manager.data.room
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import app.revanced.manager.data.room.apps.downloaded.DownloadedAppDao
+import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
+import app.revanced.manager.data.room.apps.installed.AppliedPatch
+import app.revanced.manager.data.room.apps.installed.InstalledApp
+import app.revanced.manager.data.room.apps.installed.InstalledAppDao
+import app.revanced.manager.data.room.selection.PatchSelection
+import app.revanced.manager.data.room.selection.SelectedPatch
+import app.revanced.manager.data.room.selection.SelectionDao
+import app.revanced.manager.data.room.bundles.PatchBundleDao
+import app.revanced.manager.data.room.bundles.PatchBundleEntity
+import app.revanced.manager.data.room.options.Option
+import app.revanced.manager.data.room.options.OptionDao
+import app.revanced.manager.data.room.options.OptionGroup
+import app.revanced.manager.data.room.plugins.TrustedDownloaderPlugin
+import app.revanced.manager.data.room.plugins.TrustedDownloaderPluginDao
+import kotlin.random.Random
+
+@Database(
+ entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class, TrustedDownloaderPlugin::class],
+ version = 1
+)
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun patchBundleDao(): PatchBundleDao
+ abstract fun selectionDao(): SelectionDao
+ abstract fun downloadedAppDao(): DownloadedAppDao
+ abstract fun installedAppDao(): InstalledAppDao
+ abstract fun optionDao(): OptionDao
+ abstract fun trustedDownloaderPluginDao(): TrustedDownloaderPluginDao
+
+ companion object {
+ fun generateUid() = Random.Default.nextInt()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/Converters.kt b/app/src/main/java/app/revanced/manager/data/room/Converters.kt
new file mode 100644
index 0000000000..a9437f86e2
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/Converters.kt
@@ -0,0 +1,26 @@
+package app.revanced.manager.data.room
+
+import androidx.room.TypeConverter
+import app.revanced.manager.data.room.bundles.Source
+import app.revanced.manager.data.room.options.Option.SerializedValue
+import java.io.File
+
+class Converters {
+ @TypeConverter
+ fun sourceFromString(value: String) = Source.from(value)
+
+ @TypeConverter
+ fun sourceToString(value: Source) = value.toString()
+
+ @TypeConverter
+ fun fileFromString(value: String) = File(value)
+
+ @TypeConverter
+ fun fileToString(file: File): String = file.path
+
+ @TypeConverter
+ fun serializedOptionFromString(value: String) = SerializedValue.fromJsonString(value)
+
+ @TypeConverter
+ fun serializedOptionToString(value: SerializedValue) = value.toJsonString()
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt
new file mode 100644
index 0000000000..f170331448
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt
@@ -0,0 +1,16 @@
+package app.revanced.manager.data.room.apps.downloaded
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import java.io.File
+
+@Entity(
+ tableName = "downloaded_app",
+ primaryKeys = ["package_name", "version"]
+)
+data class DownloadedApp(
+ @ColumnInfo(name = "package_name") val packageName: String,
+ @ColumnInfo(name = "version") val version: String,
+ @ColumnInfo(name = "directory") val directory: File,
+ @ColumnInfo(name = "last_used") val lastUsed: Long = System.currentTimeMillis()
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt
new file mode 100644
index 0000000000..492dbde16c
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt
@@ -0,0 +1,26 @@
+package app.revanced.manager.data.room.apps.downloaded
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface DownloadedAppDao {
+ @Query("SELECT * FROM downloaded_app")
+ fun getAllApps(): Flow>
+
+ @Query("SELECT * FROM downloaded_app WHERE package_name = :packageName AND version = :version")
+ suspend fun get(packageName: String, version: String): DownloadedApp?
+
+ @Upsert
+ suspend fun upsert(downloadedApp: DownloadedApp)
+
+ @Query("UPDATE downloaded_app SET last_used = :newValue WHERE package_name = :packageName AND version = :version")
+ suspend fun markUsed(packageName: String, version: String, newValue: Long = System.currentTimeMillis())
+
+ @Delete
+ suspend fun delete(downloadedApps: Collection)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/installed/AppliedPatch.kt b/app/src/main/java/app/revanced/manager/data/room/apps/installed/AppliedPatch.kt
new file mode 100644
index 0000000000..d2a498a3a0
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/apps/installed/AppliedPatch.kt
@@ -0,0 +1,35 @@
+package app.revanced.manager.data.room.apps.installed
+
+import android.os.Parcelable
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import app.revanced.manager.data.room.bundles.PatchBundleEntity
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+@Entity(
+ tableName = "applied_patch",
+ primaryKeys = ["package_name", "bundle", "patch_name"],
+ foreignKeys = [
+ ForeignKey(
+ InstalledApp::class,
+ parentColumns = ["current_package_name"],
+ childColumns = ["package_name"],
+ onDelete = ForeignKey.CASCADE
+ ),
+ ForeignKey(
+ PatchBundleEntity::class,
+ parentColumns = ["uid"],
+ childColumns = ["bundle"],
+ onDelete = ForeignKey.CASCADE
+ )
+ ],
+ indices = [Index(value = ["bundle"], unique = false)]
+)
+data class AppliedPatch(
+ @ColumnInfo(name = "package_name") val packageName: String,
+ @ColumnInfo(name = "bundle") val bundle: Int,
+ @ColumnInfo(name = "patch_name") val patchName: String
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledApp.kt b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledApp.kt
new file mode 100644
index 0000000000..c0986dfd10
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledApp.kt
@@ -0,0 +1,20 @@
+package app.revanced.manager.data.room.apps.installed
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import app.revanced.manager.R
+
+enum class InstallType(val stringResource: Int) {
+ DEFAULT(R.string.default_install),
+ MOUNT(R.string.mount_install)
+}
+
+@Entity(tableName = "installed_app")
+data class InstalledApp(
+ @PrimaryKey
+ @ColumnInfo(name = "current_package_name") val currentPackageName: String,
+ @ColumnInfo(name = "original_package_name") val originalPackageName: String,
+ @ColumnInfo(name = "version") val version: String,
+ @ColumnInfo(name = "install_type") val installType: InstallType
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt
new file mode 100644
index 0000000000..c290cc5e9e
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/apps/installed/InstalledAppDao.kt
@@ -0,0 +1,46 @@
+package app.revanced.manager.data.room.apps.installed
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.MapColumn
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Upsert
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface InstalledAppDao {
+ @Query("SELECT * FROM installed_app")
+ fun getAll(): Flow>
+
+ @Query("SELECT * FROM installed_app WHERE current_package_name = :packageName")
+ suspend fun get(packageName: String): InstalledApp?
+
+ @Query(
+ "SELECT bundle, patch_name FROM applied_patch" +
+ " WHERE package_name = :packageName"
+ )
+ suspend fun getPatchesSelection(packageName: String): Map<@MapColumn("bundle") Int, List<@MapColumn(
+ "patch_name"
+ ) String>>
+
+ @Transaction
+ suspend fun upsertApp(installedApp: InstalledApp, appliedPatches: List) {
+ upsertApp(installedApp)
+ deleteAppliedPatches(installedApp.currentPackageName)
+ insertAppliedPatches(appliedPatches)
+ }
+
+ @Upsert
+ suspend fun upsertApp(installedApp: InstalledApp)
+
+ @Insert
+ suspend fun insertAppliedPatches(appliedPatches: List)
+
+ @Query("DELETE FROM applied_patch WHERE package_name = :packageName")
+ suspend fun deleteAppliedPatches(packageName: String)
+
+ @Delete
+ suspend fun delete(installedApp: InstalledApp)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt
new file mode 100644
index 0000000000..d9955a702b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt
@@ -0,0 +1,37 @@
+package app.revanced.manager.data.room.bundles
+
+import androidx.room.*
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface PatchBundleDao {
+ @Query("SELECT * FROM patch_bundles")
+ suspend fun all(): List
+
+ @Query("SELECT version, auto_update FROM patch_bundles WHERE uid = :uid")
+ fun getPropsById(uid: Int): Flow
+
+ @Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid")
+ suspend fun updateVersion(uid: Int, patches: String?)
+
+ @Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid")
+ suspend fun setAutoUpdate(uid: Int, value: Boolean)
+
+ @Query("UPDATE patch_bundles SET name = :value WHERE uid = :uid")
+ suspend fun setName(uid: Int, value: String)
+
+ @Query("DELETE FROM patch_bundles WHERE uid != 0")
+ suspend fun purgeCustomBundles()
+
+ @Transaction
+ suspend fun reset() {
+ purgeCustomBundles()
+ updateVersion(0, null) // Reset the main source
+ }
+
+ @Query("DELETE FROM patch_bundles WHERE uid = :uid")
+ suspend fun remove(uid: Int)
+
+ @Insert
+ suspend fun add(source: PatchBundleEntity)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt
new file mode 100644
index 0000000000..8ba5f64a96
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt
@@ -0,0 +1,44 @@
+package app.revanced.manager.data.room.bundles
+
+import androidx.room.*
+import io.ktor.http.*
+
+sealed class Source {
+ object Local : Source() {
+ const val SENTINEL = "local"
+
+ override fun toString() = SENTINEL
+ }
+
+ object API : Source() {
+ const val SENTINEL = "api"
+
+ override fun toString() = SENTINEL
+ }
+
+ data class Remote(val url: Url) : Source() {
+ override fun toString() = url.toString()
+ }
+
+ companion object {
+ fun from(value: String) = when (value) {
+ Local.SENTINEL -> Local
+ API.SENTINEL -> API
+ else -> Remote(Url(value))
+ }
+ }
+}
+
+@Entity(tableName = "patch_bundles")
+data class PatchBundleEntity(
+ @PrimaryKey val uid: Int,
+ @ColumnInfo(name = "name") val name: String,
+ @ColumnInfo(name = "version") val version: String? = null,
+ @ColumnInfo(name = "source") val source: Source,
+ @ColumnInfo(name = "auto_update") val autoUpdate: Boolean
+)
+
+data class BundleProperties(
+ @ColumnInfo(name = "version") val version: String? = null,
+ @ColumnInfo(name = "auto_update") val autoUpdate: Boolean
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/options/Option.kt b/app/src/main/java/app/revanced/manager/data/room/options/Option.kt
new file mode 100644
index 0000000000..44bc3d40ab
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/options/Option.kt
@@ -0,0 +1,116 @@
+package app.revanced.manager.data.room.options
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import app.revanced.manager.patcher.patch.Option
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.add
+import kotlinx.serialization.json.boolean
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.float
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.long
+import kotlin.reflect.KClass
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+@Entity(
+ tableName = "options",
+ primaryKeys = ["group", "patch_name", "key"],
+ foreignKeys = [ForeignKey(
+ OptionGroup::class,
+ parentColumns = ["uid"],
+ childColumns = ["group"],
+ onDelete = ForeignKey.CASCADE
+ )]
+)
+data class Option(
+ @ColumnInfo(name = "group") val group: Int,
+ @ColumnInfo(name = "patch_name") val patchName: String,
+ @ColumnInfo(name = "key") val key: String,
+ // Encoded as Json.
+ @ColumnInfo(name = "value") val value: SerializedValue,
+) {
+ @Serializable
+ data class SerializedValue(val raw: JsonElement) {
+ fun toJsonString() = json.encodeToString(raw)
+ fun deserializeFor(option: Option<*>): Any? {
+ if (raw is JsonNull) return null
+
+ val errorMessage = "Cannot deserialize value as ${option.type}"
+ try {
+ if (option.type.classifier == List::class) {
+ val elementType = option.type.arguments.first().type!!
+ return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) }
+ }
+
+ return deserializeBasicType(option.type, raw.jsonPrimitive)
+ } catch (e: IllegalArgumentException) {
+ throw SerializationException(errorMessage, e)
+ } catch (e: IllegalStateException) {
+ throw SerializationException(errorMessage, e)
+ } catch (e: kotlinx.serialization.SerializationException) {
+ throw SerializationException(errorMessage, e)
+ }
+ }
+
+ companion object {
+ private val json = Json {
+ // Patcher does not forbid the use of these values, so we should support them.
+ allowSpecialFloatingPointValues = true
+ }
+
+ private fun deserializeBasicType(type: KType, value: JsonPrimitive) = when (type) {
+ typeOf() -> value.boolean
+ typeOf() -> value.int
+ typeOf() -> value.long
+ typeOf() -> value.float
+ typeOf() -> value.content.also {
+ if (!value.isString) throw SerializationException(
+ "Expected value to be a string: $value"
+ )
+ }
+
+ else -> throw SerializationException("Unknown type: $type")
+ }
+
+ fun fromJsonString(value: String) = SerializedValue(json.decodeFromString(value))
+ fun fromValue(value: Any?) = SerializedValue(when (value) {
+ null -> JsonNull
+ is Number -> JsonPrimitive(value)
+ is Boolean -> JsonPrimitive(value)
+ is String -> JsonPrimitive(value)
+ is List<*> -> buildJsonArray {
+ var elementClass: KClass? = null
+
+ value.forEach {
+ when (it) {
+ null -> throw SerializationException("List elements must not be null")
+ is Number -> add(it)
+ is Boolean -> add(it)
+ is String -> add(it)
+ else -> throw SerializationException("Unknown element type: ${it::class.simpleName}")
+ }
+
+ if (elementClass == null) elementClass = it::class
+ else if (elementClass != it::class) throw SerializationException("List elements must have the same type")
+ }
+ }
+
+ else -> throw SerializationException("Unknown type: ${value::class.simpleName}")
+ })
+ }
+ }
+
+ class SerializationException(message: String, cause: Throwable? = null) :
+ Exception(message, cause)
+}
diff --git a/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt b/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt
new file mode 100644
index 0000000000..5a147f6f3b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt
@@ -0,0 +1,50 @@
+package app.revanced.manager.data.room.options
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.MapColumn
+import androidx.room.Query
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+abstract class OptionDao {
+ @Transaction
+ @Query(
+ "SELECT patch_bundle, `group`, patch_name, `key`, value FROM option_groups" +
+ " LEFT JOIN options ON uid = options.`group`" +
+ " WHERE package_name = :packageName"
+ )
+ abstract suspend fun getOptions(packageName: String): Map<@MapColumn("patch_bundle") Int, List>
+
+ @Query("SELECT uid FROM option_groups WHERE patch_bundle = :bundleUid AND package_name = :packageName")
+ abstract suspend fun getGroupId(bundleUid: Int, packageName: String): Int?
+
+ @Query("SELECT package_name FROM option_groups")
+ abstract fun getPackagesWithOptions(): Flow>
+
+ @Insert
+ abstract suspend fun createOptionGroup(group: OptionGroup)
+
+ @Query("DELETE FROM option_groups WHERE patch_bundle = :uid")
+ abstract suspend fun clearForPatchBundle(uid: Int)
+
+ @Query("DELETE FROM option_groups WHERE package_name = :packageName")
+ abstract suspend fun clearForPackage(packageName: String)
+
+ @Query("DELETE FROM option_groups")
+ abstract suspend fun reset()
+
+ @Insert
+ protected abstract suspend fun insertOptions(patches: List)
+
+ @Query("DELETE FROM options WHERE `group` = :groupId")
+ protected abstract suspend fun clearGroup(groupId: Int)
+
+ @Transaction
+ open suspend fun updateOptions(options: Map>) =
+ options.forEach { (groupId, options) ->
+ clearGroup(groupId)
+ insertOptions(options)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt b/app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt
new file mode 100644
index 0000000000..df35dc993e
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/options/OptionGroup.kt
@@ -0,0 +1,24 @@
+package app.revanced.manager.data.room.options
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import app.revanced.manager.data.room.bundles.PatchBundleEntity
+
+@Entity(
+ tableName = "option_groups",
+ foreignKeys = [ForeignKey(
+ PatchBundleEntity::class,
+ parentColumns = ["uid"],
+ childColumns = ["patch_bundle"],
+ onDelete = ForeignKey.CASCADE
+ )],
+ indices = [Index(value = ["patch_bundle", "package_name"], unique = true)]
+)
+data class OptionGroup(
+ @PrimaryKey val uid: Int,
+ @ColumnInfo(name = "patch_bundle") val patchBundle: Int,
+ @ColumnInfo(name = "package_name") val packageName: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt
new file mode 100644
index 0000000000..8e1b9c39bd
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt
@@ -0,0 +1,11 @@
+package app.revanced.manager.data.room.plugins
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "trusted_downloader_plugins")
+class TrustedDownloaderPlugin(
+ @PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
+ @ColumnInfo(name = "signature") val signature: ByteArray
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt
new file mode 100644
index 0000000000..ad1845f73d
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt
@@ -0,0 +1,22 @@
+package app.revanced.manager.data.room.plugins
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Upsert
+
+@Dao
+interface TrustedDownloaderPluginDao {
+ @Query("SELECT signature FROM trusted_downloader_plugins WHERE package_name = :packageName")
+ suspend fun getTrustedSignature(packageName: String): ByteArray?
+
+ @Upsert
+ suspend fun upsertTrust(plugin: TrustedDownloaderPlugin)
+
+ @Query("DELETE FROM trusted_downloader_plugins WHERE package_name = :packageName")
+ suspend fun remove(packageName: String)
+
+ @Transaction
+ @Query("DELETE FROM trusted_downloader_plugins WHERE package_name IN (:packageNames)")
+ suspend fun removeAll(packageNames: Set)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/PatchSelection.kt b/app/src/main/java/app/revanced/manager/data/room/selection/PatchSelection.kt
new file mode 100644
index 0000000000..02f5ab94d3
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/selection/PatchSelection.kt
@@ -0,0 +1,24 @@
+package app.revanced.manager.data.room.selection
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import app.revanced.manager.data.room.bundles.PatchBundleEntity
+
+@Entity(
+ tableName = "patch_selections",
+ foreignKeys = [ForeignKey(
+ PatchBundleEntity::class,
+ parentColumns = ["uid"],
+ childColumns = ["patch_bundle"],
+ onDelete = ForeignKey.CASCADE
+ )],
+ indices = [Index(value = ["patch_bundle", "package_name"], unique = true)]
+)
+data class PatchSelection(
+ @PrimaryKey val uid: Int,
+ @ColumnInfo(name = "patch_bundle") val patchBundle: Int,
+ @ColumnInfo(name = "package_name") val packageName: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/SelectedPatch.kt b/app/src/main/java/app/revanced/manager/data/room/selection/SelectedPatch.kt
new file mode 100644
index 0000000000..c190364cf5
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/selection/SelectedPatch.kt
@@ -0,0 +1,20 @@
+package app.revanced.manager.data.room.selection
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+
+@Entity(
+ tableName = "selected_patches",
+ primaryKeys = ["selection", "patch_name"],
+ foreignKeys = [ForeignKey(
+ PatchSelection::class,
+ parentColumns = ["uid"],
+ childColumns = ["selection"],
+ onDelete = ForeignKey.CASCADE
+ )]
+)
+data class SelectedPatch(
+ @ColumnInfo(name = "selection") val selection: Int,
+ @ColumnInfo(name = "patch_name") val patchName: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt
new file mode 100644
index 0000000000..14ad1d871f
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt
@@ -0,0 +1,58 @@
+package app.revanced.manager.data.room.selection
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.MapColumn
+import androidx.room.Query
+import androidx.room.Transaction
+
+@Dao
+abstract class SelectionDao {
+ @Transaction
+ @Query(
+ "SELECT patch_bundle, patch_name FROM patch_selections" +
+ " LEFT JOIN selected_patches ON uid = selected_patches.selection" +
+ " WHERE package_name = :packageName"
+ )
+ abstract suspend fun getSelectedPatches(packageName: String): Map<@MapColumn("patch_bundle") Int, List<@MapColumn(
+ "patch_name"
+ ) String>>
+
+ @Transaction
+ @Query(
+ "SELECT package_name, patch_name FROM patch_selections" +
+ " LEFT JOIN selected_patches ON uid = selected_patches.selection" +
+ " WHERE patch_bundle = :bundleUid"
+ )
+ abstract suspend fun exportSelection(bundleUid: Int): Map<@MapColumn("package_name") String, List<@MapColumn(
+ "patch_name"
+ ) String>>
+
+ @Query("SELECT uid FROM patch_selections WHERE patch_bundle = :bundleUid AND package_name = :packageName")
+ abstract suspend fun getSelectionId(bundleUid: Int, packageName: String): Int?
+
+ @Insert
+ abstract suspend fun createSelection(selection: PatchSelection)
+
+ @Query("DELETE FROM patch_selections WHERE patch_bundle = :uid")
+ abstract suspend fun clearForPatchBundle(uid: Int)
+
+ @Query("DELETE FROM patch_selections WHERE package_name = :packageName")
+ abstract suspend fun clearForPackage(packageName: String)
+
+ @Query("DELETE FROM patch_selections")
+ abstract suspend fun reset()
+
+ @Insert
+ protected abstract suspend fun selectPatches(patches: List)
+
+ @Query("DELETE FROM selected_patches WHERE selection = :selectionId")
+ protected abstract suspend fun clearSelection(selectionId: Int)
+
+ @Transaction
+ open suspend fun updateSelections(selections: Map>) =
+ selections.forEach { (selectionUid, patches) ->
+ clearSelection(selectionUid)
+ selectPatches(patches.map { SelectedPatch(selectionUid, it) })
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/di/DatabaseModule.kt b/app/src/main/java/app/revanced/manager/di/DatabaseModule.kt
new file mode 100644
index 0000000000..37d8c05dd6
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/di/DatabaseModule.kt
@@ -0,0 +1,15 @@
+package app.revanced.manager.di
+
+import android.content.Context
+import androidx.room.Room
+import app.revanced.manager.data.room.AppDatabase
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+
+val databaseModule = module {
+ fun provideAppDatabase(context: Context) = Room.databaseBuilder(context, AppDatabase::class.java, "manager").build()
+
+ single {
+ provideAppDatabase(androidContext())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/di/HttpModule.kt b/app/src/main/java/app/revanced/manager/di/HttpModule.kt
new file mode 100644
index 0000000000..1d827ce633
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/di/HttpModule.kt
@@ -0,0 +1,60 @@
+package app.revanced.manager.di
+
+import android.content.Context
+import app.revanced.manager.BuildConfig
+import io.ktor.client.*
+import io.ktor.client.engine.okhttp.*
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.UserAgent
+import io.ktor.client.plugins.contentnegotiation.*
+import io.ktor.serialization.kotlinx.json.*
+import kotlinx.serialization.json.Json
+import okhttp3.Cache
+import okhttp3.Dns
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+import java.net.Inet4Address
+import java.net.InetAddress
+
+val httpModule = module {
+ fun provideHttpClient(context: Context, json: Json) = HttpClient(OkHttp) {
+ engine {
+ config {
+ dns(object : Dns {
+ override fun lookup(hostname: String): List {
+ val addresses = Dns.SYSTEM.lookup(hostname)
+ return if (hostname == "raw.githubusercontent.com") {
+ addresses.filterIsInstance()
+ } else {
+ addresses
+ }
+ }
+ })
+ cache(Cache(context.cacheDir.resolve("cache").also { it.mkdirs() }, 1024 * 1024 * 100))
+ followRedirects(true)
+ followSslRedirects(true)
+ }
+ }
+ install(ContentNegotiation) {
+ json(json)
+ }
+ install(HttpTimeout) {
+ socketTimeoutMillis = 10000
+ }
+ install(UserAgent) {
+ agent = "ReVanced-Manager/${BuildConfig.VERSION_CODE}"
+ }
+ }
+
+ fun provideJson() = Json {
+ encodeDefaults = true
+ isLenient = true
+ ignoreUnknownKeys = true
+ }
+
+ single {
+ provideHttpClient(androidContext(), get())
+ }
+ singleOf(::provideJson)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/di/ManagerModule.kt b/app/src/main/java/app/revanced/manager/di/ManagerModule.kt
new file mode 100644
index 0000000000..0aae1cd67f
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/di/ManagerModule.kt
@@ -0,0 +1,11 @@
+package app.revanced.manager.di
+
+import app.revanced.manager.domain.manager.KeystoreManager
+import app.revanced.manager.util.PM
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+val managerModule = module {
+ singleOf(::KeystoreManager)
+ singleOf(::PM)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/di/PreferencesModule.kt b/app/src/main/java/app/revanced/manager/di/PreferencesModule.kt
new file mode 100644
index 0000000000..029ef4ed0a
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/di/PreferencesModule.kt
@@ -0,0 +1,9 @@
+package app.revanced.manager.di
+
+import app.revanced.manager.domain.manager.PreferencesManager
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+val preferencesModule = module {
+ singleOf(::PreferencesManager)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt
new file mode 100644
index 0000000000..159436d407
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt
@@ -0,0 +1,29 @@
+package app.revanced.manager.di
+
+import app.revanced.manager.data.platform.Filesystem
+import app.revanced.manager.data.platform.NetworkInfo
+import app.revanced.manager.domain.repository.*
+import app.revanced.manager.domain.worker.WorkerRepository
+import app.revanced.manager.network.api.ReVancedAPI
+import org.koin.core.module.dsl.createdAtStart
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+val repositoryModule = module {
+ singleOf(::ReVancedAPI)
+ singleOf(::Filesystem) {
+ createdAtStart()
+ }
+ singleOf(::NetworkInfo)
+ singleOf(::PatchBundlePersistenceRepository)
+ singleOf(::PatchSelectionRepository)
+ singleOf(::PatchOptionsRepository)
+ singleOf(::PatchBundleRepository) {
+ // It is best to load patch bundles ASAP
+ createdAtStart()
+ }
+ singleOf(::DownloaderPluginRepository)
+ singleOf(::WorkerRepository)
+ singleOf(::DownloadedAppRepository)
+ singleOf(::InstalledAppRepository)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/di/RootModule.kt b/app/src/main/java/app/revanced/manager/di/RootModule.kt
new file mode 100644
index 0000000000..1e27555b0c
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/di/RootModule.kt
@@ -0,0 +1,9 @@
+package app.revanced.manager.di
+
+import app.revanced.manager.domain.installer.RootInstaller
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+val rootModule = module {
+ singleOf(::RootInstaller)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/di/ServiceModule.kt b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt
new file mode 100644
index 0000000000..cfda5030db
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/di/ServiceModule.kt
@@ -0,0 +1,9 @@
+package app.revanced.manager.di
+
+import app.revanced.manager.network.service.HttpService
+import org.koin.core.module.dsl.singleOf
+import org.koin.dsl.module
+
+val serviceModule = module {
+ singleOf(::HttpService)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt
new file mode 100644
index 0000000000..4846510f7d
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt
@@ -0,0 +1,26 @@
+package app.revanced.manager.di
+
+import app.revanced.manager.ui.viewmodel.*
+import org.koin.androidx.viewmodel.dsl.viewModelOf
+import org.koin.dsl.module
+
+val viewModelModule = module {
+ viewModelOf(::MainViewModel)
+ viewModelOf(::DashboardViewModel)
+ viewModelOf(::SelectedAppInfoViewModel)
+ viewModelOf(::PatchesSelectorViewModel)
+ viewModelOf(::GeneralSettingsViewModel)
+ viewModelOf(::AdvancedSettingsViewModel)
+ viewModelOf(::AppSelectorViewModel)
+ viewModelOf(::PatcherViewModel)
+ viewModelOf(::UpdateViewModel)
+ viewModelOf(::ChangelogsViewModel)
+ viewModelOf(::ImportExportViewModel)
+ viewModelOf(::AboutViewModel)
+ viewModelOf(::DeveloperOptionsViewModel)
+ viewModelOf(::ContributorViewModel)
+ viewModelOf(::DownloadsViewModel)
+ viewModelOf(::InstalledAppsViewModel)
+ viewModelOf(::InstalledAppInfoViewModel)
+ viewModelOf(::UpdatesSettingsViewModel)
+}
diff --git a/app/src/main/java/app/revanced/manager/di/WorkerModule.kt b/app/src/main/java/app/revanced/manager/di/WorkerModule.kt
new file mode 100644
index 0000000000..d5d9112e9b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/di/WorkerModule.kt
@@ -0,0 +1,9 @@
+package app.revanced.manager.di
+
+import app.revanced.manager.patcher.worker.PatcherWorker
+import org.koin.androidx.workmanager.dsl.workerOf
+import org.koin.dsl.module
+
+val workerModule = module {
+ workerOf(::PatcherWorker)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt
new file mode 100644
index 0000000000..bcbc59cf83
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt
@@ -0,0 +1,21 @@
+package app.revanced.manager.domain.bundles
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.InputStream
+
+class LocalPatchBundle(name: String, id: Int, directory: File) :
+ PatchBundleSource(name, id, directory) {
+ suspend fun replace(patches: InputStream) {
+ withContext(Dispatchers.IO) {
+ patchBundleOutputStream().use { outputStream ->
+ patches.copyTo(outputStream)
+ }
+ }
+
+ reload()?.also {
+ saveVersion(it.readManifestAttribute("Version"))
+ }
+ }
+}
diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt
new file mode 100644
index 0000000000..308e2a56dd
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt
@@ -0,0 +1,114 @@
+package app.revanced.manager.domain.bundles
+
+import android.app.Application
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.revanced.manager.R
+import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository
+import app.revanced.manager.patcher.patch.PatchBundle
+import app.revanced.manager.util.tag
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import java.io.File
+import java.io.OutputStream
+
+/**
+ * A [PatchBundle] source.
+ */
+@Stable
+sealed class PatchBundleSource(initialName: String, val uid: Int, directory: File) : KoinComponent {
+ protected val configRepository: PatchBundlePersistenceRepository by inject()
+ private val app: Application by inject()
+ protected val patchesFile = directory.resolve("patches.jar")
+
+ private val _state = MutableStateFlow(load())
+ val state = _state.asStateFlow()
+
+ private val _nameFlow = MutableStateFlow(initialName)
+ val nameFlow =
+ _nameFlow.map { it.ifEmpty { app.getString(if (isDefault) R.string.bundle_name_default else R.string.bundle_name_fallback) } }
+
+ suspend fun getName() = nameFlow.first()
+
+ /**
+ * Returns true if the bundle has been downloaded to local storage.
+ */
+ fun hasInstalled() = patchesFile.exists()
+
+ protected fun patchBundleOutputStream(): OutputStream = with(patchesFile) {
+ // Android 14+ requires dex containers to be readonly.
+ try {
+ setWritable(true, true)
+ outputStream()
+ } finally {
+ setReadOnly()
+ }
+ }
+
+ private fun load(): State {
+ if (!hasInstalled()) return State.Missing
+
+ return try {
+ State.Loaded(PatchBundle(patchesFile))
+ } catch (t: Throwable) {
+ Log.e(tag, "Failed to load patch bundle with UID $uid", t)
+ State.Failed(t)
+ }
+ }
+
+ suspend fun reload(): PatchBundle? {
+ val newState = load()
+ _state.value = newState
+
+ val bundle = newState.patchBundleOrNull()
+ // Try to read the name from the patch bundle manifest if the bundle does not have a name.
+ if (bundle != null && _nameFlow.value.isEmpty()) {
+ bundle.readManifestAttribute("Name")?.let { setName(it) }
+ }
+
+ return bundle
+ }
+
+ /**
+ * Create a flow that emits the [app.revanced.manager.data.room.bundles.BundleProperties] of this [PatchBundleSource].
+ * The flow will emit null if the associated [PatchBundleSource] is deleted.
+ */
+ fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default)
+ suspend fun getProps() = propsFlow().first()!!
+
+ suspend fun currentVersion() = getProps().version
+ protected suspend fun saveVersion(version: String?) =
+ configRepository.updateVersion(uid, version)
+
+ suspend fun setName(name: String) {
+ configRepository.setName(uid, name)
+ _nameFlow.value = name
+ }
+
+ sealed interface State {
+ fun patchBundleOrNull(): PatchBundle? = null
+
+ data object Missing : State
+ data class Failed(val throwable: Throwable) : State
+ data class Loaded(val bundle: PatchBundle) : State {
+ override fun patchBundleOrNull() = bundle
+ }
+ }
+
+ companion object Extensions {
+ val PatchBundleSource.isDefault inline get() = uid == 0
+ val PatchBundleSource.asRemoteOrNull inline get() = this as? RemotePatchBundle
+ val PatchBundleSource.nameState
+ @Composable inline get() = nameFlow.collectAsStateWithLifecycle(
+ ""
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt
new file mode 100644
index 0000000000..9deb7bbe22
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt
@@ -0,0 +1,71 @@
+package app.revanced.manager.domain.bundles
+
+import androidx.compose.runtime.Stable
+import app.revanced.manager.network.api.ReVancedAPI
+import app.revanced.manager.network.dto.ReVancedAsset
+import app.revanced.manager.network.service.HttpService
+import app.revanced.manager.network.utils.getOrThrow
+import io.ktor.client.request.url
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.koin.core.component.inject
+import java.io.File
+
+@Stable
+sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) :
+ PatchBundleSource(name, id, directory) {
+ protected val http: HttpService by inject()
+
+ protected abstract suspend fun getLatestInfo(): ReVancedAsset
+
+ private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) {
+ patchBundleOutputStream().use {
+ http.streamTo(it) {
+ url(info.downloadUrl)
+ }
+ }
+
+ saveVersion(info.version)
+ reload()
+ }
+
+ suspend fun downloadLatest() {
+ download(getLatestInfo())
+ }
+
+ suspend fun update(): Boolean = withContext(Dispatchers.IO) {
+ val info = getLatestInfo()
+ if (hasInstalled() && info.version == currentVersion())
+ return@withContext false
+
+ download(info)
+ true
+ }
+
+ suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) {
+ patchesFile.delete()
+ reload()
+ }
+
+ suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value)
+
+ companion object {
+ const val updateFailMsg = "Failed to update patch bundle(s)"
+ }
+}
+
+class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
+ RemotePatchBundle(name, id, directory, endpoint) {
+ override suspend fun getLatestInfo() = withContext(Dispatchers.IO) {
+ http.request {
+ url(endpoint)
+ }.getOrThrow()
+ }
+}
+
+class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
+ RemotePatchBundle(name, id, directory, endpoint) {
+ private val api: ReVancedAPI by inject()
+
+ override suspend fun getLatestInfo() = api.getPatchesUpdate().getOrThrow()
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt b/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt
new file mode 100644
index 0000000000..293484ca04
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt
@@ -0,0 +1,181 @@
+package app.revanced.manager.domain.installer
+
+import android.app.Application
+import android.content.ComponentName
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import app.revanced.manager.IRootSystemService
+import app.revanced.manager.service.ManagerRootService
+import app.revanced.manager.util.PM
+import com.topjohnwu.superuser.Shell
+import com.topjohnwu.superuser.ipc.RootService
+import com.topjohnwu.superuser.nio.FileSystemManager
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.time.withTimeoutOrNull
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.time.Duration
+
+class RootInstaller(
+ private val app: Application,
+ private val pm: PM
+) : ServiceConnection {
+ private var remoteFS = CompletableDeferred()
+
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ val ipc = IRootSystemService.Stub.asInterface(service)
+ val binder = ipc.fileSystemService
+
+ remoteFS.complete(FileSystemManager.getRemote(binder))
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ remoteFS = CompletableDeferred()
+ }
+
+ private suspend fun awaitRemoteFS(): FileSystemManager {
+ if (remoteFS.isActive) {
+ withContext(Dispatchers.Main) {
+ val intent = Intent(app, ManagerRootService::class.java)
+ RootService.bind(intent, this@RootInstaller)
+ }
+ }
+
+ return withTimeoutOrNull(Duration.ofSeconds(20L)) {
+ remoteFS.await()
+ } ?: throw RootServiceException()
+ }
+
+ private suspend fun getShell() = with(CompletableDeferred()) {
+ Shell.getShell(::complete)
+
+ await()
+ }
+
+ suspend fun execute(vararg commands: String) = getShell().newJob().add(*commands).exec()
+
+ fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false
+
+ fun isDeviceRooted() = System.getenv("PATH")?.split(":")?.any { path ->
+ File(path, "su").canExecute()
+ } ?: false
+
+ suspend fun isAppInstalled(packageName: String) =
+ awaitRemoteFS().getFile("$modulesPath/$packageName-revanced").exists()
+
+ suspend fun isAppMounted(packageName: String) = withContext(Dispatchers.IO) {
+ pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let {
+ execute("mount | grep \"$it\"").isSuccess
+ } ?: false
+ }
+
+ suspend fun mount(packageName: String) {
+ if (isAppMounted(packageName)) return
+
+ withContext(Dispatchers.IO) {
+ val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
+ ?: throw Exception("Failed to load application info")
+ val patchedAPK = "$modulesPath/$packageName-revanced/$packageName.apk"
+
+ execute("mount -o bind \"$patchedAPK\" \"$stockAPK\"").assertSuccess("Failed to mount APK")
+ }
+ }
+
+ suspend fun unmount(packageName: String) {
+ if (!isAppMounted(packageName)) return
+
+ withContext(Dispatchers.IO) {
+ val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
+ ?: throw Exception("Failed to load application info")
+
+ execute("umount -l \"$stockAPK\"").assertSuccess("Failed to unmount APK")
+ }
+ }
+
+ suspend fun install(
+ patchedAPK: File,
+ stockAPK: File?,
+ packageName: String,
+ version: String,
+ label: String
+ ) = withContext(Dispatchers.IO) {
+ val remoteFS = awaitRemoteFS()
+ val assets = app.assets
+ val modulePath = "$modulesPath/$packageName-revanced"
+
+ unmount(packageName)
+
+ stockAPK?.let { stockApp ->
+ pm.getPackageInfo(packageName)?.let { packageInfo ->
+ // TODO: get user id programmatically
+ if (pm.getVersionCode(packageInfo) <= pm.getVersionCode(
+ pm.getPackageInfo(patchedAPK)
+ ?: error("Failed to get package info for patched app")
+ )
+ )
+ execute("pm uninstall -k --user 0 $packageName").assertSuccess("Failed to uninstall stock app")
+ }
+
+ execute("pm install \"${stockApp.absolutePath}\"").assertSuccess("Failed to install stock app")
+ }
+
+ remoteFS.getFile(modulePath).mkdir()
+
+ listOf(
+ "service.sh",
+ "module.prop",
+ ).forEach { file ->
+ assets.open("root/$file").use { inputStream ->
+ remoteFS.getFile("$modulePath/$file").newOutputStream()
+ .use { outputStream ->
+ val content = String(inputStream.readBytes())
+ .replace("__PKG_NAME__", packageName)
+ .replace("__VERSION__", version)
+ .replace("__LABEL__", label)
+ .toByteArray()
+
+ outputStream.write(content)
+ }
+ }
+ }
+
+ "$modulePath/$packageName.apk".let { apkPath ->
+
+ remoteFS.getFile(patchedAPK.absolutePath)
+ .also { if (!it.exists()) throw Exception("File doesn't exist") }
+ .newInputStream().use { inputStream ->
+ remoteFS.getFile(apkPath).newOutputStream().use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+
+ execute(
+ "chmod 644 $apkPath",
+ "chown system:system $apkPath",
+ "chcon u:object_r:apk_data_file:s0 $apkPath",
+ "chmod +x $modulePath/service.sh"
+ ).assertSuccess("Failed to set file permissions")
+ }
+ }
+
+ suspend fun uninstall(packageName: String) {
+ val remoteFS = awaitRemoteFS()
+ if (isAppMounted(packageName))
+ unmount(packageName)
+
+ remoteFS.getFile("$modulesPath/$packageName-revanced").deleteRecursively()
+ .also { if (!it) throw Exception("Failed to delete files") }
+ }
+
+ companion object {
+ const val modulesPath = "/data/adb/modules"
+
+ private fun Shell.Result.assertSuccess(errorMessage: String) {
+ if (!isSuccess) throw Exception(errorMessage)
+ }
+ }
+}
+
+class RootServiceException : Exception("Root not available")
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt
new file mode 100644
index 0000000000..4f9dc5a34d
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt
@@ -0,0 +1,95 @@
+package app.revanced.manager.domain.manager
+
+import android.app.Application
+import android.content.Context
+import app.revanced.library.ApkSigner
+import app.revanced.library.ApkUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+import java.nio.file.Files
+import java.security.UnrecoverableKeyException
+import java.util.Date
+import kotlin.time.Duration.Companion.days
+
+class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
+ companion object Constants {
+ /**
+ * Default alias and password for the keystore.
+ */
+ const val DEFAULT = "ReVanced"
+ private val eightYearsFromNow get() = Date(System.currentTimeMillis() + (365.days * 8).inWholeMilliseconds * 24)
+ }
+
+ private val keystorePath =
+ app.getDir("signing", Context.MODE_PRIVATE).resolve("manager.keystore")
+
+ private suspend fun updatePrefs(cn: String, pass: String) = prefs.edit {
+ prefs.keystoreCommonName.value = cn
+ prefs.keystorePass.value = pass
+ }
+
+ private suspend fun signingDetails(path: File = keystorePath) = ApkUtils.KeyStoreDetails(
+ keyStore = path,
+ keyStorePassword = null,
+ alias = prefs.keystoreCommonName.get(),
+ password = prefs.keystorePass.get()
+ )
+
+ suspend fun sign(input: File, output: File) = withContext(Dispatchers.Default) {
+ ApkUtils.signApk(input, output, prefs.keystoreCommonName.get(), signingDetails())
+ }
+
+ suspend fun regenerate() = withContext(Dispatchers.Default) {
+ val keyCertPair = ApkSigner.newPrivateKeyCertificatePair(
+ prefs.keystoreCommonName.get(),
+ eightYearsFromNow
+ )
+ val ks = ApkSigner.newKeyStore(
+ setOf(
+ ApkSigner.KeyStoreEntry(
+ DEFAULT, DEFAULT, keyCertPair
+ )
+ )
+ )
+ withContext(Dispatchers.IO) {
+ keystorePath.outputStream().use {
+ ks.store(it, null)
+ }
+ }
+
+ updatePrefs(DEFAULT, DEFAULT)
+ }
+
+ suspend fun import(cn: String, pass: String, keystore: InputStream): Boolean {
+ val keystoreData = withContext(Dispatchers.IO) { keystore.readBytes() }
+
+ try {
+ val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null)
+
+ ApkSigner.readPrivateKeyCertificatePair(ks, cn, pass)
+ } catch (_: UnrecoverableKeyException) {
+ return false
+ } catch (_: IllegalArgumentException) {
+ return false
+ }
+
+ withContext(Dispatchers.IO) {
+ Files.write(keystorePath.toPath(), keystoreData)
+ }
+
+ updatePrefs(cn, pass)
+ return true
+ }
+
+ fun hasKeystore() = keystorePath.exists()
+
+ suspend fun export(target: OutputStream) {
+ withContext(Dispatchers.IO) {
+ Files.copy(keystorePath.toPath(), target)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt
new file mode 100644
index 0000000000..dbf2f1005d
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt
@@ -0,0 +1,31 @@
+package app.revanced.manager.domain.manager
+
+import android.content.Context
+import app.revanced.manager.domain.manager.base.BasePreferencesManager
+import app.revanced.manager.ui.theme.Theme
+
+class PreferencesManager(
+ context: Context
+) : BasePreferencesManager(context, "settings") {
+ val dynamicColor = booleanPreference("dynamic_color", true)
+ val theme = enumPreference("theme", Theme.SYSTEM)
+
+ val api = stringPreference("api_url", "https://api.revanced.app")
+
+ val useProcessRuntime = booleanPreference("use_process_runtime", false)
+ val patcherProcessMemoryLimit = intPreference("process_runtime_memory_limit", 700)
+
+ val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
+ val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
+
+ val firstLaunch = booleanPreference("first_launch", true)
+ val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
+ val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
+
+ val disablePatchVersionCompatCheck = booleanPreference("disable_patch_version_compatibility_check", false)
+ val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
+ val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false)
+ val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true)
+
+ val acknowledgedDownloaderPlugins = stringSetPreference("acknowledged_downloader_plugins", emptySet())
+}
diff --git a/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt
new file mode 100644
index 0000000000..06f75465d3
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt
@@ -0,0 +1,150 @@
+package app.revanced.manager.domain.manager.base
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.*
+import androidx.datastore.preferences.preferencesDataStore
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.revanced.manager.domain.manager.base.BasePreferencesManager.Companion.editor
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.runBlocking
+
+abstract class BasePreferencesManager(private val context: Context, name: String) {
+ private val Context.dataStore: DataStore by preferencesDataStore(name = name)
+ protected val dataStore get() = context.dataStore
+
+ suspend fun preload() {
+ dataStore.data.first()
+ }
+
+ suspend fun edit(block: EditorContext.() -> Unit) = dataStore.editor(block)
+
+ protected fun stringPreference(key: String, default: String) =
+ StringPreference(dataStore, key, default)
+
+ protected fun stringSetPreference(key: String, default: Set) =
+ StringSetPreference(dataStore, key, default)
+
+ protected fun booleanPreference(key: String, default: Boolean) =
+ BooleanPreference(dataStore, key, default)
+
+ protected fun intPreference(key: String, default: Int) = IntPreference(dataStore, key, default)
+
+ protected fun floatPreference(key: String, default: Float) =
+ FloatPreference(dataStore, key, default)
+
+ protected inline fun > enumPreference(
+ key: String,
+ default: E
+ ) = EnumPreference(dataStore, key, default, enumValues())
+
+ companion object {
+ suspend inline fun DataStore.editor(crossinline block: EditorContext.() -> Unit) {
+ edit {
+ EditorContext(it).run(block)
+ }
+ }
+ }
+}
+
+class EditorContext(private val prefs: MutablePreferences) {
+ var Preference.value
+ get() = prefs.run { read() }
+ set(value) = prefs.run { write(value) }
+
+ operator fun Preference>.plusAssign(value: String) = prefs.run {
+ write(read() + value)
+ }
+}
+
+abstract class Preference(
+ private val dataStore: DataStore,
+ val default: T
+) {
+ internal abstract fun Preferences.read(): T
+ internal abstract fun MutablePreferences.write(value: T)
+
+ val flow = dataStore.data.map { with(it) { read() } ?: default }.distinctUntilChanged()
+
+ suspend fun get() = flow.first()
+ fun getBlocking() = runBlocking { get() }
+
+ @Composable
+ fun getAsState() = flow.collectAsStateWithLifecycle(initialValue = remember {
+ getBlocking()
+ })
+
+ suspend fun update(value: T) = dataStore.editor {
+ this@Preference.value = value
+ }
+}
+
+class EnumPreference>(
+ dataStore: DataStore,
+ key: String,
+ default: E,
+ private val enumValues: Array
+) : Preference(dataStore, default) {
+ private val key = stringPreferencesKey(key)
+ override fun Preferences.read() =
+ this[key]?.let { name ->
+ enumValues.find { it.name == name }
+ } ?: default
+
+ override fun MutablePreferences.write(value: E) {
+ this[key] = value.name
+ }
+}
+
+abstract class BasePreference(dataStore: DataStore, default: T) :
+ Preference(dataStore, default) {
+ protected abstract val key: Preferences.Key
+ override fun Preferences.read() = this[key] ?: default
+ override fun MutablePreferences.write(value: T) {
+ this[key] = value
+ }
+}
+
+class StringPreference(
+ dataStore: DataStore,
+ key: String,
+ default: String
+) : BasePreference(dataStore, default) {
+ override val key = stringPreferencesKey(key)
+}
+
+class StringSetPreference(
+ dataStore: DataStore,
+ key: String,
+ default: Set
+) : BasePreference>(dataStore, default) {
+ override val key = stringSetPreferencesKey(key)
+}
+
+class BooleanPreference(
+ dataStore: DataStore,
+ key: String,
+ default: Boolean
+) : BasePreference(dataStore, default) {
+ override val key = booleanPreferencesKey(key)
+}
+
+class IntPreference(
+ dataStore: DataStore,
+ key: String,
+ default: Int
+) : BasePreference(dataStore, default) {
+ override val key = intPreferencesKey(key)
+}
+
+class FloatPreference(
+ dataStore: DataStore,
+ key: String,
+ default: Float
+) : BasePreference(dataStore, default) {
+ override val key = floatPreferencesKey(key)
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt
new file mode 100644
index 0000000000..b4598fb915
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt
@@ -0,0 +1,133 @@
+package app.revanced.manager.domain.repository
+
+import android.app.Application
+import android.content.Context
+import android.os.Parcelable
+import app.revanced.manager.data.room.AppDatabase
+import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
+import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
+import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
+import app.revanced.manager.plugin.downloader.OutputDownloadScope
+import app.revanced.manager.util.PM
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.conflate
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+import java.io.File
+import java.io.FilterOutputStream
+import java.nio.file.StandardOpenOption
+import java.util.concurrent.atomic.AtomicLong
+import kotlin.io.path.outputStream
+
+class DownloadedAppRepository(
+ private val app: Application,
+ db: AppDatabase,
+ private val pm: PM
+) {
+ private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE)
+ private val dao = db.downloadedAppDao()
+
+ fun getAll() = dao.getAllApps().distinctUntilChanged()
+
+ fun getApkFileForApp(app: DownloadedApp): File =
+ getApkFileForDir(dir.resolve(app.directory))
+
+ private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first()
+
+ suspend fun download(
+ plugin: LoadedDownloaderPlugin,
+ data: Parcelable,
+ expectedPackageName: String,
+ expectedVersion: String?,
+ onDownload: suspend (downloadProgress: Pair) -> Unit,
+ ): File {
+ // Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here.
+ val relativePath = File(generateUid().toString())
+ val saveDir = dir.resolve(relativePath).also { it.mkdirs() }
+ val targetFile = saveDir.resolve("base.apk").toPath()
+
+ try {
+ val downloadSize = AtomicLong(0)
+ val downloadedBytes = AtomicLong(0)
+
+ channelFlow {
+ val scope = object : OutputDownloadScope {
+ override val pluginPackageName = plugin.packageName
+ override val hostPackageName = app.packageName
+ override suspend fun reportSize(size: Long) {
+ require(size > 0) { "Size must be greater than zero" }
+ require(
+ downloadSize.compareAndSet(
+ 0,
+ size
+ )
+ ) { "Download size has already been set" }
+ send(downloadedBytes.get() to size)
+ }
+ }
+
+ fun emitProgress(bytes: Long) {
+ val newValue = downloadedBytes.addAndGet(bytes)
+ val totalSize = downloadSize.get()
+ if (totalSize < 1) return
+ trySend(newValue to totalSize).getOrThrow()
+ }
+
+ targetFile.outputStream(StandardOpenOption.CREATE_NEW).buffered().use {
+ val stream = object : FilterOutputStream(it) {
+ override fun write(b: Int) = out.write(b).also { emitProgress(1) }
+
+ override fun write(b: ByteArray?, off: Int, len: Int) =
+ out.write(b, off, len).also {
+ emitProgress(
+ (len - off).toLong()
+ )
+ }
+ }
+ plugin.download(scope, data, stream)
+ }
+ }
+ .conflate()
+ .flowOn(Dispatchers.IO)
+ .collect { (downloaded, size) -> onDownload(downloaded to size) }
+
+ if (downloadedBytes.get() < 1) error("Downloader did not download anything.")
+ val pkgInfo =
+ pm.getPackageInfo(targetFile.toFile()) ?: error("Downloaded APK file is invalid")
+ if (pkgInfo.packageName != expectedPackageName) error("Downloaded APK has the wrong package name. Expected: $expectedPackageName, Actual: ${pkgInfo.packageName}")
+ if (expectedVersion != null && pkgInfo.versionName != expectedVersion) error("Downloaded APK has the wrong version. Expected: $expectedVersion, Actual: ${pkgInfo.versionName}")
+
+ // Delete the previous copy (if present).
+ dao.get(pkgInfo.packageName, pkgInfo.versionName!!)?.directory?.let {
+ if (!dir.resolve(it).deleteRecursively()) throw Exception("Failed to delete existing directory")
+ }
+ dao.upsert(
+ DownloadedApp(
+ packageName = pkgInfo.packageName,
+ version = pkgInfo.versionName!!,
+ directory = relativePath,
+ )
+ )
+ } catch (e: Exception) {
+ saveDir.deleteRecursively()
+ throw e
+ }
+
+ // Return the Apk file.
+ return getApkFileForDir(saveDir)
+ }
+
+ suspend fun get(packageName: String, version: String, markUsed: Boolean = false) =
+ dao.get(packageName, version)?.also {
+ if (markUsed) dao.markUsed(packageName, version)
+ }
+
+ suspend fun delete(downloadedApps: Collection) {
+ downloadedApps.forEach {
+ dir.resolve(it.directory).deleteRecursively()
+ }
+
+ dao.delete(downloadedApps)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt
new file mode 100644
index 0000000000..791a09ac11
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt
@@ -0,0 +1,168 @@
+package app.revanced.manager.domain.repository
+
+import android.app.Application
+import android.content.pm.PackageManager
+import android.os.Parcelable
+import android.util.Log
+import app.revanced.manager.data.room.AppDatabase
+import app.revanced.manager.data.room.plugins.TrustedDownloaderPlugin
+import app.revanced.manager.domain.manager.PreferencesManager
+import app.revanced.manager.network.downloader.DownloaderPluginState
+import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
+import app.revanced.manager.network.downloader.ParceledDownloaderData
+import app.revanced.manager.plugin.downloader.DownloaderBuilder
+import app.revanced.manager.plugin.downloader.PluginHostApi
+import app.revanced.manager.plugin.downloader.Scope
+import app.revanced.manager.util.PM
+import app.revanced.manager.util.tag
+import dalvik.system.PathClassLoader
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+import java.lang.reflect.Modifier
+
+@OptIn(PluginHostApi::class)
+class DownloaderPluginRepository(
+ private val pm: PM,
+ private val prefs: PreferencesManager,
+ private val app: Application,
+ db: AppDatabase
+) {
+ private val trustDao = db.trustedDownloaderPluginDao()
+ private val _pluginStates = MutableStateFlow(emptyMap())
+ val pluginStates = _pluginStates.asStateFlow()
+ val loadedPluginsFlow = pluginStates.map { states ->
+ states.values.filterIsInstance().map { it.plugin }
+ }
+
+ private val acknowledgedDownloaderPlugins = prefs.acknowledgedDownloaderPlugins
+ private val installedPluginPackageNames = MutableStateFlow(emptySet())
+ val newPluginPackageNames = combine(
+ installedPluginPackageNames,
+ acknowledgedDownloaderPlugins.flow
+ ) { installed, acknowledged ->
+ installed subtract acknowledged
+ }
+
+ suspend fun reload() {
+ val plugins =
+ withContext(Dispatchers.IO) {
+ pm.getPackagesWithFeature(PLUGIN_FEATURE)
+ .associate { it.packageName to loadPlugin(it.packageName) }
+ }
+
+ _pluginStates.value = plugins
+ installedPluginPackageNames.value = plugins.keys
+
+ val acknowledgedPlugins = acknowledgedDownloaderPlugins.get()
+ val uninstalledPlugins = acknowledgedPlugins subtract installedPluginPackageNames.value
+ if (uninstalledPlugins.isNotEmpty()) {
+ Log.d(tag, "Uninstalled plugins: ${uninstalledPlugins.joinToString(", ")}")
+ acknowledgedDownloaderPlugins.update(acknowledgedPlugins subtract uninstalledPlugins)
+ trustDao.removeAll(uninstalledPlugins)
+ }
+ }
+
+ fun unwrapParceledData(data: ParceledDownloaderData): Pair {
+ val plugin =
+ (_pluginStates.value[data.pluginPackageName] as? DownloaderPluginState.Loaded)?.plugin
+ ?: throw Exception("Downloader plugin with name ${data.pluginPackageName} is not available")
+
+ return plugin to data.unwrapWith(plugin)
+ }
+
+ private suspend fun loadPlugin(packageName: String): DownloaderPluginState {
+ try {
+ if (!verify(packageName)) return DownloaderPluginState.Untrusted
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.e(tag, "Got exception while verifying plugin $packageName", e)
+ return DownloaderPluginState.Failed(e)
+ }
+
+ return try {
+ val packageInfo = pm.getPackageInfo(packageName, flags = PackageManager.GET_META_DATA)!!
+ val className = packageInfo.applicationInfo!!.metaData.getString(METADATA_PLUGIN_CLASS)
+ ?: throw Exception("Missing metadata attribute $METADATA_PLUGIN_CLASS")
+
+ val classLoader =
+ PathClassLoader(packageInfo.applicationInfo!!.sourceDir, app.classLoader)
+ val pluginContext = app.createPackageContext(packageName, 0)
+
+ val downloader = classLoader
+ .loadClass(className)
+ .getDownloaderBuilder()
+ .build(
+ scopeImpl = object : Scope {
+ override val hostPackageName = app.packageName
+ override val pluginPackageName = pluginContext.packageName
+ },
+ context = pluginContext
+ )
+
+ DownloaderPluginState.Loaded(
+ LoadedDownloaderPlugin(
+ packageName,
+ with(pm) { packageInfo.label() },
+ packageInfo.versionName!!,
+ downloader.get,
+ downloader.download,
+ classLoader
+ )
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (t: Throwable) {
+ Log.e(tag, "Failed to load plugin $packageName", t)
+ DownloaderPluginState.Failed(t)
+ }
+ }
+
+ suspend fun trustPackage(packageName: String) {
+ trustDao.upsertTrust(
+ TrustedDownloaderPlugin(
+ packageName,
+ pm.getSignature(packageName).toByteArray()
+ )
+ )
+
+ reload()
+ prefs.edit {
+ acknowledgedDownloaderPlugins += packageName
+ }
+ }
+
+ suspend fun revokeTrustForPackage(packageName: String) =
+ trustDao.remove(packageName).also { reload() }
+
+ suspend fun acknowledgeAllNewPlugins() =
+ acknowledgedDownloaderPlugins.update(installedPluginPackageNames.value)
+
+ private suspend fun verify(packageName: String): Boolean {
+ val expectedSignature =
+ trustDao.getTrustedSignature(packageName) ?: return false
+
+ return pm.hasSignature(packageName, expectedSignature)
+ }
+
+ private companion object {
+ const val PLUGIN_FEATURE = "app.revanced.manager.plugin.downloader"
+ const val METADATA_PLUGIN_CLASS = "app.revanced.manager.plugin.downloader.class"
+
+ const val PUBLIC_STATIC = Modifier.PUBLIC or Modifier.STATIC
+ val Int.isPublicStatic get() = (this and PUBLIC_STATIC) == PUBLIC_STATIC
+ val Class<*>.isDownloaderBuilder get() = DownloaderBuilder::class.java.isAssignableFrom(this)
+
+ @Suppress("UNCHECKED_CAST")
+ fun Class<*>.getDownloaderBuilder() =
+ declaredMethods
+ .firstOrNull { it.modifiers.isPublicStatic && it.returnType.isDownloaderBuilder && it.parameterTypes.isEmpty() }
+ ?.let { it(null) as DownloaderBuilder }
+ ?: throw Exception("Could not find a valid downloader implementation in class $canonicalName")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt
new file mode 100644
index 0000000000..cd3a010007
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/repository/InstalledAppRepository.kt
@@ -0,0 +1,51 @@
+package app.revanced.manager.domain.repository
+
+import app.revanced.manager.data.room.AppDatabase
+import app.revanced.manager.data.room.apps.installed.AppliedPatch
+import app.revanced.manager.data.room.apps.installed.InstallType
+import app.revanced.manager.data.room.apps.installed.InstalledApp
+import app.revanced.manager.util.PatchSelection
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+class InstalledAppRepository(
+ db: AppDatabase
+) {
+ private val dao = db.installedAppDao()
+
+ fun getAll() = dao.getAll().distinctUntilChanged()
+
+ suspend fun get(packageName: String) = dao.get(packageName)
+
+ suspend fun getAppliedPatches(packageName: String): PatchSelection =
+ dao.getPatchesSelection(packageName).mapValues { (_, patches) -> patches.toSet() }
+
+ suspend fun addOrUpdate(
+ currentPackageName: String,
+ originalPackageName: String,
+ version: String,
+ installType: InstallType,
+ patchSelection: PatchSelection
+ ) {
+ dao.upsertApp(
+ InstalledApp(
+ currentPackageName = currentPackageName,
+ originalPackageName = originalPackageName,
+ version = version,
+ installType = installType
+ ),
+ patchSelection.flatMap { (uid, patches) ->
+ patches.map { patch ->
+ AppliedPatch(
+ packageName = currentPackageName,
+ bundle = uid,
+ patchName = patch
+ )
+ }
+ }
+ )
+ }
+
+ suspend fun delete(installedApp: InstalledApp) {
+ dao.delete(installedApp)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt
new file mode 100644
index 0000000000..5711d99758
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt
@@ -0,0 +1,55 @@
+package app.revanced.manager.domain.repository
+
+import app.revanced.manager.data.room.AppDatabase
+import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
+import app.revanced.manager.data.room.bundles.PatchBundleEntity
+import app.revanced.manager.data.room.bundles.Source
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+class PatchBundlePersistenceRepository(db: AppDatabase) {
+ private val dao = db.patchBundleDao()
+
+ suspend fun loadConfiguration(): List {
+ val all = dao.all()
+ if (all.isEmpty()) {
+ dao.add(defaultSource)
+ return listOf(defaultSource)
+ }
+
+ return all
+ }
+
+ suspend fun reset() = dao.reset()
+
+ suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) =
+ PatchBundleEntity(
+ uid = generateUid(),
+ name = name,
+ version = null,
+ source = source,
+ autoUpdate = autoUpdate
+ ).also {
+ dao.add(it)
+ }
+
+ suspend fun delete(uid: Int) = dao.remove(uid)
+
+ suspend fun updateVersion(uid: Int, version: String?) =
+ dao.updateVersion(uid, version)
+
+ suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value)
+
+ suspend fun setName(uid: Int, name: String) = dao.setName(uid, name)
+
+ fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged()
+
+ private companion object {
+ val defaultSource = PatchBundleEntity(
+ uid = 0,
+ name = "",
+ version = null,
+ source = Source.API,
+ autoUpdate = false
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt
new file mode 100644
index 0000000000..79bb5cea64
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt
@@ -0,0 +1,184 @@
+package app.revanced.manager.domain.repository
+
+import android.app.Application
+import android.content.Context
+import android.util.Log
+import app.revanced.library.mostCommonCompatibleVersions
+import app.revanced.manager.R
+import app.revanced.manager.data.platform.NetworkInfo
+import app.revanced.manager.data.room.bundles.PatchBundleEntity
+import app.revanced.manager.domain.bundles.APIPatchBundle
+import app.revanced.manager.domain.bundles.JsonPatchBundle
+import app.revanced.manager.data.room.bundles.Source as SourceInfo
+import app.revanced.manager.domain.bundles.LocalPatchBundle
+import app.revanced.manager.domain.bundles.RemotePatchBundle
+import app.revanced.manager.domain.bundles.PatchBundleSource
+import app.revanced.manager.domain.manager.PreferencesManager
+import app.revanced.manager.patcher.patch.PatchInfo
+import app.revanced.manager.util.flatMapLatestAndCombine
+import app.revanced.manager.util.tag
+import app.revanced.manager.util.uiSafe
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.InputStream
+
+class PatchBundleRepository(
+ private val app: Application,
+ private val persistenceRepo: PatchBundlePersistenceRepository,
+ private val networkInfo: NetworkInfo,
+ private val prefs: PreferencesManager,
+) {
+ private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
+
+ private val _sources: MutableStateFlow> =
+ MutableStateFlow(emptyMap())
+ val sources = _sources.map { it.values.toList() }
+
+ val bundles = sources.flatMapLatestAndCombine(
+ combiner = {
+ it.mapNotNull { (uid, state) ->
+ val bundle = state.patchBundleOrNull() ?: return@mapNotNull null
+ uid to bundle
+ }.toMap()
+ }
+ ) {
+ it.state.map { state -> it.uid to state }
+ }
+
+ val suggestedVersions = bundles.map {
+ val allPatches =
+ it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()
+
+ allPatches.mostCommonCompatibleVersions(countUnusedPatches = true)
+ .mapValues { (_, versions) ->
+ if (versions.keys.size < 2)
+ return@mapValues versions.keys.firstOrNull()
+
+ // The entries are ordered from most compatible to least compatible.
+ // If there are entries with the same number of compatible patches, older versions will be first, which is undesirable.
+ // This means we have to pick the last entry we find that has the highest patch count.
+ // The order may change in future versions of ReVanced Library.
+ var currentHighestPatchCount = -1
+ versions.entries.last { (_, patchCount) ->
+ if (patchCount >= currentHighestPatchCount) {
+ currentHighestPatchCount = patchCount
+ true
+ } else false
+ }.key
+ }
+ }
+
+ suspend fun isVersionAllowed(packageName: String, version: String) =
+ withContext(Dispatchers.Default) {
+ if (!prefs.suggestedVersionSafeguard.get()) return@withContext true
+
+ val suggestedVersion = suggestedVersions.first()[packageName] ?: return@withContext true
+ suggestedVersion == version
+ }
+
+ /**
+ * Get the directory of the [PatchBundleSource] with the specified [uid], creating it if needed.
+ */
+ private fun directoryOf(uid: Int) = bundlesDir.resolve(uid.toString()).also { it.mkdirs() }
+
+ private fun PatchBundleEntity.load(): PatchBundleSource {
+ val dir = directoryOf(uid)
+
+ return when (source) {
+ is SourceInfo.Local -> LocalPatchBundle(name, uid, dir)
+ is SourceInfo.API -> APIPatchBundle(name, uid, dir, SourceInfo.API.SENTINEL)
+ is SourceInfo.Remote -> JsonPatchBundle(
+ name,
+ uid,
+ dir,
+ source.url.toString()
+ )
+ }
+ }
+
+ suspend fun reload() = withContext(Dispatchers.Default) {
+ val entities = persistenceRepo.loadConfiguration().onEach {
+ Log.d(tag, "Bundle: $it")
+ }
+
+ _sources.value = entities.associate {
+ it.uid to it.load()
+ }
+ }
+
+ suspend fun reset() = withContext(Dispatchers.Default) {
+ persistenceRepo.reset()
+ _sources.value = emptyMap()
+ bundlesDir.apply {
+ deleteRecursively()
+ mkdirs()
+ }
+
+ reload()
+ }
+
+ suspend fun remove(bundle: PatchBundleSource) = withContext(Dispatchers.Default) {
+ persistenceRepo.delete(bundle.uid)
+ directoryOf(bundle.uid).deleteRecursively()
+
+ _sources.update {
+ it.filterKeys { key ->
+ key != bundle.uid
+ }
+ }
+ }
+
+ private fun addBundle(patchBundle: PatchBundleSource) =
+ _sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
+
+ suspend fun createLocal(patches: InputStream) = withContext(Dispatchers.Default) {
+ val uid = persistenceRepo.create("", SourceInfo.Local).uid
+ val bundle = LocalPatchBundle("", uid, directoryOf(uid))
+
+ bundle.replace(patches)
+ addBundle(bundle)
+ }
+
+ suspend fun createRemote(url: String, autoUpdate: Boolean) = withContext(Dispatchers.Default) {
+ val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate)
+ addBundle(entity.load())
+ }
+
+ private suspend inline fun getBundlesByType() =
+ sources.first().filterIsInstance()
+
+ suspend fun reloadApiBundles() {
+ getBundlesByType().forEach {
+ it.deleteLocalFiles()
+ }
+
+ reload()
+ }
+
+ suspend fun redownloadRemoteBundles() =
+ getBundlesByType().forEach { it.downloadLatest() }
+
+ suspend fun updateCheck() =
+ uiSafe(app, R.string.source_download_fail, "Failed to update bundles") {
+ coroutineScope {
+ if (!networkInfo.isSafe()) {
+ Log.d(tag, "Skipping update check because the network is down or metered.")
+ return@coroutineScope
+ }
+
+ getBundlesByType().forEach {
+ launch {
+ if (!it.getProps().autoUpdate) return@launch
+ Log.d(tag, "Updating patch bundle: ${it.getName()}")
+ it.update()
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt
new file mode 100644
index 0000000000..9fe5fdc298
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt
@@ -0,0 +1,82 @@
+package app.revanced.manager.domain.repository
+
+import android.util.Log
+import app.revanced.manager.data.room.AppDatabase
+import app.revanced.manager.data.room.options.Option
+import app.revanced.manager.data.room.options.OptionGroup
+import app.revanced.manager.patcher.patch.PatchInfo
+import app.revanced.manager.util.Options
+import app.revanced.manager.util.tag
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+class PatchOptionsRepository(db: AppDatabase) {
+ private val dao = db.optionDao()
+
+ private suspend fun getOrCreateGroup(bundleUid: Int, packageName: String) =
+ dao.getGroupId(bundleUid, packageName) ?: OptionGroup(
+ uid = AppDatabase.generateUid(),
+ patchBundle = bundleUid,
+ packageName = packageName
+ ).also { dao.createOptionGroup(it) }.uid
+
+ suspend fun getOptions(
+ packageName: String,
+ bundlePatches: Map>
+ ): Options {
+ val options = dao.getOptions(packageName)
+ // Bundle -> Patches
+ return buildMap>>(options.size) {
+ options.forEach { (sourceUid, bundlePatchOptionsList) ->
+ // Patches -> Patch options
+ this[sourceUid] =
+ bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, dbOption ->
+ val deserializedPatchOptions =
+ bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf)
+
+ val option =
+ bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.key == dbOption.key }
+ if (option != null) {
+ try {
+ deserializedPatchOptions[option.key] =
+ dbOption.value.deserializeFor(option)
+ } catch (e: Option.SerializationException) {
+ Log.w(
+ tag,
+ "Option ${dbOption.patchName}:${option.key} could not be deserialized",
+ e
+ )
+ }
+ }
+
+ bundlePatchOptions
+ }
+ }
+ }
+ }
+
+ suspend fun saveOptions(packageName: String, options: Options) =
+ dao.updateOptions(options.entries.associate { (sourceUid, bundlePatchOptions) ->
+ val groupId = getOrCreateGroup(sourceUid, packageName)
+
+ groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) ->
+ patchOptions.mapNotNull { (key, value) ->
+ val serialized = try {
+ Option.SerializedValue.fromValue(value)
+ } catch (e: Option.SerializationException) {
+ Log.e(tag, "Option $patchName:$key could not be serialized", e)
+ return@mapNotNull null
+ }
+
+ Option(groupId, patchName, key, serialized)
+ }
+ }
+ })
+
+ fun getPackagesWithSavedOptions() =
+ dao.getPackagesWithOptions().map(Iterable::toSet).distinctUntilChanged()
+
+ suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
+ suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
+ suspend fun reset() = dao.reset()
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt
new file mode 100644
index 0000000000..c34e5efd6b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt
@@ -0,0 +1,47 @@
+package app.revanced.manager.domain.repository
+
+import app.revanced.manager.data.room.AppDatabase
+import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
+import app.revanced.manager.data.room.selection.PatchSelection
+
+class PatchSelectionRepository(db: AppDatabase) {
+ private val dao = db.selectionDao()
+
+ private suspend fun getOrCreateSelection(bundleUid: Int, packageName: String) =
+ dao.getSelectionId(bundleUid, packageName) ?: PatchSelection(
+ uid = generateUid(),
+ patchBundle = bundleUid,
+ packageName = packageName
+ ).also { dao.createSelection(it) }.uid
+
+ suspend fun getSelection(packageName: String): Map> =
+ dao.getSelectedPatches(packageName).mapValues { it.value.toSet() }
+
+ suspend fun updateSelection(packageName: String, selection: Map>) =
+ dao.updateSelections(selection.mapKeys { (sourceUid, _) ->
+ getOrCreateSelection(
+ sourceUid,
+ packageName
+ )
+ })
+
+ suspend fun clearSelection(packageName: String) {
+ dao.clearForPackage(packageName)
+ }
+
+ suspend fun reset() = dao.reset()
+
+ suspend fun export(bundleUid: Int): SerializedSelection = dao.exportSelection(bundleUid)
+
+ suspend fun import(bundleUid: Int, selection: SerializedSelection) {
+ dao.clearForPatchBundle(bundleUid)
+ dao.updateSelections(selection.entries.associate { (packageName, patches) ->
+ getOrCreateSelection(bundleUid, packageName) to patches.toSet()
+ })
+ }
+}
+
+/**
+ * A [Map] of package name -> selected patches.
+ */
+typealias SerializedSelection = Map>
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/worker/Worker.kt b/app/src/main/java/app/revanced/manager/domain/worker/Worker.kt
new file mode 100644
index 0000000000..85dc3df355
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/worker/Worker.kt
@@ -0,0 +1,7 @@
+package app.revanced.manager.domain.worker
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+
+abstract class Worker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt b/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt
new file mode 100644
index 0000000000..222a31c48b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt
@@ -0,0 +1,36 @@
+package app.revanced.manager.domain.worker
+
+import android.app.Application
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.OutOfQuotaPolicy
+import androidx.work.WorkManager
+import java.util.UUID
+
+class WorkerRepository(app: Application) {
+ val workManager = WorkManager.getInstance(app)
+
+ /**
+ * The standard WorkManager communication APIs use [androidx.work.Data], which has too many limitations.
+ * We can get around those limits by passing inputs using global variables instead.
+ */
+ val workerInputs = mutableMapOf()
+
+ @Suppress("UNCHECKED_CAST")
+ fun > claimInput(worker: W): A {
+ val data = workerInputs[worker.id] ?: throw IllegalStateException("Worker was not launched via WorkerRepository")
+ workerInputs.remove(worker.id)
+
+ return data as A
+ }
+
+ inline fun , A : Any> launchExpedited(name: String, input: A): UUID {
+ val request =
+ OneTimeWorkRequest.Builder(W::class.java) // create Worker
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .build()
+ workerInputs[request.id] = input
+ workManager.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
+ return request.id
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt
new file mode 100644
index 0000000000..bb36558027
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt
@@ -0,0 +1,42 @@
+package app.revanced.manager.network.api
+
+import android.os.Build
+import app.revanced.manager.domain.manager.PreferencesManager
+import app.revanced.manager.network.dto.ReVancedAsset
+import app.revanced.manager.network.dto.ReVancedGitRepository
+import app.revanced.manager.network.dto.ReVancedInfo
+import app.revanced.manager.network.service.HttpService
+import app.revanced.manager.network.utils.APIResponse
+import app.revanced.manager.network.utils.getOrThrow
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import io.ktor.client.request.url
+
+class ReVancedAPI(
+ private val client: HttpService,
+ private val prefs: PreferencesManager
+) {
+ private suspend fun apiUrl() = prefs.api.get()
+
+ private suspend inline fun request(api: String, route: String): APIResponse =
+ withContext(
+ Dispatchers.IO
+ ) {
+ client.request {
+ url("$api/v4/$route")
+ }
+ }
+
+ private suspend inline fun request(route: String) = request(apiUrl(), route)
+
+ suspend fun getAppUpdate() =
+ getLatestAppInfo().getOrThrow().takeIf { it.version != Build.VERSION.RELEASE }
+
+ suspend fun getLatestAppInfo() = request("manager")
+
+ suspend fun getPatchesUpdate() = request("patches")
+
+ suspend fun getContributors() = request>("contributors")
+
+ suspend fun getInfo(api: String? = null) = request(api ?: apiUrl(), "about")
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt b/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt
new file mode 100644
index 0000000000..a72d60c75f
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt
@@ -0,0 +1,9 @@
+package app.revanced.manager.network.downloader
+
+sealed interface DownloaderPluginState {
+ data object Untrusted : DownloaderPluginState
+
+ data class Loaded(val plugin: LoadedDownloaderPlugin) : DownloaderPluginState
+
+ data class Failed(val throwable: Throwable) : DownloaderPluginState
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt
new file mode 100644
index 0000000000..50ddd561b0
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt
@@ -0,0 +1,15 @@
+package app.revanced.manager.network.downloader
+
+import android.os.Parcelable
+import app.revanced.manager.plugin.downloader.OutputDownloadScope
+import app.revanced.manager.plugin.downloader.GetScope
+import java.io.OutputStream
+
+class LoadedDownloaderPlugin(
+ val packageName: String,
+ val name: String,
+ val version: String,
+ val get: suspend GetScope.(packageName: String, version: String?) -> Pair?,
+ val download: suspend OutputDownloadScope.(data: Parcelable, outputStream: OutputStream) -> Unit,
+ val classLoader: ClassLoader
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt b/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt
new file mode 100644
index 0000000000..a43db93041
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt
@@ -0,0 +1,45 @@
+package app.revanced.manager.network.downloader
+
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+/**
+ * A container for [Parcelable] data returned from downloaders. Instances of this class can be safely stored in a bundle without needing to set the [ClassLoader].
+ */
+class ParceledDownloaderData private constructor(
+ val pluginPackageName: String,
+ private val bundle: Bundle
+) : Parcelable {
+ constructor(plugin: LoadedDownloaderPlugin, data: Parcelable) : this(
+ plugin.packageName,
+ createBundle(data)
+ )
+
+ fun unwrapWith(plugin: LoadedDownloaderPlugin): Parcelable {
+ bundle.classLoader = plugin.classLoader
+
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val className = bundle.getString(CLASS_NAME_KEY)!!
+ val clazz = plugin.classLoader.loadClass(className)
+
+ bundle.getParcelable(DATA_KEY, clazz)!! as Parcelable
+ } else @Suppress("Deprecation") bundle.getParcelable(DATA_KEY)!!
+ }
+
+ private companion object {
+ const val CLASS_NAME_KEY = "class"
+ const val DATA_KEY = "data"
+
+ fun createBundle(data: Parcelable) = Bundle().apply {
+ putParcelable(DATA_KEY, data)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) putString(
+ CLASS_NAME_KEY,
+ data::class.java.canonicalName
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt
new file mode 100644
index 0000000000..64c05f3133
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedAsset.kt
@@ -0,0 +1,18 @@
+package app.revanced.manager.network.dto
+
+import kotlinx.datetime.LocalDateTime
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ReVancedAsset (
+ @SerialName("download_url")
+ val downloadUrl: String,
+ @SerialName("created_at")
+ val createdAt: LocalDateTime,
+ @SerialName("signature_download_url")
+ val signatureDownloadUrl: String? = null,
+ val description: String,
+ val version: String,
+)
+
diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedContributors.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedContributors.kt
new file mode 100644
index 0000000000..6583ba7c09
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedContributors.kt
@@ -0,0 +1,17 @@
+package app.revanced.manager.network.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ReVancedGitRepository(
+ val name: String,
+ val url: String,
+ val contributors: List,
+)
+
+@Serializable
+data class ReVancedContributor(
+ @SerialName("name") val username: String,
+ @SerialName("avatar_url") val avatarUrl: String,
+)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt
new file mode 100644
index 0000000000..89ed7445c6
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt
@@ -0,0 +1,53 @@
+package app.revanced.manager.network.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ReVancedInfo(
+ val name: String,
+ val about: String,
+ val branding: ReVancedBranding,
+ val contact: ReVancedContact,
+ val socials: List,
+ val donations: ReVancedDonation,
+)
+
+@Serializable
+data class ReVancedBranding(
+ val logo: String,
+)
+
+@Serializable
+data class ReVancedContact(
+ val email: String,
+)
+
+@Serializable
+data class ReVancedSocial(
+ val name: String,
+ val url: String,
+ val preferred: Boolean,
+)
+
+@Serializable
+data class ReVancedDonation(
+ val wallets: List,
+ val links: List,
+)
+
+@Serializable
+data class ReVancedWallet(
+ val network: String,
+ @SerialName("currency_code")
+ val currencyCode: String,
+ val address: String,
+ val preferred: Boolean
+)
+
+@Serializable
+data class ReVancedDonationLink(
+ val name: String,
+ val url: String,
+ val preferred: Boolean,
+)
diff --git a/app/src/main/java/app/revanced/manager/network/service/HttpService.kt b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt
new file mode 100644
index 0000000000..e0b69aa686
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/network/service/HttpService.kt
@@ -0,0 +1,101 @@
+package app.revanced.manager.network.service
+
+import android.util.Log
+import app.revanced.manager.network.utils.APIError
+import app.revanced.manager.network.utils.APIFailure
+import app.revanced.manager.network.utils.APIResponse
+import app.revanced.manager.util.tag
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.request.HttpRequestBuilder
+import io.ktor.client.request.get
+import io.ktor.client.request.prepareGet
+import io.ktor.client.request.request
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.isSuccess
+import io.ktor.utils.io.ByteReadChannel
+import io.ktor.utils.io.core.isNotEmpty
+import io.ktor.utils.io.core.readBytes
+import it.skrape.core.htmlDocument
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import java.io.File
+import java.io.OutputStream
+
+/**
+ * @author Aliucord Authors, DiamondMiner88
+ */
+class HttpService(
+ val json: Json,
+ val http: HttpClient,
+) {
+ suspend inline fun request(builder: HttpRequestBuilder.() -> Unit = {}): APIResponse {
+ var body: String? = null
+
+ val response = try {
+ val response = http.request(builder)
+
+ if (response.status.isSuccess()) {
+ body = response.bodyAsText()
+
+ if (T::class == String::class) {
+ return APIResponse.Success(body as T)
+ }
+
+ APIResponse.Success(json.decodeFromString(body))
+ } else {
+ body = try {
+ response.bodyAsText()
+ } catch (t: Throwable) {
+ null
+ }
+
+ Log.e(
+ tag,
+ "Failed to fetch: API error, http status: ${response.status}, body: $body"
+ )
+ APIResponse.Error(APIError(response.status, body))
+ }
+ } catch (t: Throwable) {
+ Log.e(tag, "Failed to fetch: error: $t, body: $body")
+ APIResponse.Failure(APIFailure(t, body))
+ }
+ return response
+ }
+
+ suspend fun streamTo(
+ outputStream: OutputStream,
+ builder: HttpRequestBuilder.() -> Unit
+ ) {
+ http.prepareGet(builder).execute { httpResponse ->
+ if (httpResponse.status.isSuccess()) {
+ val channel: ByteReadChannel = httpResponse.body()
+ withContext(Dispatchers.IO) {
+ while (!channel.isClosedForRead) {
+ val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
+ while (packet.isNotEmpty) {
+ val bytes = packet.readBytes()
+ outputStream.write(bytes)
+ }
+ }
+ }
+
+ } else {
+ throw HttpException(httpResponse.status)
+ }
+ }
+ }
+
+ suspend fun download(
+ saveLocation: File,
+ builder: HttpRequestBuilder.() -> Unit
+ ) = saveLocation.outputStream().use { streamTo(it, builder) }
+
+ suspend fun getHtml(builder: HttpRequestBuilder.() -> Unit) = htmlDocument(
+ html = http.get(builder).bodyAsText()
+ )
+
+ class HttpException(status: HttpStatusCode) : Exception("Failed to fetch: http status: $status")
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/network/utils/APIResponse.kt b/app/src/main/java/app/revanced/manager/network/utils/APIResponse.kt
new file mode 100644
index 0000000000..04765f888f
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/network/utils/APIResponse.kt
@@ -0,0 +1,86 @@
+@file:Suppress("NOTHING_TO_INLINE")
+
+package app.revanced.manager.network.utils
+
+import io.ktor.http.*
+
+/**
+ * @author Aliucord Authors, DiamondMiner88
+ */
+
+sealed interface APIResponse {
+ data class Success(val data: T) : APIResponse
+ data class Error(val error: APIError) : APIResponse
+ data class Failure(val error: APIFailure) : APIResponse
+}
+
+class APIError(code: HttpStatusCode, body: String?) : Exception("HTTP Code $code, Body: $body")
+
+class APIFailure(error: Throwable, body: String?) : Exception(body ?: error.message, error)
+
+inline fun APIResponse.fold(
+ success: (T) -> R,
+ error: (APIError) -> R,
+ failure: (APIFailure) -> R
+): R {
+ return when (this) {
+ is APIResponse.Success -> success(this.data)
+ is APIResponse.Error -> error(this.error)
+ is APIResponse.Failure -> failure(this.error)
+ }
+}
+
+inline fun APIResponse.fold(
+ success: (T) -> R,
+ fail: (Exception) -> R,
+): R {
+ return when (this) {
+ is APIResponse.Success -> success(data)
+ is APIResponse.Error -> fail(error)
+ is APIResponse.Failure -> fail(error)
+ }
+}
+
+@Suppress("UNCHECKED_CAST")
+inline fun APIResponse.transform(block: (T) -> R): APIResponse {
+ return if (this !is APIResponse.Success) {
+ // Error and Failure do not use the generic value
+ this as APIResponse
+ } else {
+ APIResponse.Success(block(data))
+ }
+}
+
+inline fun APIResponse.getOrThrow(): T {
+ return fold(
+ success = { it },
+ fail = { throw it }
+ )
+}
+
+inline fun APIResponse.getOrNull(): T? {
+ return fold(
+ success = { it },
+ fail = { null }
+ )
+}
+
+@Suppress("UNCHECKED_CAST")
+inline fun APIResponse.chain(block: (T) -> APIResponse): APIResponse {
+ return if (this !is APIResponse.Success) {
+ // Error and Failure do not use the generic value
+ this as APIResponse
+ } else {
+ block(data)
+ }
+}
+
+@Suppress("UNCHECKED_CAST")
+inline fun APIResponse.chain(secondary: APIResponse): APIResponse {
+ return if (secondary is APIResponse.Success) {
+ secondary
+ } else {
+ // Error and Failure do not use the generic value
+ this as APIResponse
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/LibraryResolver.kt b/app/src/main/java/app/revanced/manager/patcher/LibraryResolver.kt
new file mode 100644
index 0000000000..e0fe293f9a
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/LibraryResolver.kt
@@ -0,0 +1,10 @@
+package app.revanced.manager.patcher
+
+import android.content.Context
+import java.io.File
+
+abstract class LibraryResolver {
+ protected fun findLibrary(context: Context, searchTerm: String): File? = File(context.applicationInfo.nativeLibraryDir).run {
+ list { _, f -> !File(f).isDirectory && f.contains(searchTerm) }?.firstOrNull()?.let { resolve(it) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/Session.kt b/app/src/main/java/app/revanced/manager/patcher/Session.kt
new file mode 100644
index 0000000000..dd5e7dc4b3
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/Session.kt
@@ -0,0 +1,136 @@
+package app.revanced.manager.patcher
+
+import android.content.Context
+import app.revanced.library.ApkUtils.applyTo
+import app.revanced.manager.R
+import app.revanced.manager.patcher.logger.Logger
+import app.revanced.manager.ui.model.State
+import app.revanced.patcher.Patcher
+import app.revanced.patcher.PatcherConfig
+import app.revanced.patcher.patch.Patch
+import app.revanced.patcher.patch.PatchResult
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.Closeable
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+internal typealias PatchList = List>
+
+class Session(
+ cacheDir: String,
+ frameworkDir: String,
+ aaptPath: String,
+ private val androidContext: Context,
+ private val logger: Logger,
+ private val input: File,
+ private val onPatchCompleted: suspend () -> Unit,
+ private val onProgress: (name: String?, state: State?, message: String?) -> Unit
+) : Closeable {
+ private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
+ onProgress(name, state, message)
+
+ private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
+ private val patcher = Patcher(
+ PatcherConfig(
+ apkFile = input,
+ temporaryFilesPath = tempDir,
+ frameworkFileDirectory = frameworkDir,
+ aaptBinaryPath = aaptPath
+ )
+ )
+
+ private suspend fun Patcher.applyPatchesVerbose(selectedPatches: PatchList) {
+ var nextPatchIndex = 0
+
+ updateProgress(
+ name = androidContext.getString(R.string.executing_patch, selectedPatches[nextPatchIndex]),
+ state = State.RUNNING
+ )
+
+ this().collect { (patch, exception) ->
+ if (patch !in selectedPatches) return@collect
+
+ if (exception != null) {
+ updateProgress(
+ name = androidContext.getString(R.string.failed_to_execute_patch, patch.name),
+ state = State.FAILED,
+ message = exception.stackTraceToString()
+ )
+
+ logger.error("${patch.name} failed:")
+ logger.error(exception.stackTraceToString())
+ throw exception
+ }
+
+ nextPatchIndex++
+
+ onPatchCompleted()
+
+ selectedPatches.getOrNull(nextPatchIndex)?.let { nextPatch ->
+ updateProgress(
+ name = androidContext.getString(R.string.executing_patch, nextPatch.name)
+ )
+ }
+
+ logger.info("${patch.name} succeeded")
+ }
+
+ updateProgress(
+ state = State.COMPLETED,
+ name = androidContext.resources.getQuantityString(
+ R.plurals.patches_executed,
+ selectedPatches.size,
+ selectedPatches.size
+ )
+ )
+ }
+
+ suspend fun run(output: File, selectedPatches: PatchList) {
+ updateProgress(state = State.COMPLETED) // Unpacking
+
+ java.util.logging.Logger.getLogger("").apply {
+ handlers.forEach {
+ it.close()
+ removeHandler(it)
+ }
+
+ addHandler(logger.handler)
+ }
+
+ with(patcher) {
+ logger.info("Merging integrations")
+ this += selectedPatches.toSet()
+
+ logger.info("Applying patches...")
+ applyPatchesVerbose(selectedPatches.sortedBy { it.name })
+ }
+
+ logger.info("Writing patched files...")
+ val result = patcher.get()
+
+ val patched = tempDir.resolve("result.apk")
+ withContext(Dispatchers.IO) {
+ Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+ result.applyTo(patched)
+
+ logger.info("Patched apk saved to $patched")
+
+ withContext(Dispatchers.IO) {
+ Files.move(patched.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+ updateProgress(state = State.COMPLETED) // Saving
+ }
+
+ override fun close() {
+ tempDir.deleteRecursively()
+ patcher.close()
+ }
+
+ companion object {
+ operator fun PatchResult.component1() = patch
+ operator fun PatchResult.component2() = exception
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt b/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt
new file mode 100644
index 0000000000..406c9e9d1f
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/aapt/Aapt.kt
@@ -0,0 +1,12 @@
+package app.revanced.manager.patcher.aapt
+
+import android.content.Context
+import app.revanced.manager.patcher.LibraryResolver
+import android.os.Build.SUPPORTED_ABIS as DEVICE_ABIS
+object Aapt : LibraryResolver() {
+ private val WORKING_ABIS = setOf("arm64-v8a", "x86", "x86_64", "armeabi-v7a")
+
+ fun supportsDevice() = (DEVICE_ABIS intersect WORKING_ABIS).isNotEmpty()
+
+ fun binary(context: Context) = findLibrary(context, "aapt")
+}
diff --git a/app/src/main/java/app/revanced/manager/patcher/logger/Logger.kt b/app/src/main/java/app/revanced/manager/patcher/logger/Logger.kt
new file mode 100644
index 0000000000..88f2a1333a
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/logger/Logger.kt
@@ -0,0 +1,37 @@
+package app.revanced.manager.patcher.logger
+
+import java.util.logging.Handler
+import java.util.logging.Level
+import java.util.logging.LogRecord
+
+abstract class Logger {
+ abstract fun log(level: LogLevel, message: String)
+
+ fun trace(msg: String) = log(LogLevel.TRACE, msg)
+ fun info(msg: String) = log(LogLevel.INFO, msg)
+ fun warn(msg: String) = log(LogLevel.WARN, msg)
+ fun error(msg: String) = log(LogLevel.ERROR, msg)
+
+ val handler = object : Handler() {
+ override fun publish(record: LogRecord) {
+ val msg = record.message
+
+ when (record.level) {
+ Level.INFO -> info(msg)
+ Level.SEVERE -> error(msg)
+ Level.WARNING -> warn(msg)
+ else -> trace(msg)
+ }
+ }
+
+ override fun flush() = Unit
+ override fun close() = Unit
+ }
+}
+
+enum class LogLevel {
+ TRACE,
+ INFO,
+ WARN,
+ ERROR,
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt
new file mode 100644
index 0000000000..2b93a8294a
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt
@@ -0,0 +1,56 @@
+package app.revanced.manager.patcher.patch
+
+import android.util.Log
+import app.revanced.manager.util.tag
+import app.revanced.patcher.patch.Patch
+import app.revanced.patcher.patch.PatchLoader
+import java.io.File
+import java.io.IOException
+import java.util.jar.JarFile
+
+class PatchBundle(val patchesJar: File) {
+ private val loader = object : Iterable> {
+ private fun load(): Iterable> {
+ patchesJar.setReadOnly()
+ return PatchLoader.Dex(setOf(patchesJar))
+ }
+
+ override fun iterator(): Iterator> = load().iterator()
+ }
+
+ init {
+ Log.d(tag, "Loaded patch bundle: $patchesJar")
+ }
+
+ /**
+ * A list containing the metadata of every patch inside this bundle.
+ */
+ val patches = loader.map(::PatchInfo)
+
+ /**
+ * The [java.util.jar.Manifest] of [patchesJar].
+ */
+ private val manifest = try {
+ JarFile(patchesJar).use { it.manifest }
+ } catch (_: IOException) {
+ null
+ }
+
+ fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)
+
+ /**
+ * Load all patches compatible with the specified package.
+ */
+ fun patches(packageName: String) = loader.filter { patch ->
+ val compatiblePackages = patch.compatiblePackages
+ ?: // The patch has no compatibility constraints, which means it is universal.
+ return@filter true
+
+ if (!compatiblePackages.any { (name, _) -> name == packageName }) {
+ // Patch is not compatible with this package.
+ return@filter false
+ }
+
+ true
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt
new file mode 100644
index 0000000000..2babc7f4e1
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt
@@ -0,0 +1,87 @@
+package app.revanced.manager.patcher.patch
+
+import androidx.compose.runtime.Immutable
+import app.revanced.patcher.patch.Patch
+import app.revanced.patcher.patch.Option as PatchOption
+import app.revanced.patcher.patch.resourcePatch
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableSet
+import kotlin.reflect.KType
+
+data class PatchInfo(
+ val name: String,
+ val description: String?,
+ val include: Boolean,
+ val compatiblePackages: ImmutableList?,
+ val options: ImmutableList>?
+) {
+ constructor(patch: Patch<*>) : this(
+ patch.name.orEmpty(),
+ patch.description,
+ patch.use,
+ patch.compatiblePackages?.map { (pkgName, versions) ->
+ CompatiblePackage(
+ pkgName,
+ versions?.toImmutableSet()
+ )
+ }?.toImmutableList(),
+ patch.options.map { (_, option) -> Option(option) }.ifEmpty { null }?.toImmutableList()
+ )
+
+ fun compatibleWith(packageName: String) =
+ compatiblePackages?.any { it.packageName == packageName } ?: true
+
+ fun supports(packageName: String, versionName: String?): Boolean {
+ val packages = compatiblePackages ?: return true // Universal patch
+
+ return packages.any { pkg ->
+ if (pkg.packageName != packageName) return@any false
+ if (pkg.versions == null) return@any true
+
+ versionName != null && versionName in pkg.versions
+ }
+ }
+
+ /**
+ * Create a fake [Patch] with the same metadata as the [PatchInfo] instance.
+ * The resulting patch cannot be executed.
+ * This is necessary because some functions in ReVanced Library only accept full [Patch] objects.
+ */
+ fun toPatcherPatch(): Patch<*> =
+ resourcePatch(name = name, description = description, use = include) {
+ compatiblePackages?.let { pkgs ->
+ compatibleWith(*pkgs.map { it.packageName to it.versions }.toTypedArray())
+ }
+ }
+}
+
+@Immutable
+data class CompatiblePackage(
+ val packageName: String,
+ val versions: ImmutableSet?
+)
+
+@Immutable
+data class Option(
+ val title: String,
+ val key: String,
+ val description: String,
+ val required: Boolean,
+ val type: KType,
+ val default: T?,
+ val presets: Map?,
+ val validator: (T?) -> Boolean,
+) {
+ constructor(option: PatchOption) : this(
+ option.title ?: option.key,
+ option.key,
+ option.description.orEmpty(),
+ option.required,
+ option.type,
+ option.default,
+ option.values,
+ { option.validator(option, it) },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt
new file mode 100644
index 0000000000..eb50bd35b9
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt
@@ -0,0 +1,66 @@
+package app.revanced.manager.patcher.runtime
+
+import android.content.Context
+import app.revanced.manager.patcher.Session
+import app.revanced.manager.patcher.logger.Logger
+import app.revanced.manager.patcher.worker.ProgressEventHandler
+import app.revanced.manager.ui.model.State
+import app.revanced.manager.util.Options
+import app.revanced.manager.util.PatchSelection
+import java.io.File
+
+/**
+ * Simple [Runtime] implementation that runs the patcher using coroutines.
+ */
+class CoroutineRuntime(private val context: Context) : Runtime(context) {
+ override suspend fun execute(
+ inputFile: String,
+ outputFile: String,
+ packageName: String,
+ selectedPatches: PatchSelection,
+ options: Options,
+ logger: Logger,
+ onPatchCompleted: suspend () -> Unit,
+ onProgress: ProgressEventHandler,
+ ) {
+ val bundles = bundles()
+
+ val selectedBundles = selectedPatches.keys
+ val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
+ .mapValues { (_, bundle) -> bundle.patches(packageName) }
+
+ val patchList = selectedPatches.flatMap { (bundle, selected) ->
+ allPatches[bundle]?.filter { selected.contains(it.name) }
+ ?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
+ }
+
+ // Set all patch options.
+ options.forEach { (bundle, bundlePatchOptions) ->
+ val patches = allPatches[bundle] ?: return@forEach
+ bundlePatchOptions.forEach { (patchName, configuredPatchOptions) ->
+ val patchOptions = patches.single { it.name == patchName }.options
+ configuredPatchOptions.forEach { (key, value) ->
+ patchOptions[key] = value
+ }
+ }
+ }
+
+ onProgress(null, State.COMPLETED, null) // Loading patches
+
+ Session(
+ cacheDir,
+ frameworkPath,
+ aaptPath,
+ context,
+ logger,
+ File(inputFile),
+ onPatchCompleted = onPatchCompleted,
+ onProgress
+ ).use { session ->
+ session.run(
+ File(outputFile),
+ patchList
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt
new file mode 100644
index 0000000000..d7e9d342fe
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt
@@ -0,0 +1,188 @@
+package app.revanced.manager.patcher.runtime
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.util.Log
+import androidx.core.content.ContextCompat
+import app.revanced.manager.BuildConfig
+import app.revanced.manager.patcher.runtime.process.IPatcherEvents
+import app.revanced.manager.patcher.runtime.process.IPatcherProcess
+import app.revanced.manager.patcher.LibraryResolver
+import app.revanced.manager.patcher.logger.Logger
+import app.revanced.manager.patcher.runtime.process.Parameters
+import app.revanced.manager.patcher.runtime.process.PatchConfiguration
+import app.revanced.manager.patcher.runtime.process.PatcherProcess
+import app.revanced.manager.patcher.worker.ProgressEventHandler
+import app.revanced.manager.ui.model.State
+import app.revanced.manager.util.Options
+import app.revanced.manager.util.PM
+import app.revanced.manager.util.PatchSelection
+import app.revanced.manager.util.tag
+import com.github.pgreze.process.Redirect
+import com.github.pgreze.process.process
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+import org.koin.core.component.inject
+
+/**
+ * Runs the patcher in another process by using the app_process binary and IPC.
+ */
+class ProcessRuntime(private val context: Context) : Runtime(context) {
+ private val pm: PM by inject()
+
+ private suspend fun awaitBinderConnection(): IPatcherProcess {
+ val binderFuture = CompletableDeferred()
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val binder =
+ intent.getBundleExtra(INTENT_BUNDLE_KEY)?.getBinder(BUNDLE_BINDER_KEY)!!
+
+ binderFuture.complete(IPatcherProcess.Stub.asInterface(binder))
+ }
+ }
+
+ ContextCompat.registerReceiver(context, receiver, IntentFilter().apply {
+ addAction(CONNECT_TO_APP_ACTION)
+ }, ContextCompat.RECEIVER_NOT_EXPORTED)
+
+ return try {
+ withTimeout(10000L) {
+ binderFuture.await()
+ }
+ } finally {
+ context.unregisterReceiver(receiver)
+ }
+ }
+
+ override suspend fun execute(
+ inputFile: String,
+ outputFile: String,
+ packageName: String,
+ selectedPatches: PatchSelection,
+ options: Options,
+ logger: Logger,
+ onPatchCompleted: suspend () -> Unit,
+ onProgress: ProgressEventHandler,
+ ) = coroutineScope {
+ // Get the location of our own Apk.
+ val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir
+
+ val limit = "${prefs.patcherProcessMemoryLimit.get()}M"
+ val propOverride = resolvePropOverride(context)?.absolutePath
+ ?: throw Exception("Couldn't find prop override library")
+
+ val env =
+ System.getenv().toMutableMap().apply {
+ putAll(
+ mapOf(
+ "CLASSPATH" to managerBaseApk,
+ // Override the props used by ART to set the memory limit.
+ "LD_PRELOAD" to propOverride,
+ "PROP_dalvik.vm.heapgrowthlimit" to limit,
+ "PROP_dalvik.vm.heapsize" to limit,
+ )
+ )
+ }
+
+ launch(Dispatchers.IO) {
+ val result = process(
+ APP_PROCESS_BIN_PATH,
+ "-Djava.io.tmpdir=$cacheDir", // The process will use /tmp if this isn't set, which is a problem because that folder is not accessible on Android.
+ "/", // The unused cmd-dir parameter
+ "--nice-name=${context.packageName}:Patcher",
+ PatcherProcess::class.java.name, // The class with the main function.
+ context.packageName,
+ env = env,
+ stdout = Redirect.CAPTURE,
+ stderr = Redirect.CAPTURE,
+ ) { line ->
+ // The process shouldn't generally be writing to stdio. Log any lines we get as warnings.
+ logger.warn("[STDIO]: $line")
+ }
+
+ Log.d(tag, "Process finished with exit code ${result.resultCode}")
+
+ if (result.resultCode != 0) throw Exception("Process exited with nonzero exit code ${result.resultCode}")
+ }
+
+ val patching = CompletableDeferred()
+
+ launch(Dispatchers.IO) {
+ val binder = awaitBinderConnection()
+
+ // Android Studio's fast deployment feature causes an issue where the other process will be running older code compared to the main process.
+ // The patcher process is running outdated code if the randomly generated BUILD_ID numbers don't match.
+ // To fix it, clear the cache in the Android settings or disable fast deployment (Run configurations -> Edit Configurations -> app -> Enable "always deploy with package manager").
+ if (binder.buildId() != BuildConfig.BUILD_ID) throw Exception("app_process is running outdated code. Clear the app cache or disable disable Android 11 deployment optimizations in your IDE")
+
+ val eventHandler = object : IPatcherEvents.Stub() {
+ override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg)
+
+ override fun patchSucceeded() {
+ launch { onPatchCompleted() }
+ }
+
+ override fun progress(name: String?, state: String?, msg: String?) =
+ onProgress(name, state?.let { enumValueOf(it) }, msg)
+
+ override fun finished(exceptionStackTrace: String?) {
+ binder.exit()
+
+ exceptionStackTrace?.let {
+ patching.completeExceptionally(RemoteFailureException(it))
+ return
+ }
+ patching.complete(Unit)
+ }
+ }
+
+ val bundles = bundles()
+
+ val parameters = Parameters(
+ aaptPath = aaptPath,
+ frameworkDir = frameworkPath,
+ cacheDir = cacheDir,
+ packageName = packageName,
+ inputFile = inputFile,
+ outputFile = outputFile,
+ configurations = selectedPatches.map { (id, patches) ->
+ val bundle = bundles[id]!!
+
+ PatchConfiguration(
+ bundle.patchesJar.absolutePath,
+ patches,
+ options[id].orEmpty()
+ )
+ }
+ )
+
+ binder.start(parameters, eventHandler)
+ }
+
+ // Wait until patching finishes.
+ patching.await()
+ }
+
+ companion object : LibraryResolver() {
+ private const val APP_PROCESS_BIN_PATH = "/system/bin/app_process"
+
+ const val CONNECT_TO_APP_ACTION = "CONNECT_TO_APP_ACTION"
+ const val INTENT_BUNDLE_KEY = "BUNDLE"
+ const val BUNDLE_BINDER_KEY = "BINDER"
+
+ private fun resolvePropOverride(context: Context) = findLibrary(context, "prop_override")
+ }
+
+ /**
+ * An [Exception] occured in the remote process while patching.
+ *
+ * @param originalStackTrace The stack trace of the original [Exception].
+ */
+ class RemoteFailureException(val originalStackTrace: String) : Exception()
+}
+
diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt
new file mode 100644
index 0000000000..7f4616bcd5
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/runtime/Runtime.kt
@@ -0,0 +1,40 @@
+package app.revanced.manager.patcher.runtime
+
+import android.content.Context
+import app.revanced.manager.data.platform.Filesystem
+import app.revanced.manager.domain.manager.PreferencesManager
+import app.revanced.manager.domain.repository.PatchBundleRepository
+import app.revanced.manager.patcher.aapt.Aapt
+import app.revanced.manager.patcher.logger.Logger
+import app.revanced.manager.patcher.worker.ProgressEventHandler
+import app.revanced.manager.util.Options
+import app.revanced.manager.util.PatchSelection
+import kotlinx.coroutines.flow.first
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import java.io.FileNotFoundException
+
+sealed class Runtime(context: Context) : KoinComponent {
+ private val fs: Filesystem by inject()
+ private val patchBundlesRepo: PatchBundleRepository by inject()
+ protected val prefs: PreferencesManager by inject()
+
+ protected val cacheDir: String = fs.tempDir.absolutePath
+ protected val aaptPath = Aapt.binary(context)?.absolutePath
+ ?: throw FileNotFoundException("Could not resolve aapt.")
+ protected val frameworkPath: String =
+ context.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
+
+ protected suspend fun bundles() = patchBundlesRepo.bundles.first()
+
+ abstract suspend fun execute(
+ inputFile: String,
+ outputFile: String,
+ packageName: String,
+ selectedPatches: PatchSelection,
+ options: Options,
+ logger: Logger,
+ onPatchCompleted: suspend () -> Unit,
+ onProgress: ProgressEventHandler,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt
new file mode 100644
index 0000000000..b00d558a98
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt
@@ -0,0 +1,23 @@
+package app.revanced.manager.patcher.runtime.process
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import kotlinx.parcelize.RawValue
+
+@Parcelize
+data class Parameters(
+ val cacheDir: String,
+ val aaptPath: String,
+ val frameworkDir: String,
+ val packageName: String,
+ val inputFile: String,
+ val outputFile: String,
+ val configurations: List,
+) : Parcelable
+
+@Parcelize
+data class PatchConfiguration(
+ val bundlePath: String,
+ val patches: Set,
+ val options: @RawValue Map>
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt
new file mode 100644
index 0000000000..b0f8e248a9
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt
@@ -0,0 +1,123 @@
+package app.revanced.manager.patcher.runtime.process
+
+import android.app.ActivityThread
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.os.Looper
+import app.revanced.manager.BuildConfig
+import app.revanced.manager.patcher.Session
+import app.revanced.manager.patcher.logger.LogLevel
+import app.revanced.manager.patcher.logger.Logger
+import app.revanced.manager.patcher.patch.PatchBundle
+import app.revanced.manager.patcher.runtime.ProcessRuntime
+import app.revanced.manager.ui.model.State
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.io.File
+import kotlin.system.exitProcess
+
+/**
+ * The main class that runs inside the runner process launched by [ProcessRuntime].
+ */
+class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
+ private var eventBinder: IPatcherEvents? = null
+
+ private val scope =
+ CoroutineScope(Dispatchers.Default + CoroutineExceptionHandler { _, throwable ->
+ // Try to send the exception information to the main app.
+ eventBinder?.let {
+ try {
+ it.finished(throwable.stackTraceToString())
+ return@CoroutineExceptionHandler
+ } catch (_: Exception) {
+ }
+ }
+
+ throwable.printStackTrace()
+ exitProcess(1)
+ })
+
+ override fun buildId() = BuildConfig.BUILD_ID
+ override fun exit() = exitProcess(0)
+
+ override fun start(parameters: Parameters, events: IPatcherEvents) {
+ eventBinder = events
+
+ scope.launch {
+ val logger = object : Logger() {
+ override fun log(level: LogLevel, message: String) =
+ events.log(level.name, message)
+ }
+
+ logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB")
+
+ val patchList = parameters.configurations.flatMap { config ->
+ val bundle = PatchBundle(File(config.bundlePath))
+
+ val patches =
+ bundle.patches(parameters.packageName).filter { it.name in config.patches }
+ .associateBy { it.name }
+
+ config.options.forEach { (patchName, opts) ->
+ val patchOptions = patches[patchName]?.options
+ ?: throw Exception("Patch with name $patchName does not exist.")
+
+ opts.forEach { (key, value) ->
+ patchOptions[key] = value
+ }
+ }
+
+ patches.values
+ }
+
+ events.progress(null, State.COMPLETED.name, null) // Loading patches
+
+ Session(
+ cacheDir = parameters.cacheDir,
+ aaptPath = parameters.aaptPath,
+ frameworkDir = parameters.frameworkDir,
+ androidContext = context,
+ logger = logger,
+ input = File(parameters.inputFile),
+ onPatchCompleted = { events.patchSucceeded() },
+ onProgress = { name, state, message ->
+ events.progress(name, state?.name, message)
+ }
+ ).use {
+ it.run(File(parameters.outputFile), patchList)
+ }
+
+ events.finished(null)
+ }
+ }
+
+ companion object {
+ @JvmStatic
+ fun main(args: Array) {
+ Looper.prepare()
+
+ val managerPackageName = args[0]
+
+ // Abuse hidden APIs to get a context.
+ val systemContext = ActivityThread.systemMain().systemContext as Context
+ val appContext = systemContext.createPackageContext(managerPackageName, 0)
+
+ val ipcInterface = PatcherProcess(appContext)
+
+ appContext.sendBroadcast(Intent().apply {
+ action = ProcessRuntime.CONNECT_TO_APP_ACTION
+ `package` = managerPackageName
+
+ putExtra(ProcessRuntime.INTENT_BUNDLE_KEY, Bundle().apply {
+ putBinder(ProcessRuntime.BUNDLE_BINDER_KEY, ipcInterface.asBinder())
+ })
+ })
+
+ Looper.loop()
+ exitProcess(1) // Shouldn't happen
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt
new file mode 100644
index 0000000000..5096170caa
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt
@@ -0,0 +1,257 @@
+package app.revanced.manager.patcher.worker
+
+import android.app.Activity
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.graphics.drawable.Icon
+import android.os.Build
+import android.os.Parcelable
+import android.os.PowerManager
+import android.util.Log
+import androidx.activity.result.ActivityResult
+import androidx.core.content.ContextCompat
+import androidx.work.ForegroundInfo
+import androidx.work.WorkerParameters
+import app.revanced.manager.R
+import app.revanced.manager.data.platform.Filesystem
+import app.revanced.manager.data.room.apps.installed.InstallType
+import app.revanced.manager.domain.installer.RootInstaller
+import app.revanced.manager.domain.manager.KeystoreManager
+import app.revanced.manager.domain.manager.PreferencesManager
+import app.revanced.manager.domain.repository.DownloadedAppRepository
+import app.revanced.manager.domain.repository.DownloaderPluginRepository
+import app.revanced.manager.domain.repository.InstalledAppRepository
+import app.revanced.manager.domain.worker.Worker
+import app.revanced.manager.domain.worker.WorkerRepository
+import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
+import app.revanced.manager.patcher.logger.Logger
+import app.revanced.manager.patcher.runtime.CoroutineRuntime
+import app.revanced.manager.patcher.runtime.ProcessRuntime
+import app.revanced.manager.plugin.downloader.GetScope
+import app.revanced.manager.plugin.downloader.PluginHostApi
+import app.revanced.manager.plugin.downloader.UserInteractionException
+import app.revanced.manager.ui.model.SelectedApp
+import app.revanced.manager.ui.model.State
+import app.revanced.manager.util.Options
+import app.revanced.manager.util.PM
+import app.revanced.manager.util.PatchSelection
+import app.revanced.manager.util.tag
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import java.io.File
+
+typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit
+
+@OptIn(PluginHostApi::class)
+class PatcherWorker(
+ context: Context,
+ parameters: WorkerParameters
+) : Worker(context, parameters), KoinComponent {
+ private val workerRepository: WorkerRepository by inject()
+ private val prefs: PreferencesManager by inject()
+ private val keystoreManager: KeystoreManager by inject()
+ private val downloaderPluginRepository: DownloaderPluginRepository by inject()
+ private val downloadedAppRepository: DownloadedAppRepository by inject()
+ private val pm: PM by inject()
+ private val fs: Filesystem by inject()
+ private val installedAppRepository: InstalledAppRepository by inject()
+ private val rootInstaller: RootInstaller by inject()
+
+ class Args(
+ val input: SelectedApp,
+ val output: String,
+ val selectedPatches: PatchSelection,
+ val options: Options,
+ val logger: Logger,
+ val onDownloadProgress: suspend (Pair?) -> Unit,
+ val onPatchCompleted: suspend () -> Unit,
+ val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
+ val setInputFile: suspend (File) -> Unit,
+ val onProgress: ProgressEventHandler
+ ) {
+ val packageName get() = input.packageName
+ }
+
+ override suspend fun getForegroundInfo() =
+ ForegroundInfo(
+ 1,
+ createNotification(),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0
+ )
+
+ private fun createNotification(): Notification {
+ val notificationIntent = Intent(applicationContext, PatcherWorker::class.java)
+ val pendingIntent: PendingIntent = PendingIntent.getActivity(
+ applicationContext, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
+ )
+ val channel = NotificationChannel(
+ "revanced-patcher-patching", "Patching", NotificationManager.IMPORTANCE_HIGH
+ )
+ val notificationManager =
+ ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
+ notificationManager!!.createNotificationChannel(channel)
+ return Notification.Builder(applicationContext, channel.id)
+ .setContentTitle(applicationContext.getText(R.string.app_name))
+ .setContentText(applicationContext.getText(R.string.patcher_notification_message))
+ .setLargeIcon(Icon.createWithResource(applicationContext, R.drawable.ic_notification))
+ .setSmallIcon(Icon.createWithResource(applicationContext, R.drawable.ic_notification))
+ .setContentIntent(pendingIntent).build()
+ }
+
+ override suspend fun doWork(): Result {
+ if (runAttemptCount > 0) {
+ Log.d(tag, "Android requested retrying but retrying is disabled.".logFmt())
+ return Result.failure()
+ }
+
+ try {
+ // This does not always show up for some reason.
+ setForeground(getForegroundInfo())
+ } catch (e: Exception) {
+ Log.d(tag, "Failed to set foreground info:", e)
+ }
+
+ val wakeLock: PowerManager.WakeLock =
+ (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
+ .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::Patcher")
+ .apply {
+ acquire(10 * 60 * 1000L)
+ Log.d(tag, "Acquired wakelock.")
+ }
+
+ val args = workerRepository.claimInput(this)
+
+ return try {
+ runPatcher(args)
+ } finally {
+ wakeLock.release()
+ }
+ }
+
+ private suspend fun runPatcher(args: Args): Result {
+
+ fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
+ args.onProgress(name, state, message)
+
+ val patchedApk = fs.tempDir.resolve("patched.apk")
+
+ return try {
+ if (args.input is SelectedApp.Installed) {
+ installedAppRepository.get(args.packageName)?.let {
+ if (it.installType == InstallType.MOUNT) {
+ rootInstaller.unmount(args.packageName)
+ }
+ }
+ }
+
+ suspend fun download(plugin: LoadedDownloaderPlugin, data: Parcelable) =
+ downloadedAppRepository.download(
+ plugin,
+ data,
+ args.packageName,
+ args.input.version,
+ onDownload = args.onDownloadProgress
+ ).also {
+ args.setInputFile(it)
+ updateProgress(state = State.COMPLETED) // Download APK
+ }
+
+ val inputFile = when (val selectedApp = args.input) {
+ is SelectedApp.Download -> {
+ val (plugin, data) = downloaderPluginRepository.unwrapParceledData(selectedApp.data)
+
+ download(plugin, data)
+ }
+
+ is SelectedApp.Search -> {
+ downloaderPluginRepository.loadedPluginsFlow.first()
+ .firstNotNullOfOrNull { plugin ->
+ try {
+ val getScope = object : GetScope {
+ override val pluginPackageName = plugin.packageName
+ override val hostPackageName = applicationContext.packageName
+ override suspend fun requestStartActivity(intent: Intent): Intent? {
+ val result = args.handleStartActivityRequest(plugin, intent)
+ return when (result.resultCode) {
+ Activity.RESULT_OK -> result.data
+ Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
+ else -> throw UserInteractionException.Activity.NotCompleted(
+ result.resultCode,
+ result.data
+ )
+ }
+ }
+ }
+ withContext(Dispatchers.IO) {
+ plugin.get(
+ getScope,
+ selectedApp.packageName,
+ selectedApp.version
+ )
+ }?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version }
+ } catch (e: UserInteractionException.Activity.NotCompleted) {
+ throw e
+ } catch (_: UserInteractionException) {
+ null
+ }?.let { (data, _) -> download(plugin, data) }
+ } ?: throw Exception("App is not available.")
+ }
+
+ is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) }
+ is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo!!.sourceDir)
+ }
+
+ val runtime = if (prefs.useProcessRuntime.get()) {
+ ProcessRuntime(applicationContext)
+ } else {
+ CoroutineRuntime(applicationContext)
+ }
+
+ runtime.execute(
+ inputFile.absolutePath,
+ patchedApk.absolutePath,
+ args.packageName,
+ args.selectedPatches,
+ args.options,
+ args.logger,
+ args.onPatchCompleted,
+ args.onProgress
+ )
+
+ keystoreManager.sign(patchedApk, File(args.output))
+ updateProgress(state = State.COMPLETED) // Signing
+
+ Log.i(tag, "Patching succeeded".logFmt())
+ Result.success()
+ } catch (e: ProcessRuntime.RemoteFailureException) {
+ Log.e(
+ tag,
+ "An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()
+ )
+ updateProgress(state = State.FAILED, message = e.originalStackTrace)
+ Result.failure()
+ } catch (e: Exception) {
+ Log.e(tag, "An exception occurred while patching".logFmt(), e)
+ updateProgress(state = State.FAILED, message = e.stackTraceToString())
+ Result.failure()
+ } finally {
+ patchedApk.delete()
+ if (args.input is SelectedApp.Local && args.input.temporary) {
+ args.input.file.delete()
+ }
+ }
+ }
+
+ companion object {
+ private const val LOG_PREFIX = "[Worker]"
+ private fun String.logFmt() = "$LOG_PREFIX $this"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/service/InstallService.kt b/app/src/main/java/app/revanced/manager/service/InstallService.kt
new file mode 100644
index 0000000000..7bf2d2131a
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/service/InstallService.kt
@@ -0,0 +1,53 @@
+package app.revanced.manager.service
+
+import android.app.Service
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.os.IBinder
+
+@Suppress("DEPRECATION")
+class InstallService : Service() {
+
+ override fun onStartCommand(
+ intent: Intent, flags: Int, startId: Int
+ ): Int {
+ val extraStatus = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)
+ val extraStatusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+ val extraPackageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
+ when (extraStatus) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ startActivity(if (Build.VERSION.SDK_INT >= 33) {
+ intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
+ } else {
+ intent.getParcelableExtra(Intent.EXTRA_INTENT)
+ }.apply {
+ this?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ })
+ }
+
+ else -> {
+ sendBroadcast(Intent().apply {
+ action = APP_INSTALL_ACTION
+ `package` = packageName
+ putExtra(EXTRA_INSTALL_STATUS, extraStatus)
+ putExtra(EXTRA_INSTALL_STATUS_MESSAGE, extraStatusMessage)
+ putExtra(EXTRA_PACKAGE_NAME, extraPackageName)
+ })
+ }
+ }
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ const val APP_INSTALL_ACTION = "APP_INSTALL_ACTION"
+
+ const val EXTRA_INSTALL_STATUS = "EXTRA_INSTALL_STATUS"
+ const val EXTRA_INSTALL_STATUS_MESSAGE = "EXTRA_INSTALL_STATUS_MESSAGE"
+ const val EXTRA_PACKAGE_NAME = "EXTRA_PACKAGE_NAME"
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/service/RootService.kt b/app/src/main/java/app/revanced/manager/service/RootService.kt
new file mode 100644
index 0000000000..ed475e50fc
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/service/RootService.kt
@@ -0,0 +1,16 @@
+package app.revanced.manager.service
+
+import android.content.Intent
+import android.os.IBinder
+import app.revanced.manager.IRootSystemService
+import com.topjohnwu.superuser.ipc.RootService
+import com.topjohnwu.superuser.nio.FileSystemManager
+
+class ManagerRootService : RootService() {
+ class RootSystemService : IRootSystemService.Stub() {
+ override fun getFileSystemService() =
+ FileSystemManager.getService()
+ }
+
+ override fun onBind(intent: Intent): IBinder = RootSystemService()
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/service/UninstallService.kt b/app/src/main/java/app/revanced/manager/service/UninstallService.kt
new file mode 100644
index 0000000000..6bb4d4fdfe
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/service/UninstallService.kt
@@ -0,0 +1,53 @@
+package app.revanced.manager.service
+
+import android.app.Service
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.os.IBinder
+
+@Suppress("DEPRECATION")
+class UninstallService : Service() {
+
+ override fun onStartCommand(
+ intent: Intent,
+ flags: Int,
+ startId: Int
+ ): Int {
+ val extraStatus = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -999)
+ val extraStatusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
+
+ when (extraStatus) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ startActivity(if (Build.VERSION.SDK_INT >= 33) {
+ intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
+ } else {
+ intent.getParcelableExtra(Intent.EXTRA_INTENT)
+ }.apply {
+ this?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ })
+ }
+
+ else -> {
+ sendBroadcast(Intent().apply {
+ action = APP_UNINSTALL_ACTION
+ `package` = packageName
+ putExtra(EXTRA_UNINSTALL_STATUS, extraStatus)
+ putExtra(EXTRA_UNINSTALL_STATUS_MESSAGE, extraStatusMessage)
+ })
+ }
+ }
+ stopSelf()
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ const val APP_UNINSTALL_ACTION = "APP_UNINSTALL_ACTION"
+
+ const val EXTRA_UNINSTALL_STATUS = "EXTRA_UNINSTALL_STATUS"
+ const val EXTRA_UNINSTALL_STATUS_MESSAGE = "EXTRA_INSTALL_STATUS_MESSAGE"
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/AlertDialogExtended.kt b/app/src/main/java/app/revanced/manager/ui/component/AlertDialogExtended.kt
new file mode 100644
index 0000000000..c2089d5811
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/AlertDialogExtended.kt
@@ -0,0 +1,152 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialogDefaults
+import androidx.compose.material3.BasicAlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ProvideTextStyle
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun AlertDialogExtended(
+ modifier: Modifier = Modifier,
+ onDismissRequest: () -> Unit,
+ confirmButton: @Composable () -> Unit,
+ dismissButton: @Composable (() -> Unit)? = null,
+ tertiaryButton: @Composable (() -> Unit)? = null,
+ icon: @Composable (() -> Unit)? = null,
+ title: @Composable (() -> Unit)? = null,
+ text: @Composable (() -> Unit)? = null,
+ shape: Shape = AlertDialogDefaults.shape,
+ containerColor: Color = AlertDialogDefaults.containerColor,
+ iconContentColor: Color = AlertDialogDefaults.iconContentColor,
+ titleContentColor: Color = AlertDialogDefaults.titleContentColor,
+ textContentColor: Color = AlertDialogDefaults.textContentColor,
+ tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
+ textHorizontalPadding: PaddingValues = TextHorizontalPadding
+) {
+ BasicAlertDialog(onDismissRequest = onDismissRequest) {
+ Surface(
+ modifier = modifier,
+ shape = shape,
+ color = containerColor,
+ tonalElevation = tonalElevation,
+ ) {
+ Column(modifier = Modifier.padding(vertical = 24.dp)) {
+ Column(
+ modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth()
+ ) {
+ icon?.let {
+ ContentStyle(color = iconContentColor) {
+ Box(
+ Modifier
+ .padding(bottom = 16.dp)
+ .align(Alignment.CenterHorizontally)
+ ) {
+ icon()
+ }
+ }
+ }
+ title?.let {
+ ContentStyle(
+ color = titleContentColor,
+ textStyle = MaterialTheme.typography.headlineSmall
+ ) {
+ Box(
+ // Align the title to the center when an icon is present.
+ Modifier
+ .padding(bottom = 16.dp)
+ .align(
+ if (icon == null) {
+ Alignment.Start
+ } else {
+ Alignment.CenterHorizontally
+ }
+ )
+ ) {
+ title()
+ }
+ }
+ }
+ }
+ text?.let {
+ ContentStyle(
+ color = textContentColor,
+ textStyle = MaterialTheme.typography.bodyMedium
+ ) {
+ Box(
+ Modifier
+ .weight(weight = 1f, fill = false)
+ .padding(bottom = 24.dp)
+ .padding(textHorizontalPadding)
+ .align(Alignment.Start)
+ ) {
+ text()
+ }
+ }
+ }
+ Box(
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ ) {
+ ContentStyle(
+ color = MaterialTheme.colorScheme.primary,
+ textStyle = MaterialTheme.typography.labelLarge
+ ) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(
+ 12.dp,
+ if (tertiaryButton != null) Alignment.Start else Alignment.End
+ ),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ tertiaryButton?.let {
+ it()
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ dismissButton?.invoke()
+ confirmButton()
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ContentStyle(
+ color: Color = LocalContentColor.current,
+ textStyle: TextStyle = LocalTextStyle.current,
+ content: @Composable () -> Unit
+) {
+ CompositionLocalProvider(LocalContentColor provides color) {
+ ProvideTextStyle(textStyle) {
+ content()
+ }
+ }
+}
+
+val TextHorizontalPadding = PaddingValues(horizontal = 24.dp)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/AppIcon.kt b/app/src/main/java/app/revanced/manager/ui/component/AppIcon.kt
new file mode 100644
index 0000000000..0d8cd822a0
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/AppIcon.kt
@@ -0,0 +1,47 @@
+package app.revanced.manager.ui.component
+
+import android.content.pm.PackageInfo
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Android
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import coil.compose.AsyncImage
+import io.github.fornewid.placeholder.material3.placeholder
+
+@Composable
+fun AppIcon(
+ packageInfo: PackageInfo?,
+ contentDescription: String?,
+ modifier: Modifier = Modifier
+) {
+ var showPlaceHolder by rememberSaveable { mutableStateOf(true) }
+
+ if (packageInfo == null) {
+ val image = rememberVectorPainter(Icons.Default.Android)
+ val colorFilter = ColorFilter.tint(LocalContentColor.current)
+
+ Image(
+ image,
+ contentDescription,
+ modifier,
+ colorFilter = colorFilter
+ )
+ } else {
+ AsyncImage(
+ packageInfo,
+ contentDescription,
+ Modifier.placeholder(visible = showPlaceHolder, color = MaterialTheme.colorScheme.inverseOnSurface, shape = RoundedCornerShape(100)).then(modifier),
+ onSuccess = { showPlaceHolder = false }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt b/app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt
new file mode 100644
index 0000000000..6d45b20bee
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/AppInfo.kt
@@ -0,0 +1,39 @@
+package app.revanced.manager.ui.component
+
+import android.content.pm.PackageInfo
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun AppInfo(appInfo: PackageInfo?, placeholderLabel: String? = null, extraContent: @Composable () -> Unit = {}) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ AppIcon(
+ appInfo,
+ contentDescription = null,
+ modifier = Modifier
+ .size(100.dp)
+ .padding(bottom = 5.dp)
+ )
+
+ AppLabel(
+ appInfo,
+ modifier = Modifier.padding(top = 16.dp),
+ style = MaterialTheme.typography.titleLarge,
+ defaultText = placeholderLabel
+ )
+
+ extraContent()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/AppLabel.kt b/app/src/main/java/app/revanced/manager/ui/component/AppLabel.kt
new file mode 100644
index 0000000000..33a1e20152
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/AppLabel.kt
@@ -0,0 +1,52 @@
+package app.revanced.manager.ui.component
+
+import android.content.pm.PackageInfo
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import app.revanced.manager.R
+import io.github.fornewid.placeholder.material3.placeholder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+@Composable
+fun AppLabel(
+ packageInfo: PackageInfo?,
+ modifier: Modifier = Modifier,
+ style: TextStyle = LocalTextStyle.current,
+ defaultText: String? = stringResource(R.string.not_installed)
+) {
+ val context = LocalContext.current
+
+ var label: String? by rememberSaveable { mutableStateOf(null) }
+
+ LaunchedEffect(packageInfo) {
+ label = withContext(Dispatchers.IO) {
+ packageInfo?.applicationInfo?.loadLabel(context.packageManager)?.toString()
+ ?: defaultText
+ }
+ }
+
+ Text(
+ label ?: stringResource(R.string.loading),
+ modifier = Modifier
+ .placeholder(
+ visible = label == null,
+ color = MaterialTheme.colorScheme.inverseOnSurface,
+ shape = RoundedCornerShape(100)
+ )
+ .then(modifier),
+ style = style
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/AppScaffold.kt b/app/src/main/java/app/revanced/manager/ui/component/AppScaffold.kt
new file mode 100644
index 0000000000..468fa189be
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/AppScaffold.kt
@@ -0,0 +1,67 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AppScaffold(
+ topBar: @Composable (TopAppBarScrollBehavior) -> Unit = {},
+ bottomBar: @Composable () -> Unit = {},
+ floatingActionButton: @Composable () -> Unit = {},
+ content: @Composable (PaddingValues) -> Unit
+) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
+
+ Scaffold(
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = { topBar(scrollBehavior) },
+ bottomBar = bottomBar,
+ floatingActionButton = floatingActionButton,
+ content = content
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AppTopBar(
+ title: String,
+ onBackClick: (() -> Unit)? = null,
+ backIcon: @Composable (() -> Unit) = @Composable {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(
+ R.string.back
+ )
+ )
+ },
+ actions: @Composable (RowScope.() -> Unit) = {},
+ scrollBehavior: TopAppBarScrollBehavior? = null
+) {
+ val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
+
+ TopAppBar(
+ title = { Text(title) },
+ scrollBehavior = scrollBehavior,
+ navigationIcon = {
+ if (onBackClick != null) {
+ IconButton(onClick = onBackClick) {
+ backIcon()
+ }
+ }
+ },
+ actions = actions,
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = containerColor
+ )
+ )
+}
+
diff --git a/app/src/main/java/app/revanced/manager/ui/component/ArrowButton.kt b/app/src/main/java/app/revanced/manager/ui/component/ArrowButton.kt
new file mode 100644
index 0000000000..aed7e0c7db
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/ArrowButton.kt
@@ -0,0 +1,46 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.res.stringResource
+import app.revanced.manager.R
+
+@Composable
+fun ArrowButton(
+ modifier: Modifier = Modifier,
+ expanded: Boolean,
+ onClick: (() -> Unit)?,
+ rotationInitial: Float = 0f,
+ rotationFinal: Float = 180f
+) {
+ val description = if (expanded) R.string.collapse_content else R.string.expand_content
+ val rotation by animateFloatAsState(
+ targetValue = if (expanded) rotationInitial else rotationFinal,
+ label = "rotation"
+ )
+
+ onClick?.let {
+ IconButton(onClick = it) {
+ Icon(
+ imageVector = Icons.Filled.KeyboardArrowUp,
+ contentDescription = stringResource(description),
+ modifier = Modifier
+ .rotate(rotation)
+ .then(modifier)
+ )
+ }
+ } ?: Icon(
+ imageVector = Icons.Filled.KeyboardArrowUp,
+ contentDescription = stringResource(description),
+ modifier = Modifier
+ .rotate(rotation)
+ .then(modifier)
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt
new file mode 100644
index 0000000000..29f2f97026
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/AutoUpdatesDialog.kt
@@ -0,0 +1,83 @@
+package app.revanced.manager.ui.component
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Source
+import androidx.compose.material.icons.outlined.Update
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.haptics.HapticCheckbox
+import app.revanced.manager.util.transparentListItemColors
+
+@Composable
+fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) {
+ var patchesEnabled by rememberSaveable { mutableStateOf(true) }
+ var managerEnabled by rememberSaveable { mutableStateOf(true) }
+
+ AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {
+ TextButton(onClick = { onSubmit(managerEnabled, patchesEnabled) }) {
+ Text(stringResource(R.string.save))
+ }
+ },
+ icon = { Icon(Icons.Outlined.Update, null) },
+ title = { Text(text = stringResource(R.string.auto_updates_dialog_title)) },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text(text = stringResource(R.string.auto_updates_dialog_description))
+
+ Column {
+ AutoUpdatesItem(
+ headline = R.string.auto_updates_dialog_manager,
+ icon = Icons.Outlined.Update,
+ checked = managerEnabled,
+ onCheckedChange = { managerEnabled = it }
+ )
+ HorizontalDivider()
+ AutoUpdatesItem(
+ headline = R.string.auto_updates_dialog_patches,
+ icon = Icons.Outlined.Source,
+ checked = patchesEnabled,
+ onCheckedChange = { patchesEnabled = it }
+ )
+ }
+
+ Text(text = stringResource(R.string.auto_updates_dialog_note))
+ }
+ }
+ )
+}
+
+@Composable
+private fun AutoUpdatesItem(
+ @StringRes headline: Int,
+ icon: ImageVector,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit
+) = ListItem(
+ leadingContent = { Icon(icon, null) },
+ headlineContent = { Text(stringResource(headline)) },
+ trailingContent = { HapticCheckbox(checked = checked, onCheckedChange = null) },
+ modifier = Modifier.clickable { onCheckedChange(!checked) },
+ colors = transparentListItemColors
+)
diff --git a/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt
new file mode 100644
index 0000000000..7b6ecd9a12
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/AvailableUpdateDialog.kt
@@ -0,0 +1,84 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Update
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.haptics.HapticCheckbox
+import app.revanced.manager.util.transparentListItemColors
+
+@Composable
+fun AvailableUpdateDialog(
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+ setShowManagerUpdateDialogOnLaunch: (Boolean) -> Unit,
+ newVersion: String
+) {
+ var dontShowAgain by rememberSaveable { mutableStateOf(false) }
+ val dismissDialog = {
+ setShowManagerUpdateDialogOnLaunch(!dontShowAgain)
+ onDismiss()
+ }
+
+ AlertDialogExtended(
+ onDismissRequest = dismissDialog,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ dismissDialog()
+ onConfirm()
+ }
+ ) {
+ Text(stringResource(R.string.show))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = dismissDialog
+ ) {
+ Text(stringResource(R.string.dismiss))
+ }
+ },
+ icon = {
+ Icon(imageVector = Icons.Outlined.Update, contentDescription = null)
+ },
+ title = {
+ Text(stringResource(R.string.update_available))
+ },
+ text = {
+ Column(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ text = stringResource(R.string.update_available_dialog_description, newVersion)
+ )
+ ListItem(
+ modifier = Modifier.clickable { dontShowAgain = !dontShowAgain },
+ headlineContent = {
+ Text(stringResource(R.string.never_show_again))
+ },
+ leadingContent = {
+ CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
+ HapticCheckbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it })
+ }
+ },
+ colors = transparentListItemColors
+ )
+ }
+ },
+ textHorizontalPadding = PaddingValues(0.dp)
+ )
+}
diff --git a/app/src/main/java/app/revanced/manager/ui/component/CheckedFilterChip.kt b/app/src/main/java/app/revanced/manager/ui/component/CheckedFilterChip.kt
new file mode 100644
index 0000000000..a81c456f7b
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/CheckedFilterChip.kt
@@ -0,0 +1,61 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandIn
+import androidx.compose.animation.shrinkOut
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.SelectableChipColors
+import androidx.compose.material3.SelectableChipElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
+
+@Composable
+fun CheckedFilterChip(
+ selected: Boolean,
+ onClick: () -> Unit,
+ label: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ shape: Shape = FilterChipDefaults.shape,
+ colors: SelectableChipColors = FilterChipDefaults.filterChipColors(),
+ elevation: SelectableChipElevation? = FilterChipDefaults.filterChipElevation(),
+ border: BorderStroke? = FilterChipDefaults.filterChipBorder(enabled, selected),
+ interactionSource: MutableInteractionSource? = null
+) {
+ FilterChip(
+ selected = selected,
+ onClick = onClick,
+ label = label,
+ modifier = modifier,
+ enabled = enabled,
+ leadingIcon = {
+ AnimatedVisibility(
+ visible = selected,
+ enter = expandIn(expandFrom = Alignment.CenterStart),
+ exit = shrinkOut(shrinkTowards = Alignment.CenterStart)
+ ) {
+ Icon(
+ modifier = Modifier.size(FilterChipDefaults.IconSize),
+ imageVector = Icons.Filled.Done,
+ contentDescription = null,
+ )
+ }
+ },
+ trailingIcon = trailingIcon,
+ shape = shape,
+ colors = colors,
+ elevation = elevation,
+ border = border,
+ interactionSource = interactionSource
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/ColumnWithScrollbar.kt b/app/src/main/java/app/revanced/manager/ui/component/ColumnWithScrollbar.kt
new file mode 100644
index 0000000000..bc35877966
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/ColumnWithScrollbar.kt
@@ -0,0 +1,29 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+
+@Composable
+fun ColumnWithScrollbar(
+ modifier: Modifier = Modifier,
+ state: ScrollState = rememberScrollState(),
+ verticalArrangement: Arrangement.Vertical = Arrangement.Top,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Column(
+ modifier = modifier.then(Modifier.verticalScroll(state)),
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = horizontalAlignment,
+ content = content
+ )
+ Scrollbar(state, Modifier.then(modifier.padding())) // Get the modifier's padding to maintain scrollbar within the screen, e.g. paddingValues
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/ContentSelector.kt b/app/src/main/java/app/revanced/manager/ui/component/ContentSelector.kt
new file mode 100644
index 0000000000..3e7117b1f8
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/ContentSelector.kt
@@ -0,0 +1,21 @@
+package app.revanced.manager.ui.component
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material3.Button
+import androidx.compose.runtime.Composable
+
+@Composable
+fun ContentSelector(mime: String, onSelect: (Uri) -> Unit, content: @Composable () -> Unit) {
+ val activityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ uri?.let(onSelect)
+ }
+ Button(
+ onClick = {
+ activityLauncher.launch(mime)
+ }
+ ) {
+ content()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt
new file mode 100644
index 0000000000..1ceb9cef27
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt
@@ -0,0 +1,79 @@
+package app.revanced.manager.ui.component
+
+import android.content.Intent
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.outlined.Share
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.bundle.BundleTopBar
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ExceptionViewerDialog(text: String, onDismiss: () -> Unit) {
+ val context = LocalContext.current
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnBackPress = true
+ )
+ ) {
+ Scaffold(
+ topBar = {
+ BundleTopBar(
+ title = stringResource(R.string.bundle_error),
+ onBackClick = onDismiss,
+ backIcon = {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ stringResource(R.string.back)
+ )
+ },
+ actions = {
+ IconButton(
+ onClick = {
+ val sendIntent: Intent = Intent().apply {
+ action = Intent.ACTION_SEND
+ putExtra(
+ Intent.EXTRA_TEXT,
+ text
+ )
+ type = "text/plain"
+ }
+
+ val shareIntent = Intent.createChooser(sendIntent, null)
+ context.startActivity(shareIntent)
+ }
+ ) {
+ Icon(
+ Icons.Outlined.Share,
+ contentDescription = stringResource(R.string.share)
+ )
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ ColumnWithScrollbar(
+ modifier = Modifier.padding(paddingValues)
+ ) {
+ Text(text, modifier = Modifier.horizontalScroll(rememberScrollState()))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt b/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt
new file mode 100644
index 0000000000..b07b23e683
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/GroupHeader.kt
@@ -0,0 +1,23 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun GroupHeader(
+ title: String,
+ modifier: Modifier = Modifier
+) {
+ Text(
+ text = title,
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.labelLarge,
+ modifier = Modifier.padding(24.dp).semantics { heading() }.then(modifier)
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt
new file mode 100644
index 0000000000..2ae48ce6ff
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/InstallerStatusDialog.kt
@@ -0,0 +1,149 @@
+package app.revanced.manager.ui.component
+
+import android.content.pm.PackageInstaller
+import androidx.annotation.RequiresApi
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ErrorOutline
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.R
+import app.revanced.manager.ui.model.InstallerModel
+import com.github.materiiapps.enumutil.FromValue
+
+private typealias InstallerStatusDialogButtonHandler = ((model: InstallerModel) -> Unit)
+private typealias InstallerStatusDialogButton = @Composable (model: InstallerModel, dismiss: () -> Unit) -> Unit
+
+@Composable
+fun InstallerStatusDialog(installerStatus: Int, model: InstallerModel, onDismiss: () -> Unit) {
+ val dialogKind = remember {
+ DialogKind.fromValue(installerStatus) ?: DialogKind.FAILURE
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ dialogKind.confirmButton(model, onDismiss)
+ },
+ dismissButton = {
+ dialogKind.dismissButton?.invoke(model, onDismiss)
+ },
+ icon = {
+ Icon(dialogKind.icon, null)
+ },
+ title = {
+ Text(
+ text = stringResource(dialogKind.title),
+ style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center),
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ },
+ text = {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(stringResource(dialogKind.contentStringResId))
+ }
+ }
+ )
+}
+
+private fun installerStatusDialogButton(
+ @StringRes buttonStringResId: Int,
+ buttonHandler: InstallerStatusDialogButtonHandler = { },
+): InstallerStatusDialogButton = { model, dismiss ->
+ TextButton(
+ onClick = {
+ dismiss()
+ buttonHandler(model)
+ }
+ ) {
+ Text(stringResource(buttonStringResId))
+ }
+}
+
+@FromValue("flag")
+enum class DialogKind(
+ val flag: Int,
+ val title: Int,
+ @StringRes val contentStringResId: Int,
+ val icon: ImageVector = Icons.Outlined.ErrorOutline,
+ val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok),
+ val dismissButton: InstallerStatusDialogButton? = null,
+) {
+ FAILURE(
+ flag = PackageInstaller.STATUS_FAILURE,
+ title = R.string.installation_failed_dialog_title,
+ contentStringResId = R.string.installation_failed_description,
+ confirmButton = installerStatusDialogButton(R.string.install_app) { model ->
+ model.install()
+ }
+ ),
+ FAILURE_ABORTED(
+ flag = PackageInstaller.STATUS_FAILURE_ABORTED,
+ title = R.string.installation_cancelled_dialog_title,
+ contentStringResId = R.string.installation_aborted_description,
+ confirmButton = installerStatusDialogButton(R.string.install_app) { model ->
+ model.install()
+ }
+ ),
+ FAILURE_BLOCKED(
+ flag = PackageInstaller.STATUS_FAILURE_BLOCKED,
+ title = R.string.installation_blocked_dialog_title,
+ contentStringResId = R.string.installation_blocked_description,
+ ),
+ FAILURE_CONFLICT(
+ flag = PackageInstaller.STATUS_FAILURE_CONFLICT,
+ title = R.string.installation_conflict_dialog_title,
+ contentStringResId = R.string.installation_conflict_description,
+ confirmButton = installerStatusDialogButton(R.string.reinstall) { model ->
+ model.reinstall()
+ },
+ dismissButton = installerStatusDialogButton(R.string.cancel),
+ ),
+ FAILURE_INCOMPATIBLE(
+ flag = PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
+ title = R.string.installation_incompatible_dialog_title,
+ contentStringResId = R.string.installation_incompatible_description,
+ ),
+ FAILURE_INVALID(
+ flag = PackageInstaller.STATUS_FAILURE_INVALID,
+ title = R.string.installation_invalid_dialog_title,
+ contentStringResId = R.string.installation_invalid_description,
+ confirmButton = installerStatusDialogButton(R.string.reinstall) { model ->
+ model.reinstall()
+ },
+ dismissButton = installerStatusDialogButton(R.string.cancel),
+ ),
+ FAILURE_STORAGE(
+ flag = PackageInstaller.STATUS_FAILURE_STORAGE,
+ title = R.string.installation_storage_issue_dialog_title,
+ contentStringResId = R.string.installation_storage_issue_description,
+ ),
+
+ @RequiresApi(34)
+ FAILURE_TIMEOUT(
+ flag = PackageInstaller.STATUS_FAILURE_TIMEOUT,
+ title = R.string.installation_timeout_dialog_title,
+ contentStringResId = R.string.installation_timeout_description,
+ confirmButton = installerStatusDialogButton(R.string.install_app) { model ->
+ model.install()
+ },
+ );
+
+ // Needed due to the @FromValue annotation.
+ companion object
+}
diff --git a/app/src/main/java/app/revanced/manager/ui/component/LazyColumnWithScrollbar.kt b/app/src/main/java/app/revanced/manager/ui/component/LazyColumnWithScrollbar.kt
new file mode 100644
index 0000000000..e1f984c546
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/LazyColumnWithScrollbar.kt
@@ -0,0 +1,42 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun LazyColumnWithScrollbar(
+ modifier: Modifier = Modifier,
+ state: LazyListState = rememberLazyListState(),
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ reverseLayout: Boolean = false,
+ verticalArrangement: Arrangement.Vertical =
+ if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+ flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
+ userScrollEnabled: Boolean = true,
+ content: LazyListScope.() -> Unit
+) {
+ LazyColumn(
+ modifier = modifier,
+ state = state,
+ contentPadding = contentPadding,
+ reverseLayout = reverseLayout,
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = horizontalAlignment,
+ flingBehavior = flingBehavior,
+ userScrollEnabled = userScrollEnabled,
+ content = content
+ )
+ Scrollbar(state, Modifier.then(modifier.padding())) // Get the modifier's padding to maintain scrollbar within the screen, e.g. paddingValues
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt b/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt
new file mode 100644
index 0000000000..44d5c9cd9d
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/LoadingIndicator.kt
@@ -0,0 +1,37 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ProgressIndicatorDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.unit.Dp
+
+@Composable
+fun LoadingIndicator(
+ modifier: Modifier = Modifier,
+ progress: () -> Float? = { null },
+ color: Color = ProgressIndicatorDefaults.circularColor,
+ strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
+ trackColor: Color = ProgressIndicatorDefaults.circularTrackColor,
+ strokeCap: StrokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap
+) {
+ progress()?.let {
+ CircularProgressIndicator(
+ progress = { it },
+ modifier = modifier,
+ color = color,
+ strokeWidth = strokeWidth,
+ trackColor = trackColor,
+ strokeCap = strokeCap
+ )
+ } ?:
+ CircularProgressIndicator(
+ modifier = modifier,
+ color = color,
+ strokeWidth = strokeWidth,
+ trackColor = trackColor,
+ strokeCap = strokeCap
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt b/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt
new file mode 100644
index 0000000000..2b5b276ea5
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/Markdown.kt
@@ -0,0 +1,32 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.font.FontWeight
+import com.mikepenz.markdown.compose.Markdown
+import com.mikepenz.markdown.m3.markdownColor
+import com.mikepenz.markdown.m3.markdownTypography
+
+@Composable
+fun Markdown(
+ text: String
+) {
+ val markdown = text.trimIndent()
+
+ Markdown(
+ content = markdown,
+ colors = markdownColor(
+ text = MaterialTheme.colorScheme.onSurfaceVariant,
+ codeBackground = MaterialTheme.colorScheme.secondaryContainer,
+ codeText = MaterialTheme.colorScheme.onSecondaryContainer,
+ linkText = MaterialTheme.colorScheme.primary
+ ),
+ typography = markdownTypography(
+ h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
+ h2 = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ h3 = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ text = MaterialTheme.typography.bodyMedium,
+ list = MaterialTheme.typography.bodyMedium
+ )
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt
new file mode 100644
index 0000000000..0357a18f8e
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/NotificationCard.kt
@@ -0,0 +1,182 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Close
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.R
+
+@Composable
+fun NotificationCard(
+ text: String,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+ actions: (@Composable RowScope.() -> Unit)? = null,
+ title: String? = null,
+ isWarning: Boolean = false
+) {
+ val color =
+ if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
+
+ NotificationCardInstance(modifier = modifier, isWarning = isWarning) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Box(
+ modifier = Modifier.size(28.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ imageVector = icon,
+ contentDescription = null,
+ tint = color,
+ )
+ }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ title?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleLarge,
+ color = color,
+ )
+ }
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = color,
+ )
+ Row(
+ horizontalArrangement = Arrangement.End,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ actions?.invoke(this)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun NotificationCard(
+ text: String,
+ icon: ImageVector,
+ modifier: Modifier = Modifier,
+ title: String? = null,
+ isWarning: Boolean = false,
+ onDismiss: (() -> Unit)? = null,
+ onClick: (() -> Unit)? = null
+) {
+ val color =
+ if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
+
+ NotificationCardInstance(modifier = modifier, isWarning = isWarning, onClick = onClick) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Box(
+ modifier = Modifier.size(28.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ modifier = Modifier.size(24.dp),
+ imageVector = icon,
+ contentDescription = null,
+ tint = color,
+ )
+ }
+ if (title != null) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = color,
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = color,
+ )
+ }
+ } else {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = color,
+ )
+ }
+ if (onDismiss != null) {
+ IconButton(onClick = onDismiss) {
+ Icon(
+ imageVector = Icons.Outlined.Close,
+ contentDescription = stringResource(R.string.close),
+ tint = color,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun NotificationCardInstance(
+ modifier: Modifier = Modifier,
+ isWarning: Boolean = false,
+ onClick: (() -> Unit)? = null,
+ content: @Composable () -> Unit,
+) {
+ val colors =
+ CardDefaults.cardColors(containerColor = if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)
+ val defaultModifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(24.dp))
+
+ if (onClick != null) {
+ Card(
+ onClick = onClick,
+ colors = colors,
+ modifier = modifier.then(defaultModifier)
+ ) {
+ content()
+ }
+ } else {
+ Card(
+ colors = colors,
+ modifier = modifier.then(defaultModifier)
+ ) {
+ content()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/NumberInputDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/NumberInputDialog.kt
new file mode 100644
index 0000000000..5a293165fd
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/NumberInputDialog.kt
@@ -0,0 +1,99 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisallowComposableCalls
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import app.revanced.manager.R
+
+@Composable
+private inline fun NumberInputDialog(
+ current: T?,
+ name: String,
+ crossinline onSubmit: (T?) -> Unit,
+ crossinline validator: @DisallowComposableCalls (T) -> Boolean,
+ crossinline toNumberOrNull: @DisallowComposableCalls String.() -> T?
+) {
+ var fieldValue by rememberSaveable {
+ mutableStateOf(current?.toString().orEmpty())
+ }
+ val numberFieldValue by remember {
+ derivedStateOf { fieldValue.toNumberOrNull() }
+ }
+ val validatorFailed by remember {
+ derivedStateOf { numberFieldValue?.let { !validator(it) } ?: false }
+ }
+
+ AlertDialog(
+ onDismissRequest = { onSubmit(null) },
+ title = { Text(name) },
+ text = {
+ OutlinedTextField(
+ value = fieldValue,
+ onValueChange = { fieldValue = it },
+ placeholder = {
+ Text(stringResource(R.string.dialog_input_placeholder))
+ },
+ isError = validatorFailed,
+ supportingText = {
+ if (validatorFailed) {
+ Text(
+ stringResource(R.string.input_dialog_value_invalid),
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { numberFieldValue?.let(onSubmit) },
+ enabled = numberFieldValue != null && !validatorFailed,
+ ) {
+ Text(stringResource(R.string.save))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { onSubmit(null) }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+}
+
+@Composable
+fun IntInputDialog(
+ current: Int?,
+ name: String,
+ validator: (Int) -> Boolean = { true },
+ onSubmit: (Int?) -> Unit
+) = NumberInputDialog(current, name, onSubmit, validator, String::toIntOrNull)
+
+@Composable
+fun LongInputDialog(
+ current: Long?,
+ name: String,
+ validator: (Long) -> Boolean = { true },
+ onSubmit: (Long?) -> Unit
+) = NumberInputDialog(current, name, onSubmit, validator, String::toLongOrNull)
+
+@Composable
+fun FloatInputDialog(
+ current: Float?,
+ name: String,
+ validator: (Float) -> Boolean = { true },
+ onSubmit: (Float?) -> Unit
+) = NumberInputDialog(current, name, onSubmit, validator, String::toFloatOrNull)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/PasswordField.kt b/app/src/main/java/app/revanced/manager/ui/component/PasswordField.kt
new file mode 100644
index 0000000000..ee64c05ba0
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/PasswordField.kt
@@ -0,0 +1,50 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Visibility
+import androidx.compose.material.icons.outlined.VisibilityOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import app.revanced.manager.R
+
+@Composable
+fun PasswordField(modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit, label: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null) {
+ var visible by rememberSaveable {
+ mutableStateOf(false)
+ }
+
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ placeholder = placeholder,
+ label = label,
+ modifier = modifier,
+ trailingIcon = {
+ IconButton(onClick = {
+ visible = !visible
+ }) {
+ val (icon, description) = remember(visible) {
+ if (visible) Icons.Outlined.VisibilityOff to R.string.hide_password_field else Icons.Outlined.Visibility to R.string.show_password_field
+ }
+ Icon(icon, stringResource(description))
+ }
+ },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password
+ ),
+ visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation()
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/SafeguardDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/SafeguardDialog.kt
new file mode 100644
index 0000000000..6aabe12a77
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/SafeguardDialog.kt
@@ -0,0 +1,51 @@
+package app.revanced.manager.ui.component
+
+import androidx.annotation.StringRes
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.WarningAmber
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import app.revanced.manager.R
+
+@Composable
+fun SafeguardDialog(
+ onDismiss: () -> Unit,
+ @StringRes title: Int,
+ body: String,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ icon = {
+ Icon(Icons.Outlined.WarningAmber, null)
+ },
+ title = {
+ Text(
+ text = stringResource(title),
+ style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center)
+ )
+ },
+ text = {
+ Text(body)
+ }
+ )
+}
+
+@Composable
+fun NonSuggestedVersionDialog(suggestedVersion: String, onDismiss: () -> Unit) {
+ SafeguardDialog(
+ onDismiss = onDismiss,
+ title = R.string.non_suggested_version_warning_title,
+ body = stringResource(R.string.non_suggested_version_warning_description, suggestedVersion),
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/Scrollbar.kt b/app/src/main/java/app/revanced/manager/ui/component/Scrollbar.kt
new file mode 100644
index 0000000000..88917f8291
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/Scrollbar.kt
@@ -0,0 +1,64 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.gigamole.composescrollbars.Scrollbars
+import com.gigamole.composescrollbars.ScrollbarsState
+import com.gigamole.composescrollbars.config.ScrollbarsConfig
+import com.gigamole.composescrollbars.config.ScrollbarsOrientation
+import com.gigamole.composescrollbars.config.layercontenttype.ScrollbarsLayerContentType
+import com.gigamole.composescrollbars.config.layersType.ScrollbarsLayersType
+import com.gigamole.composescrollbars.config.layersType.thicknessType.ScrollbarsThicknessType
+import com.gigamole.composescrollbars.config.visibilitytype.ScrollbarsVisibilityType
+import com.gigamole.composescrollbars.scrolltype.ScrollbarsScrollType
+import com.gigamole.composescrollbars.scrolltype.knobtype.ScrollbarsDynamicKnobType
+import com.gigamole.composescrollbars.scrolltype.knobtype.ScrollbarsStaticKnobType
+
+@Composable
+fun Scrollbar(scrollState: ScrollState, modifier: Modifier = Modifier) {
+ Scrollbar(
+ ScrollbarsScrollType.Scroll(
+ knobType = ScrollbarsStaticKnobType.Auto(),
+ state = scrollState
+ ),
+ modifier
+ )
+}
+
+@Composable
+fun Scrollbar(lazyListState: LazyListState, modifier: Modifier = Modifier) {
+ Scrollbar(
+ ScrollbarsScrollType.Lazy.List.Dynamic(
+ knobType = ScrollbarsDynamicKnobType.Auto(),
+ state = lazyListState
+ ),
+ modifier
+ )
+}
+
+@Composable
+private fun Scrollbar(scrollType: ScrollbarsScrollType, modifier: Modifier = Modifier) {
+ Scrollbars(
+ state = ScrollbarsState(
+ ScrollbarsConfig(
+ orientation = ScrollbarsOrientation.Vertical,
+ paddingValues = PaddingValues(0.dp),
+ layersType = ScrollbarsLayersType.Wrap(ScrollbarsThicknessType.Exact(4.dp)),
+ knobLayerContentType = ScrollbarsLayerContentType.Default.Colored.Idle(
+ idleColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
+ ),
+ visibilityType = ScrollbarsVisibilityType.Dynamic.Fade(
+ isVisibleOnTouchDown = true,
+ isStaticWhenScrollPossible = false
+ )
+ ),
+ scrollType
+ ),
+ modifier = modifier
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/SearchBar.kt b/app/src/main/java/app/revanced/manager/ui/component/SearchBar.kt
new file mode 100644
index 0000000000..7c48b8121c
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/SearchBar.kt
@@ -0,0 +1,60 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SearchBar
+import androidx.compose.material3.SearchBarColors
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchBar(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ expanded: Boolean,
+ onExpandedChange: (Boolean) -> Unit,
+ placeholder: (@Composable () -> Unit)? = null,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ val colors = SearchBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ dividerColor = MaterialTheme.colorScheme.outline
+ )
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ Box(modifier = Modifier.fillMaxWidth()) {
+ SearchBar(
+ modifier = Modifier.align(Alignment.Center),
+ inputField = {
+ SearchBarDefaults.InputField(
+ modifier = Modifier.sizeIn(minWidth = 380.dp),
+ query = query,
+ onQueryChange = onQueryChange,
+ onSearch = {
+ keyboardController?.hide()
+ },
+ expanded = expanded,
+ onExpandedChange = onExpandedChange,
+ placeholder = placeholder,
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon
+ )
+ },
+ expanded = expanded,
+ onExpandedChange = onExpandedChange,
+ colors = colors,
+ content = content
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt b/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt
new file mode 100644
index 0000000000..04b5b58949
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt
@@ -0,0 +1,70 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SearchBar
+import androidx.compose.material3.SearchBarColors
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import app.revanced.manager.R
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchView(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ onActiveChange: (Boolean) -> Unit,
+ placeholder: (@Composable () -> Unit)? = null,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ val colors = SearchBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ dividerColor = MaterialTheme.colorScheme.outline
+ )
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+
+ SearchBar(
+ inputField = {
+ SearchBarDefaults.InputField(
+ query = query,
+ onQueryChange = onQueryChange,
+ onSearch = {
+ keyboardController?.hide()
+ },
+ expanded = true,
+ onExpandedChange = onActiveChange,
+ placeholder = placeholder,
+ leadingIcon = {
+ IconButton(onClick = { onActiveChange(false) }) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ stringResource(R.string.back)
+ )
+ }
+ }
+ )
+ },
+ expanded = true,
+ onExpandedChange = onActiveChange,
+ modifier = Modifier.focusRequester(focusRequester),
+ colors = colors,
+ content = content
+ )
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/SegmentedButton.kt b/app/src/main/java/app/revanced/manager/ui/component/SegmentedButton.kt
new file mode 100644
index 0000000000..bf07d72582
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/SegmentedButton.kt
@@ -0,0 +1,80 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.basicMarquee
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+/**
+ * Credits to [Vendetta](https://github.com/vendetta-mod)
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun RowScope.SegmentedButton(
+ icon: Any,
+ text: String,
+ onClick: () -> Unit,
+ iconDescription: String? = null,
+ enabled: Boolean = true
+) {
+ val contentColor = if (enabled)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.onSurface.copy(0.38f)
+
+ CompositionLocalProvider(LocalContentColor provides contentColor) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
+ modifier = Modifier
+ .clickable(enabled = enabled, onClick = onClick)
+ .background(
+ if (enabled)
+ MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
+ else
+ MaterialTheme.colorScheme.onSurface.copy(0.12f)
+ )
+ .weight(1f)
+ .padding(vertical = 20.dp)
+ ) {
+ when (icon) {
+ is ImageVector -> {
+ Icon(
+ imageVector = icon,
+ contentDescription = iconDescription
+ )
+ }
+
+ is Painter -> {
+ Icon(
+ painter = icon,
+ contentDescription = iconDescription
+ )
+ }
+ }
+
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ modifier = Modifier.basicMarquee()
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/TextInputDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/TextInputDialog.kt
new file mode 100644
index 0000000000..d0484f1e98
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/TextInputDialog.kt
@@ -0,0 +1,51 @@
+package app.revanced.manager.ui.component
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.res.stringResource
+import app.revanced.manager.R
+
+@Composable
+fun TextInputDialog(
+ initial: String,
+ title: String,
+ onDismissRequest: () -> Unit,
+ onConfirm: (String) -> Unit,
+ validator: (String) -> Boolean = String::isNotEmpty,
+) {
+ val (value, setValue) = rememberSaveable(initial) {
+ mutableStateOf(initial)
+ }
+ val valid = remember(value, validator) {
+ validator(value)
+ }
+
+ AlertDialog(
+ onDismissRequest = onDismissRequest,
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(value) },
+ enabled = valid
+ ) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismissRequest) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ title = {
+ Text(title)
+ },
+ text = {
+ TextField(value = value, onValueChange = setValue)
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt
new file mode 100644
index 0000000000..dfc63735b9
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt
@@ -0,0 +1,181 @@
+package app.revanced.manager.ui.component.bundle
+
+import android.webkit.URLUtil
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowRight
+import androidx.compose.material.icons.outlined.Extension
+import androidx.compose.material.icons.outlined.Inventory2
+import androidx.compose.material.icons.outlined.Sell
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.ColumnWithScrollbar
+import app.revanced.manager.ui.component.TextInputDialog
+import app.revanced.manager.ui.component.haptics.HapticSwitch
+
+@Composable
+fun BaseBundleDialog(
+ modifier: Modifier = Modifier,
+ isDefault: Boolean,
+ name: String?,
+ remoteUrl: String?,
+ onRemoteUrlChange: ((String) -> Unit)? = null,
+ patchCount: Int,
+ version: String?,
+ autoUpdate: Boolean,
+ onAutoUpdateChange: (Boolean) -> Unit,
+ onPatchesClick: () -> Unit,
+ extraFields: @Composable ColumnScope.() -> Unit = {}
+) {
+ ColumnWithScrollbar(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(modifier),
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Inventory2,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(32.dp)
+ )
+ name?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 2.dp)
+ ) {
+ version?.let {
+ Tag(Icons.Outlined.Sell, it)
+ }
+ Tag(Icons.Outlined.Extension, patchCount.toString())
+ }
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+
+ if (remoteUrl != null) {
+ BundleListItem(
+ headlineText = stringResource(R.string.bundle_auto_update),
+ supportingText = stringResource(R.string.bundle_auto_update_description),
+ trailingContent = {
+ HapticSwitch(
+ checked = autoUpdate,
+ onCheckedChange = onAutoUpdateChange
+ )
+ },
+ modifier = Modifier.clickable {
+ onAutoUpdateChange(!autoUpdate)
+ }
+ )
+ }
+
+ remoteUrl?.takeUnless { isDefault }?.let { url ->
+ var showUrlInputDialog by rememberSaveable {
+ mutableStateOf(false)
+ }
+ if (showUrlInputDialog) {
+ TextInputDialog(
+ initial = url,
+ title = stringResource(R.string.bundle_input_source_url),
+ onDismissRequest = { showUrlInputDialog = false },
+ onConfirm = {
+ showUrlInputDialog = false
+ onRemoteUrlChange?.invoke(it)
+ },
+ validator = {
+ if (it.isEmpty()) return@TextInputDialog false
+
+ URLUtil.isValidUrl(it)
+ }
+ )
+ }
+
+ BundleListItem(
+ modifier = Modifier.clickable(
+ enabled = onRemoteUrlChange != null,
+ onClick = {
+ showUrlInputDialog = true
+ }
+ ),
+ headlineText = stringResource(R.string.bundle_input_source_url),
+ supportingText = url.ifEmpty {
+ stringResource(R.string.field_not_set)
+ }
+ )
+ }
+
+ val patchesClickable = patchCount > 0
+ BundleListItem(
+ headlineText = stringResource(R.string.patches),
+ supportingText = stringResource(R.string.bundle_view_patches),
+ modifier = Modifier.clickable(
+ enabled = patchesClickable,
+ onClick = onPatchesClick
+ )
+ ) {
+ if (patchesClickable) {
+ Icon(
+ Icons.AutoMirrored.Outlined.ArrowRight,
+ stringResource(R.string.patches)
+ )
+ }
+ }
+
+ extraFields()
+ }
+}
+
+@Composable
+private fun Tag(
+ icon: ImageVector,
+ text: String
+) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.outline,
+ )
+ Text(
+ text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt
new file mode 100644
index 0000000000..eaebd8341e
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt
@@ -0,0 +1,146 @@
+package app.revanced.manager.ui.component.bundle
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.outlined.ArrowRight
+import androidx.compose.material.icons.outlined.DeleteOutline
+import androidx.compose.material.icons.outlined.Update
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.revanced.manager.R
+import app.revanced.manager.domain.bundles.LocalPatchBundle
+import app.revanced.manager.domain.bundles.PatchBundleSource
+import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
+import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
+import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
+import app.revanced.manager.ui.component.ExceptionViewerDialog
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BundleInformationDialog(
+ onDismissRequest: () -> Unit,
+ onDeleteRequest: () -> Unit,
+ bundle: PatchBundleSource,
+ onUpdate: () -> Unit,
+) {
+ val composableScope = rememberCoroutineScope()
+ var viewCurrentBundlePatches by remember { mutableStateOf(false) }
+ val isLocal = bundle is LocalPatchBundle
+ val state by bundle.state.collectAsStateWithLifecycle()
+ val props by remember(bundle) {
+ bundle.propsFlow()
+ }.collectAsStateWithLifecycle(null)
+ val patchCount = remember(state) {
+ state.patchBundleOrNull()?.patches?.size ?: 0
+ }
+
+ if (viewCurrentBundlePatches) {
+ BundlePatchesDialog(
+ onDismissRequest = {
+ viewCurrentBundlePatches = false
+ },
+ bundle = bundle,
+ )
+ }
+
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnBackPress = true
+ )
+ ) {
+ val bundleName by bundle.nameState
+
+ Scaffold(
+ topBar = {
+ BundleTopBar(
+ title = stringResource(R.string.patch_bundle_field),
+ onBackClick = onDismissRequest,
+ backIcon = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.back)
+ )
+ },
+ actions = {
+ if (!bundle.isDefault) {
+ IconButton(onClick = onDeleteRequest) {
+ Icon(
+ Icons.Outlined.DeleteOutline,
+ stringResource(R.string.delete)
+ )
+ }
+ }
+ if (!isLocal) {
+ IconButton(onClick = onUpdate) {
+ Icon(
+ Icons.Outlined.Update,
+ stringResource(R.string.refresh)
+ )
+ }
+ }
+ }
+ )
+ },
+ ) { paddingValues ->
+ BaseBundleDialog(
+ modifier = Modifier.padding(paddingValues),
+ isDefault = bundle.isDefault,
+ name = bundleName,
+ remoteUrl = bundle.asRemoteOrNull?.endpoint,
+ patchCount = patchCount,
+ version = props?.version,
+ autoUpdate = props?.autoUpdate ?: false,
+ onAutoUpdateChange = {
+ composableScope.launch {
+ bundle.asRemoteOrNull?.setAutoUpdate(it)
+ }
+ },
+ onPatchesClick = {
+ viewCurrentBundlePatches = true
+ },
+ extraFields = {
+ (state as? PatchBundleSource.State.Failed)?.throwable?.let {
+ var showDialog by rememberSaveable {
+ mutableStateOf(false)
+ }
+ if (showDialog) ExceptionViewerDialog(
+ onDismiss = { showDialog = false },
+ text = remember(it) { it.stackTraceToString() }
+ )
+
+ BundleListItem(
+ headlineText = stringResource(R.string.bundle_error),
+ supportingText = stringResource(R.string.bundle_error_description),
+ trailingContent = {
+ Icon(
+ Icons.AutoMirrored.Outlined.ArrowRight,
+ null
+ )
+ },
+ modifier = Modifier.clickable { showDialog = true }
+ )
+ }
+
+ if (state is PatchBundleSource.State.Missing && !isLocal) {
+ BundleListItem(
+ headlineText = stringResource(R.string.bundle_error),
+ supportingText = stringResource(R.string.bundle_not_downloaded),
+ modifier = Modifier.clickable(onClick = onUpdate)
+ )
+ }
+ }
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt
new file mode 100644
index 0000000000..6f3ae914ee
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt
@@ -0,0 +1,110 @@
+package app.revanced.manager.ui.component.bundle
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.ErrorOutline
+import androidx.compose.material.icons.outlined.Warning
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.revanced.manager.R
+import app.revanced.manager.domain.bundles.PatchBundleSource
+import app.revanced.manager.ui.component.haptics.HapticCheckbox
+import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
+import kotlinx.coroutines.flow.map
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun BundleItem(
+ bundle: PatchBundleSource,
+ onDelete: () -> Unit,
+ onUpdate: () -> Unit,
+ selectable: Boolean,
+ onSelect: () -> Unit,
+ isBundleSelected: Boolean,
+ toggleSelection: (Boolean) -> Unit,
+) {
+ var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) }
+ val state by bundle.state.collectAsStateWithLifecycle()
+
+ val version by remember(bundle) {
+ bundle.propsFlow().map { props -> props?.version }
+ }.collectAsStateWithLifecycle(null)
+ val name by bundle.nameState
+
+ if (viewBundleDialogPage) {
+ BundleInformationDialog(
+ onDismissRequest = { viewBundleDialogPage = false },
+ onDeleteRequest = {
+ viewBundleDialogPage = false
+ onDelete()
+ },
+ bundle = bundle,
+ onUpdate = onUpdate,
+ )
+ }
+
+ ListItem(
+ modifier = Modifier
+ .height(64.dp)
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = { viewBundleDialogPage = true },
+ onLongClick = onSelect,
+ ),
+ leadingContent = if (selectable) {
+ {
+ HapticCheckbox(
+ checked = isBundleSelected,
+ onCheckedChange = toggleSelection,
+ )
+ }
+ } else null,
+
+ headlineContent = { Text(name) },
+ supportingContent = {
+ state.patchBundleOrNull()?.patches?.size?.let { patchCount ->
+ Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount))
+ }
+ },
+ trailingContent = {
+ Row {
+ val icon = remember(state) {
+ when (state) {
+ is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.bundle_error
+ is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.bundle_missing
+ is PatchBundleSource.State.Loaded -> null
+ }
+ }
+
+ icon?.let { (vector, description) ->
+ Icon(
+ vector,
+ contentDescription = stringResource(description),
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+
+ version?.let { Text(text = it) }
+ }
+ },
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleListItem.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleListItem.kt
new file mode 100644
index 0000000000..48a1ac17ed
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleListItem.kt
@@ -0,0 +1,33 @@
+package app.revanced.manager.ui.component.bundle
+
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun BundleListItem(
+ modifier: Modifier = Modifier,
+ headlineText: String,
+ supportingText: String = "",
+ trailingContent: @Composable (() -> Unit)? = null,
+) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = headlineText,
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ supportingContent = {
+ Text(
+ text = supportingText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline
+ )
+ },
+ trailingContent = trailingContent,
+ modifier = modifier
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt
new file mode 100644
index 0000000000..9920194913
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt
@@ -0,0 +1,276 @@
+package app.revanced.manager.ui.component.bundle
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import app.revanced.manager.R
+import app.revanced.manager.domain.bundles.PatchBundleSource
+import app.revanced.manager.patcher.patch.PatchInfo
+import app.revanced.manager.ui.component.ArrowButton
+import app.revanced.manager.ui.component.LazyColumnWithScrollbar
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BundlePatchesDialog(
+ onDismissRequest: () -> Unit,
+ bundle: PatchBundleSource,
+) {
+ var showAllVersions by rememberSaveable { mutableStateOf(false) }
+ var showOptions by rememberSaveable { mutableStateOf(false) }
+ val state by bundle.state.collectAsStateWithLifecycle()
+
+ Dialog(
+ onDismissRequest = onDismissRequest,
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnBackPress = true
+ )
+ ) {
+ Scaffold(
+ topBar = {
+ BundleTopBar(
+ title = stringResource(R.string.bundle_patches),
+ onBackClick = onDismissRequest,
+ backIcon = {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = stringResource(R.string.back)
+ )
+ },
+ )
+ },
+ ) { paddingValues ->
+ LazyColumnWithScrollbar(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(paddingValues),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ contentPadding = PaddingValues(16.dp)
+ ) {
+ state.patchBundleOrNull()?.let { bundle ->
+ items(bundle.patches) { patch ->
+ PatchItem(
+ patch,
+ showAllVersions,
+ onExpandVersions = { showAllVersions = !showAllVersions },
+ showOptions,
+ onExpandOptions = { showOptions = !showOptions }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun PatchItem(
+ patch: PatchInfo,
+ expandVersions: Boolean,
+ onExpandVersions: () -> Unit,
+ expandOptions: Boolean,
+ onExpandOptions: () -> Unit
+) {
+ ElevatedCard(
+ modifier = Modifier
+ .fillMaxWidth()
+ .then(
+ if (patch.options.isNullOrEmpty()) Modifier else Modifier
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = onExpandOptions),
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Absolute.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = patch.name,
+ color = MaterialTheme.colorScheme.primary,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (!patch.options.isNullOrEmpty()) {
+ ArrowButton(expanded = expandOptions, onClick = null)
+ }
+ }
+ patch.description?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ if (patch.compatiblePackages.isNullOrEmpty()) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ PatchInfoChip(
+ text = "$PACKAGE_ICON ${stringResource(R.string.bundle_view_patches_any_package)}"
+ )
+ PatchInfoChip(
+ text = "$VERSION_ICON ${stringResource(R.string.bundle_view_patches_any_version)}"
+ )
+ }
+ } else {
+ patch.compatiblePackages.forEach { compatiblePackage ->
+ val packageName = compatiblePackage.packageName
+ val versions = compatiblePackage.versions.orEmpty().reversed()
+
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ PatchInfoChip(
+ modifier = Modifier.align(Alignment.CenterVertically),
+ text = "$PACKAGE_ICON $packageName"
+ )
+
+ if (versions.isNotEmpty()) {
+ if (expandVersions) {
+ versions.forEach { version ->
+ PatchInfoChip(
+ modifier = Modifier.align(Alignment.CenterVertically),
+ text = "$VERSION_ICON $version"
+ )
+ }
+ } else {
+ PatchInfoChip(
+ modifier = Modifier.align(Alignment.CenterVertically),
+ text = "$VERSION_ICON ${versions.first()}"
+ )
+ }
+ if (versions.size > 1) {
+ PatchInfoChip(
+ onClick = onExpandVersions,
+ text = if (expandVersions) stringResource(R.string.less) else "+${versions.size - 1}"
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ if (!patch.options.isNullOrEmpty()) {
+ AnimatedVisibility(visible = expandOptions) {
+ val options = patch.options
+
+ Column {
+ options.forEachIndexed { i, option ->
+ OutlinedCard(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardColors(
+ containerColor = Color.Transparent,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ disabledContainerColor = Color.Transparent,
+ disabledContentColor = MaterialTheme.colorScheme.onSurface
+ ), shape = when {
+ options.size == 1 -> RoundedCornerShape(8.dp)
+ i == 0 -> RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
+ i == options.lastIndex -> RoundedCornerShape(
+ bottomStart = 8.dp,
+ bottomEnd = 8.dp
+ )
+
+ else -> RoundedCornerShape(0.dp)
+ }
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Text(
+ text = option.title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = option.description,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun PatchInfoChip(
+ modifier: Modifier = Modifier,
+ onClick: (() -> Unit)? = null,
+ text: String
+) {
+ val shape = RoundedCornerShape(8.0.dp)
+ val cardModifier = if (onClick != null) {
+ Modifier
+ .clip(shape)
+ .clickable(onClick = onClick)
+ } else {
+ Modifier
+ }
+
+ OutlinedCard(
+ modifier = modifier.then(cardModifier),
+ colors = CardColors(
+ containerColor = Color.Transparent,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ disabledContainerColor = Color.Transparent,
+ disabledContentColor = MaterialTheme.colorScheme.onSurface
+ ),
+ shape = shape,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.20f))
+ ) {
+ Row(
+ modifier = Modifier.padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text,
+ overflow = TextOverflow.Ellipsis,
+ softWrap = false,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+const val PACKAGE_ICON = "\uD83D\uDCE6"
+const val VERSION_ICON = "\uD83C\uDFAF"
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt
new file mode 100644
index 0000000000..8ea55e22f4
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt
@@ -0,0 +1,76 @@
+package app.revanced.manager.ui.component.bundle
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.domain.bundles.PatchBundleSource
+import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BundleSelector(bundles: List, onFinish: (PatchBundleSource?) -> Unit) {
+ LaunchedEffect(bundles) {
+ if (bundles.size == 1) {
+ onFinish(bundles[0])
+ }
+ }
+
+ if (bundles.size < 2) {
+ return
+ }
+
+ ModalBottomSheet(
+ onDismissRequest = { onFinish(null) }
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .height(48.dp)
+ .fillMaxWidth()
+ ) {
+ Text(
+ text = "Select bundle",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ bundles.forEach {
+ val name by it.nameState
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .height(48.dp)
+ .fillMaxWidth()
+ .clickable {
+ onFinish(it)
+ }
+ ) {
+ Text(
+ name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleTopBar.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleTopBar.kt
new file mode 100644
index 0000000000..543d2d3465
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleTopBar.kt
@@ -0,0 +1,46 @@
+package app.revanced.manager.ui.component.bundle
+
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.dp
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BundleTopBar(
+ title: String,
+ onBackClick: (() -> Unit)? = null,
+ actions: @Composable (RowScope.() -> Unit) = {},
+ scrollBehavior: TopAppBarScrollBehavior? = null,
+ backIcon: @Composable () -> Unit,
+) {
+ val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
+
+ TopAppBar(
+ title = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge
+ )
+ },
+ scrollBehavior = scrollBehavior,
+ navigationIcon = {
+ if (onBackClick != null) {
+ IconButton(onClick = onBackClick) {
+ backIcon()
+ }
+ }
+ },
+ actions = actions,
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = containerColor
+ )
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt
new file mode 100644
index 0000000000..37d9ed1ad0
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt
@@ -0,0 +1,237 @@
+package app.revanced.manager.ui.component.bundle
+
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Topic
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.AlertDialogExtended
+import app.revanced.manager.ui.component.TextHorizontalPadding
+import app.revanced.manager.ui.component.haptics.HapticCheckbox
+import app.revanced.manager.ui.component.haptics.HapticRadioButton
+import app.revanced.manager.ui.model.BundleType
+import app.revanced.manager.util.BIN_MIMETYPE
+import app.revanced.manager.util.transparentListItemColors
+
+@Composable
+fun ImportPatchBundleDialog(
+ onDismiss: () -> Unit,
+ onRemoteSubmit: (String, Boolean) -> Unit,
+ onLocalSubmit: (Uri) -> Unit
+) {
+ var currentStep by rememberSaveable { mutableIntStateOf(0) }
+ var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) }
+ var patchBundle by rememberSaveable { mutableStateOf(null) }
+ var remoteUrl by rememberSaveable { mutableStateOf("") }
+ var autoUpdate by rememberSaveable { mutableStateOf(false) }
+
+ val patchActivityLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ uri?.let { patchBundle = it }
+ }
+
+ fun launchPatchActivity() {
+ patchActivityLauncher.launch(BIN_MIMETYPE)
+ }
+
+ val steps = listOf<@Composable () -> Unit>(
+ {
+ SelectBundleTypeStep(bundleType) { selectedType ->
+ bundleType = selectedType
+ }
+ },
+ {
+ ImportBundleStep(
+ bundleType,
+ patchBundle,
+ remoteUrl,
+ autoUpdate,
+ { launchPatchActivity() },
+ { remoteUrl = it },
+ { autoUpdate = it }
+ )
+ }
+ )
+
+ val inputsAreValid by remember {
+ derivedStateOf {
+ (bundleType == BundleType.Local && patchBundle != null) ||
+ (bundleType == BundleType.Remote && remoteUrl.isNotEmpty())
+ }
+ }
+
+ AlertDialogExtended(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(stringResource(if (currentStep == 0) R.string.select else R.string.add_patch_bundle))
+ },
+ text = {
+ steps[currentStep]()
+ },
+ confirmButton = {
+ if (currentStep == steps.lastIndex) {
+ TextButton(
+ enabled = inputsAreValid,
+ onClick = {
+ when (bundleType) {
+ BundleType.Local -> patchBundle?.let(onLocalSubmit)
+ BundleType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate)
+ }
+ }
+ ) {
+ Text(stringResource(R.string.add))
+ }
+ } else {
+ TextButton(onClick = { currentStep++ }) {
+ Text(stringResource(R.string.next))
+ }
+ }
+ },
+ dismissButton = {
+ if (currentStep > 0) {
+ TextButton(onClick = { currentStep-- }) {
+ Text(stringResource(R.string.back))
+ }
+ } else {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ },
+ textHorizontalPadding = PaddingValues(0.dp)
+ )
+}
+
+@Composable
+fun SelectBundleTypeStep(
+ bundleType: BundleType,
+ onBundleTypeSelected: (BundleType) -> Unit
+) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ Text(
+ modifier = Modifier.padding(horizontal = 24.dp),
+ text = stringResource(R.string.select_bundle_type_dialog_description)
+ )
+ Column {
+ ListItem(
+ modifier = Modifier.clickable(
+ role = Role.RadioButton,
+ onClick = { onBundleTypeSelected(BundleType.Remote) }
+ ),
+ headlineContent = { Text(stringResource(R.string.enter_url)) },
+ overlineContent = { Text(stringResource(R.string.recommended)) },
+ supportingContent = { Text(stringResource(R.string.remote_bundle_description)) },
+ leadingContent = {
+ HapticRadioButton(
+ selected = bundleType == BundleType.Remote,
+ onClick = null
+ )
+ },
+ colors = transparentListItemColors
+ )
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ ListItem(
+ modifier = Modifier.clickable(
+ role = Role.RadioButton,
+ onClick = { onBundleTypeSelected(BundleType.Local) }
+ ),
+ headlineContent = { Text(stringResource(R.string.select_from_storage)) },
+ supportingContent = { Text(stringResource(R.string.local_bundle_description)) },
+ overlineContent = { },
+ leadingContent = {
+ HapticRadioButton(
+ selected = bundleType == BundleType.Local,
+ onClick = null
+ )
+ },
+ colors = transparentListItemColors
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ImportBundleStep(
+ bundleType: BundleType,
+ patchBundle: Uri?,
+ remoteUrl: String,
+ autoUpdate: Boolean,
+ launchPatchActivity: () -> Unit,
+ onRemoteUrlChange: (String) -> Unit,
+ onAutoUpdateChange: (Boolean) -> Unit
+) {
+ Column {
+ when (bundleType) {
+ BundleType.Local -> {
+ Column(
+ modifier = Modifier.padding(horizontal = 8.dp)
+ ) {
+ ListItem(
+ headlineContent = {
+ Text(stringResource(R.string.patch_bundle_field))
+ },
+ supportingContent = { Text(stringResource(if (patchBundle != null) R.string.file_field_set else R.string.file_field_not_set)) },
+ trailingContent = {
+ IconButton(onClick = launchPatchActivity) {
+ Icon(imageVector = Icons.Default.Topic, contentDescription = null)
+ }
+ },
+ modifier = Modifier.clickable { launchPatchActivity() },
+ colors = transparentListItemColors
+ )
+ }
+ }
+
+ BundleType.Remote -> {
+ Column(
+ modifier = Modifier.padding(TextHorizontalPadding)
+ ) {
+ OutlinedTextField(
+ value = remoteUrl,
+ onValueChange = onRemoteUrlChange,
+ label = { Text(stringResource(R.string.bundle_url)) }
+ )
+ }
+ Column(
+ modifier = Modifier.padding(horizontal = 8.dp)
+ ) {
+ ListItem(
+ modifier = Modifier.clickable(
+ role = Role.Checkbox,
+ onClick = { onAutoUpdateChange(!autoUpdate) }
+ ),
+ headlineContent = { Text(stringResource(R.string.auto_update)) },
+ leadingContent = {
+ CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
+ HapticCheckbox(
+ checked = autoUpdate,
+ onCheckedChange = {
+ onAutoUpdateChange(!autoUpdate)
+ }
+ )
+ }
+ },
+ colors = transparentListItemColors
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt
new file mode 100644
index 0000000000..fb5453f960
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticCheckbox.kt
@@ -0,0 +1,30 @@
+package app.revanced.manager.ui.component.haptics
+
+import android.view.HapticFeedbackConstants
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CheckboxColors
+import androidx.compose.material3.CheckboxDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import app.revanced.manager.util.withHapticFeedback
+
+@Composable
+fun HapticCheckbox(
+ checked: Boolean,
+ onCheckedChange: ((Boolean) -> Unit)?,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: CheckboxColors = CheckboxDefaults.colors(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = onCheckedChange?.withHapticFeedback(HapticFeedbackConstants.CLOCK_TICK),
+ modifier = modifier,
+ enabled = enabled,
+ colors = colors,
+ interactionSource = interactionSource
+ )
+}
diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt
new file mode 100644
index 0000000000..4fc6ad30a8
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticExtendedFloatingActionButton.kt
@@ -0,0 +1,41 @@
+package app.revanced.manager.ui.component.haptics
+
+import android.view.HapticFeedbackConstants
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.FloatingActionButtonElevation
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import app.revanced.manager.util.withHapticFeedback
+
+@Composable
+fun HapticExtendedFloatingActionButton (
+ text: @Composable () -> Unit,
+ icon: @Composable () -> Unit,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ expanded: Boolean = true,
+ shape: Shape = FloatingActionButtonDefaults.extendedFabShape,
+ containerColor: Color = FloatingActionButtonDefaults.containerColor,
+ contentColor: Color = contentColorFor(containerColor),
+ elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ ExtendedFloatingActionButton(
+ text = text,
+ icon = icon,
+ onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY),
+ modifier = modifier,
+ expanded = expanded,
+ shape = shape,
+ containerColor = containerColor,
+ contentColor = contentColor,
+ elevation = elevation,
+ interactionSource = interactionSource
+ )
+}
diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt
new file mode 100644
index 0000000000..f4a2e15333
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticFloatingActionButton.kt
@@ -0,0 +1,37 @@
+package app.revanced.manager.ui.component.haptics
+
+import android.view.HapticFeedbackConstants
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.FloatingActionButtonElevation
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import app.revanced.manager.util.withHapticFeedback
+
+@Composable
+fun HapticFloatingActionButton (
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ shape: Shape = FloatingActionButtonDefaults.shape,
+ containerColor: Color = FloatingActionButtonDefaults.containerColor,
+ contentColor: Color = contentColorFor(containerColor),
+ elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ content: @Composable () -> Unit,
+) {
+ FloatingActionButton(
+ onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY),
+ modifier = modifier,
+ shape = shape,
+ containerColor = containerColor,
+ contentColor = contentColor,
+ elevation = elevation,
+ interactionSource = interactionSource,
+ content = content
+ )
+}
diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt
new file mode 100644
index 0000000000..63a9e58278
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticRadioButton.kt
@@ -0,0 +1,38 @@
+package app.revanced.manager.ui.component.haptics
+
+import android.view.HapticFeedbackConstants
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.RadioButtonColors
+import androidx.compose.material3.RadioButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalView
+
+@Composable
+fun HapticRadioButton(
+ selected: Boolean,
+ onClick: (() -> Unit)?,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ colors: RadioButtonColors = RadioButtonDefaults.colors(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ val view = LocalView.current
+
+ RadioButton(
+ selected = selected,
+ onClick = onClick?.let {
+ {
+ // Perform haptic feedback
+ if (!selected) view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
+ it()
+ }
+ },
+ modifier = modifier,
+ enabled = enabled,
+ colors = colors,
+ interactionSource = interactionSource
+ )
+}
diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt
new file mode 100644
index 0000000000..c2491397f6
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticSwitch.kt
@@ -0,0 +1,41 @@
+package app.revanced.manager.ui.component.haptics
+
+import android.os.Build
+import android.view.HapticFeedbackConstants
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchColors
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+
+@Composable
+fun HapticSwitch(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ thumbContent: (@Composable () -> Unit)? = null,
+ enabled: Boolean = true,
+ colors: SwitchColors = SwitchDefaults.colors(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+) {
+ Switch(
+ checked = checked,
+ onCheckedChange = { newChecked ->
+ val useNewConstants = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
+ when {
+ newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_ON
+ newChecked -> HapticFeedbackConstants.VIRTUAL_KEY
+ !newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_OFF
+ !newChecked -> HapticFeedbackConstants.CLOCK_TICK
+ }
+ onCheckedChange(newChecked)
+ },
+ modifier = modifier,
+ thumbContent = thumbContent,
+ enabled = enabled,
+ colors = colors,
+ interactionSource = interactionSource,
+ )
+}
diff --git a/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt
new file mode 100644
index 0000000000..d0676951ea
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/haptics/HapticTab.kt
@@ -0,0 +1,36 @@
+package app.revanced.manager.ui.component.haptics
+
+import android.view.HapticFeedbackConstants
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.Tab
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import app.revanced.manager.util.withHapticFeedback
+
+@Composable
+fun HapticTab (
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ text: @Composable (() -> Unit)? = null,
+ icon: @Composable (() -> Unit)? = null,
+ selectedContentColor: Color = LocalContentColor.current,
+ unselectedContentColor: Color = selectedContentColor,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ Tab(
+ selected = selected,
+ onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY),
+ modifier = modifier,
+ enabled = enabled,
+ text = text,
+ icon = icon,
+ selectedContentColor = selectedContentColor,
+ unselectedContentColor = unselectedContentColor,
+ interactionSource = interactionSource
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt
new file mode 100644
index 0000000000..b86124d918
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/patcher/InstallPickerDialog.kt
@@ -0,0 +1,61 @@
+package app.revanced.manager.ui.component.patcher
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import app.revanced.manager.R
+import app.revanced.manager.data.room.apps.installed.InstallType
+import app.revanced.manager.ui.component.haptics.HapticRadioButton
+import app.revanced.manager.util.transparentListItemColors
+
+@Composable
+fun InstallPickerDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (InstallType) -> Unit
+) {
+ var selectedInstallType by rememberSaveable { mutableStateOf(InstallType.DEFAULT) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ onConfirm(selectedInstallType)
+ onDismiss()
+ }
+ ) {
+ Text(stringResource(R.string.install_app))
+ }
+ },
+ title = { Text(stringResource(R.string.select_install_type)) },
+ text = {
+ Column {
+ InstallType.entries.forEach {
+ ListItem(
+ modifier = Modifier.clickable { selectedInstallType = it },
+ leadingContent = {
+ HapticRadioButton(
+ selected = selectedInstallType == it,
+ onClick = null
+ )
+ },
+ headlineContent = { Text(stringResource(it.stringResource)) },
+ colors = transparentListItemColors
+ )
+ }
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt b/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt
new file mode 100644
index 0000000000..d6c78263c2
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt
@@ -0,0 +1,251 @@
+package app.revanced.manager.ui.component.patcher
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.outlined.Circle
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import app.revanced.manager.R
+import app.revanced.manager.ui.component.ArrowButton
+import app.revanced.manager.ui.component.LoadingIndicator
+import app.revanced.manager.ui.model.ProgressKey
+import app.revanced.manager.ui.model.State
+import app.revanced.manager.ui.model.Step
+import app.revanced.manager.ui.model.StepCategory
+import app.revanced.manager.ui.model.StepProgressProvider
+import java.util.Locale
+import kotlin.math.floor
+
+// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
+@Composable
+fun Steps(
+ category: StepCategory,
+ steps: List,
+ stepCount: Pair? = null,
+ stepProgressProvider: StepProgressProvider
+) {
+ var expanded by rememberSaveable { mutableStateOf(true) }
+
+ val categoryColor by animateColorAsState(
+ if (expanded) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent,
+ label = "category"
+ )
+
+ val cardColor by animateColorAsState(
+ if (expanded) MaterialTheme.colorScheme.surfaceContainer else Color.Transparent,
+ label = "card"
+ )
+
+ val state = remember(steps) {
+ when {
+ steps.all { it.state == State.COMPLETED } -> State.COMPLETED
+ steps.any { it.state == State.FAILED } -> State.FAILED
+ steps.any { it.state == State.RUNNING } -> State.RUNNING
+ else -> State.WAITING
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .fillMaxWidth()
+ .background(cardColor)
+ ) {
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .clickable { expanded = !expanded }
+ .background(categoryColor)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.padding(16.dp)
+ ) {
+ StepIcon(state = state, size = 24.dp)
+
+ Text(stringResource(category.displayName))
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ val stepProgress = remember(stepCount, steps) {
+ stepCount?.let { (current, total) -> "$current/$total" }
+ ?: "${steps.count { it.state == State.COMPLETED }}/${steps.size}"
+ }
+
+ Text(
+ text = stepProgress,
+ style = MaterialTheme.typography.labelSmall
+ )
+
+ ArrowButton(modifier = Modifier.size(24.dp), expanded = expanded, onClick = null)
+ }
+ }
+
+ AnimatedVisibility(visible = expanded) {
+ Column(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ steps.forEach { step ->
+ val (progress, progressText) = when (step.progressKey) {
+ null -> null
+ ProgressKey.DOWNLOAD -> stepProgressProvider.downloadProgress?.let { (downloaded, total) ->
+ if (total != null) downloaded.toFloat() / total.toFloat() to "${downloaded.megaBytes}/${total.megaBytes} MB"
+ else null to "${downloaded.megaBytes} MB"
+ }
+ } ?: (null to null)
+
+ SubStep(
+ name = step.name,
+ state = step.state,
+ message = step.message,
+ progress = progress,
+ progressText = progressText
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SubStep(
+ name: String,
+ state: State,
+ message: String? = null,
+ progress: Float? = null,
+ progressText: String? = null
+) {
+ var messageExpanded by rememberSaveable { mutableStateOf(true) }
+
+ Column(
+ modifier = Modifier
+ .run {
+ if (message != null)
+ clickable { messageExpanded = !messageExpanded }
+ else this
+ }
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ StepIcon(state, progress, size = 20.dp)
+ }
+
+ Text(
+ text = name,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f, true),
+ )
+
+ when {
+ message != null -> Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ ArrowButton(
+ modifier = Modifier.size(20.dp),
+ expanded = messageExpanded,
+ onClick = null
+ )
+ }
+
+ progressText != null -> Text(
+ progressText,
+ style = MaterialTheme.typography.labelSmall
+ )
+ }
+ }
+
+ AnimatedVisibility(visible = messageExpanded && message != null) {
+ Text(
+ text = message.orEmpty(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier.padding(horizontal = 52.dp, vertical = 8.dp)
+ )
+ }
+ }
+}
+
+@Composable
+fun StepIcon(state: State, progress: Float? = null, size: Dp) {
+ val strokeWidth = Dp(floor(size.value / 10) + 1)
+
+ when (state) {
+ State.COMPLETED -> Icon(
+ Icons.Filled.CheckCircle,
+ contentDescription = stringResource(R.string.step_completed),
+ tint = MaterialTheme.colorScheme.surfaceTint,
+ modifier = Modifier.size(size)
+ )
+
+ State.FAILED -> Icon(
+ Icons.Filled.Cancel,
+ contentDescription = stringResource(R.string.step_failed),
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(size)
+ )
+
+ State.WAITING -> Icon(
+ Icons.Outlined.Circle,
+ contentDescription = stringResource(R.string.step_waiting),
+ tint = MaterialTheme.colorScheme.surfaceVariant,
+ modifier = Modifier.size(size)
+ )
+
+ State.RUNNING ->
+ LoadingIndicator(
+ modifier = stringResource(R.string.step_running).let { description ->
+ Modifier
+ .size(size)
+ .semantics {
+ contentDescription = description
+ }
+ },
+ progress = { progress },
+ strokeWidth = strokeWidth
+ )
+ }
+}
+
+private val Long.megaBytes get() = "%.1f".format(locale = Locale.ROOT, toDouble() / 1_000_000)
\ No newline at end of file
diff --git a/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt
new file mode 100644
index 0000000000..06438176b8
--- /dev/null
+++ b/app/src/main/java/app/revanced/manager/ui/component/patches/OptionFields.kt
@@ -0,0 +1,657 @@
+package app.revanced.manager.ui.component.patches
+
+import android.app.Application
+import android.os.Parcelable
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.DragHandle
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisallowComposableCalls
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.toMutableStateList
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.DialogProperties
+import app.revanced.manager.R
+import app.revanced.manager.data.platform.Filesystem
+import app.revanced.manager.patcher.patch.Option
+import app.revanced.manager.ui.component.*
+import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
+import app.revanced.manager.ui.component.haptics.HapticRadioButton
+import app.revanced.manager.ui.component.haptics.HapticSwitch
+import app.revanced.manager.util.isScrollingUp
+import app.revanced.manager.util.mutableStateSetOf
+import app.revanced.manager.util.saver.snapshotStateListSaver
+import app.revanced.manager.util.saver.snapshotStateSetSaver
+import app.revanced.manager.util.toast
+import app.revanced.manager.util.transparentListItemColors
+import kotlinx.parcelize.Parcelize
+import org.koin.compose.koinInject
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.get
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyColumnState
+import java.io.Serializable
+import kotlin.random.Random
+import kotlin.reflect.typeOf
+import androidx.compose.ui.window.Dialog as ComposeDialog
+
+private class OptionEditorScope(
+ private val editor: OptionEditor,
+ val option: Option,
+ val openDialog: () -> Unit,
+ val dismissDialog: () -> Unit,
+ val value: T?,
+ val setValue: (T?) -> Unit,
+) {
+ fun submitDialog(value: T?) {
+ setValue(value)
+ dismissDialog()
+ }
+
+ fun clickAction() = editor.clickAction(this)
+
+ @Composable
+ fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
+
+ @Composable
+ fun Dialog() = editor.Dialog(this)
+}
+
+private interface OptionEditor {
+ fun clickAction(scope: OptionEditorScope) = scope.openDialog()
+
+ @Composable
+ fun ListItemTrailingContent(scope: OptionEditorScope) {
+ IconButton(onClick = { clickAction(scope) }) {
+ Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
+ }
+ }
+
+ @Composable
+ fun Dialog(scope: OptionEditorScope)
+}
+
+private inline fun OptionEditor.toMapEditorElements() = arrayOf(
+ typeOf() to this,
+ typeOf>() to ListOptionEditor(this)
+)
+
+private val optionEditors = mapOf(
+ *BooleanOptionEditor.toMapEditorElements(),
+ *StringOptionEditor.toMapEditorElements(),
+ *IntOptionEditor.toMapEditorElements(),
+ *LongOptionEditor.toMapEditorElements(),
+ *FloatOptionEditor.toMapEditorElements()
+)
+
+@Composable
+private inline fun WithOptionEditor(
+ editor: OptionEditor,
+ option: Option,
+ value: T?,
+ noinline setValue: (T?) -> Unit,
+ crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {},
+ block: OptionEditorScope.() -> Unit
+) {
+ var showDialog by rememberSaveable { mutableStateOf(false) }
+ val scope = remember(editor, option, value, setValue) {
+ OptionEditorScope(
+ editor,
+ option,
+ openDialog = { showDialog = true },
+ dismissDialog = {
+ showDialog = false
+ onDismissDialog()
+ },
+ value,
+ setValue
+ )
+ }
+
+ if (showDialog) scope.Dialog()
+
+ scope.block()
+}
+
+@Composable
+fun OptionItem(
+ option: Option,
+ value: T?,
+ setValue: (T?) -> Unit,
+) {
+ val editor = remember(option.type, option.presets) {
+ @Suppress("UNCHECKED_CAST")
+ val baseOptionEditor =
+ optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor
+
+ if (option.type != typeOf() && option.presets != null) PresetOptionEditor(
+ baseOptionEditor
+ )
+ else baseOptionEditor
+ }
+
+ WithOptionEditor(editor, option, value, setValue) {
+ ListItem(
+ modifier = Modifier.clickable(onClick = ::clickAction),
+ headlineContent = { Text(option.title) },
+ supportingContent = {
+ Column {
+ Text(option.description)
+ if (option.required && value == null) Text(
+ stringResource(R.string.option_required),
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ },
+ trailingContent = { ListItemTrailingContent() }
+ )
+ }
+}
+
+private object StringOptionEditor : OptionEditor {
+ @Composable
+ override fun Dialog(scope: OptionEditorScope) {
+ var showFileDialog by rememberSaveable { mutableStateOf(false) }
+ var fieldValue by rememberSaveable(scope.value) {
+ mutableStateOf(scope.value.orEmpty())
+ }
+ val validatorFailed by remember {
+ derivedStateOf { !scope.option.validator(fieldValue) }
+ }
+
+ val fs: Filesystem = koinInject()
+ val (contract, permissionName) = fs.permissionContract()
+ val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
+ showFileDialog = it
+ }
+
+ if (showFileDialog) {
+ PathSelectorDialog(
+ root = fs.externalFilesDir()
+ ) {
+ showFileDialog = false
+ it?.let { path ->
+ fieldValue = path.toString()
+ }
+ }
+ }
+
+ AlertDialog(
+ onDismissRequest = scope.dismissDialog,
+ title = { Text(scope.option.title) },
+ text = {
+ OutlinedTextField(
+ value = fieldValue,
+ onValueChange = { fieldValue = it },
+ placeholder = {
+ Text(stringResource(R.string.dialog_input_placeholder))
+ },
+ isError = validatorFailed,
+ supportingText = {
+ if (validatorFailed) {
+ Text(
+ stringResource(R.string.input_dialog_value_invalid),
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ },
+ trailingIcon = {
+ var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
+ IconButton(
+ onClick = { showDropdownMenu = true }
+ ) {
+ Icon(
+ Icons.Outlined.MoreVert,
+ stringResource(R.string.string_option_menu_description)
+ )
+ }
+
+ DropdownMenu(
+ expanded = showDropdownMenu,
+ onDismissRequest = { showDropdownMenu = false }
+ ) {
+ DropdownMenuItem(
+ leadingIcon = {
+ Icon(Icons.Outlined.Folder, null)
+ },
+ text = {
+ Text(stringResource(R.string.path_selector))
+ },
+ onClick = {
+ showDropdownMenu = false
+ if (fs.hasStoragePermission()) {
+ showFileDialog = true
+ } else {
+ permissionLauncher.launch(permissionName)
+ }
+ }
+ )
+ }
+ }
+ )
+ },
+ confirmButton = {
+ TextButton(
+ enabled = !validatorFailed,
+ onClick = { scope.submitDialog(fieldValue) }) {
+ Text(stringResource(R.string.save))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = scope.dismissDialog) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+ }
+}
+
+private abstract class NumberOptionEditor : OptionEditor {
+ @Composable
+ protected abstract fun NumberDialog(
+ title: String,
+ current: T?,
+ validator: (T?) -> Boolean,
+ onSubmit: (T?) -> Unit
+ )
+
+ @Composable
+ override fun Dialog(scope: OptionEditorScope) {
+ NumberDialog(scope.option.title, scope.value, scope.option.validator) {
+ if (it == null) return@NumberDialog scope.dismissDialog()
+
+ scope.submitDialog(it)
+ }
+ }
+}
+
+private object IntOptionEditor : NumberOptionEditor() {
+ @Composable
+ override fun NumberDialog(
+ title: String,
+ current: Int?,
+ validator: (Int?) -> Boolean,
+ onSubmit: (Int?) -> Unit
+ ) = IntInputDialog(current, title, validator, onSubmit)
+}
+
+private object LongOptionEditor : NumberOptionEditor() {
+ @Composable
+ override fun NumberDialog(
+ title: String,
+ current: Long?,
+ validator: (Long?) -> Boolean,
+ onSubmit: (Long?) -> Unit
+ ) = LongInputDialog(current, title, validator, onSubmit)
+}
+
+private object FloatOptionEditor : NumberOptionEditor() {
+ @Composable
+ override fun NumberDialog(
+ title: String,
+ current: Float?,
+ validator: (Float?) -> Boolean,
+ onSubmit: (Float?) -> Unit
+ ) = FloatInputDialog(current, title, validator, onSubmit)
+}
+
+private object BooleanOptionEditor : OptionEditor {
+ override fun clickAction(scope: OptionEditorScope) {
+ scope.setValue(!scope.current)
+ }
+
+ @Composable
+ override fun ListItemTrailingContent(scope: OptionEditorScope) {
+ HapticSwitch(checked = scope.current, onCheckedChange = scope.setValue)
+ }
+
+ @Composable
+ override fun Dialog(scope: OptionEditorScope) {
+ }
+
+ private val OptionEditorScope.current get() = value ?: false
+}
+
+private object UnknownTypeEditor : OptionEditor, KoinComponent {
+ override fun clickAction(scope: OptionEditorScope) =
+ get().toast("Unknown type: ${scope.option.type}")
+
+ @Composable
+ override fun Dialog(scope: OptionEditorScope) {
+ }
+}
+
+/**
+ * A wrapper for [OptionEditor]s that shows selectable presets.
+ *
+ * @param innerEditor The [OptionEditor] for [T].
+ */
+private class PresetOptionEditor(private val innerEditor: OptionEditor) :
+ OptionEditor {
+ @Composable
+ override fun Dialog(scope: OptionEditorScope) {
+ var selectedPreset by rememberSaveable(scope.value, scope.option.presets) {
+ val presets = scope.option.presets!!
+
+ mutableStateOf(presets.entries.find { it.value == scope.value }?.key)
+ }
+
+ WithOptionEditor(
+ innerEditor,
+ scope.option,
+ scope.value,
+ scope.setValue,
+ onDismissDialog = scope.dismissDialog
+ ) inner@{
+ var hidePresetsDialog by rememberSaveable {
+ mutableStateOf(false)
+ }
+ if (hidePresetsDialog) return@inner
+
+ // TODO: add a divider for scrollable content
+ AlertDialogExtended(
+ onDismissRequest = scope.dismissDialog,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ if (selectedPreset != null) scope.submitDialog(
+ scope.option.presets?.get(
+ selectedPreset
+ )
+ )
+ else {
+ this@inner.openDialog()
+ // Hide the presets dialog so it doesn't show up in the background.
+ hidePresetsDialog = true
+ }
+ }
+ ) {
+ Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = scope.dismissDialog) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ title = { Text(scope.option.title) },
+ textHorizontalPadding = PaddingValues(horizontal = 0.dp),
+ text = {
+ val presets = remember(scope.option.presets) {
+ scope.option.presets?.entries?.toList().orEmpty()
+ }
+
+ LazyColumn {
+ @Composable
+ fun Item(title: String, value: Any?, presetKey: String?) {
+ ListItem(
+ modifier = Modifier.clickable { selectedPreset = presetKey },
+ headlineContent = { Text(title) },
+ supportingContent = value?.toString()?.let { { Text(it) } },
+ leadingContent = {
+ HapticRadioButton(
+ selected = selectedPreset == presetKey,
+ onClick = { selectedPreset = presetKey }
+ )
+ },
+ colors = transparentListItemColors
+ )
+ }
+
+ items(presets, key = { it.key }) {
+ Item(it.key, it.value, it.key)
+ }
+
+ item(key = null) {
+ Item(stringResource(R.string.option_preset_custom_value), null, null)
+ }
+ }
+ }
+ )
+ }
+ }
+}
+
+private class ListOptionEditor(private val elementEditor: OptionEditor) :
+ OptionEditor> {
+ private fun createElementOption(option: Option>) = Option(
+ option.title,
+ option.key,
+ option.description,
+ option.required,
+ option.type.arguments.first().type!!,
+ null,
+ null
+ ) { true }
+
+ @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+ @Composable
+ override fun Dialog(scope: OptionEditorScope>) {
+ val items =
+ rememberSaveable(scope.value, saver = snapshotStateListSaver()) {
+ // We need a key for each element in order to support dragging.
+ scope.value?.map(::Item)?.toMutableStateList() ?: mutableStateListOf()
+ }
+ val listIsDirty by remember {
+ derivedStateOf {
+ val current = scope.value.orEmpty()
+ if (current.size != items.size) return@derivedStateOf true
+
+ current.forEachIndexed { index, value ->
+ if (value != items[index].value) return@derivedStateOf true
+ }
+
+ false
+ }
+ }
+
+ val lazyListState = rememberLazyListState()
+ val reorderableLazyColumnState =
+ rememberReorderableLazyColumnState(lazyListState) { from, to ->
+ // Update the list
+ items.add(to.index, items.removeAt(from.index))
+ }
+
+ var deleteMode by rememberSaveable {
+ mutableStateOf(false)
+ }
+ val deletionTargets = rememberSaveable(saver = snapshotStateSetSaver()) {
+ mutableStateSetOf()
+ }
+
+ val back = back@{
+ if (deleteMode) {
+ deletionTargets.clear()
+ deleteMode = false
+ return@back
+ }
+
+ if (!listIsDirty) {
+ scope.dismissDialog()
+ return@back
+ }
+
+ scope.submitDialog(items.mapNotNull { it.value })
+ }
+
+ ComposeDialog(
+ onDismissRequest = back,
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnBackPress = true
+ ),
+ ) {
+ Scaffold(
+ topBar = {
+ AppTopBar(
+ title = if (deleteMode) pluralStringResource(
+ R.plurals.selected_count,
+ deletionTargets.size,
+ deletionTargets.size
+ ) else scope.option.title,
+ onBackClick = back,
+ backIcon = {
+ if (deleteMode) {
+ return@AppTopBar Icon(
+ Icons.Filled.Close,
+ stringResource(R.string.cancel)
+ )
+ }
+
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
+ },
+ actions = {
+ if (deleteMode) {
+ IconButton(
+ onClick = {
+ if (items.size == deletionTargets.size) deletionTargets.clear()
+ else deletionTargets.addAll(items.map { it.key })
+ }
+ ) {
+ Icon(
+ Icons.Outlined.SelectAll,
+ stringResource(R.string.select_deselect_all)
+ )
+ }
+ IconButton(
+ onClick = {
+ items.removeIf { it.key in deletionTargets }
+ deletionTargets.clear()
+ deleteMode = false
+ }
+ ) {
+ Icon(
+ Icons.Outlined.Delete,
+ stringResource(R.string.delete)
+ )
+ }
+ } else {
+ IconButton(onClick = items::clear) {
+ Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
+ }
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ if (deleteMode) return@Scaffold
+
+ HapticExtendedFloatingActionButton(
+ text = { Text(stringResource(R.string.add)) },
+ icon = {
+ Icon(
+ Icons.Outlined.Add,
+ stringResource(R.string.add)
+ )
+ },
+ expanded = lazyListState.isScrollingUp,
+ onClick = { items.add(Item(null)) }
+ )
+ }
+ ) { paddingValues ->
+ val elementOption = remember(scope.option) { createElementOption(scope.option) }
+
+ LazyColumn(
+ state = lazyListState,
+ modifier = Modifier
+ .fillMaxHeight()
+ .padding(paddingValues),
+ ) {
+ itemsIndexed(items, key = { _, item -> item.key }) { index, item ->
+ val interactionSource = remember { MutableInteractionSource() }
+
+ ReorderableItem(reorderableLazyColumnState, key = item.key) {
+ WithOptionEditor(
+ elementEditor,
+ elementOption,
+ value = item.value,
+ setValue = { items[index] = item.copy(value = it) }
+ ) {
+ ListItem(
+ modifier = Modifier.combinedClickable(
+ indication = LocalIndication.current,
+ interactionSource = interactionSource,
+ onLongClickLabel = stringResource(R.string.select),
+ onLongClick = {
+ deletionTargets.add(item.key)
+ deleteMode = true
+ },
+ onClick = {
+ if (!deleteMode) {
+ clickAction()
+ return@combinedClickable
+ }
+
+ if (item.key in deletionTargets) {
+ deletionTargets.remove(
+ item.key
+ )
+ deleteMode = deletionTargets.isNotEmpty()
+ } else deletionTargets.add(item.key)
+ },
+ ),
+ tonalElevation = if (deleteMode && item.key in deletionTargets) 8.dp else 0.dp,
+ leadingContent = {
+ IconButton(
+ modifier = Modifier.draggableHandle(interactionSource = interactionSource),
+ onClick = {},
+ ) {
+ Icon(
+ Icons.Filled.DragHandle,
+ stringResource(R.string.drag_handle)
+ )
+ }
+ },
+ headlineContent = {
+ if (item.value == null) return@ListItem Text(
+ stringResource(R.string.empty),
+ fontStyle = FontStyle.Italic
+ )
+
+ Text(item.value.toString())
+ },
+ trailingContent = {
+ ListItemTrailingContent()
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Parcelize
+ private data class Item