Compare commits

...

47 Commits

Author SHA1 Message Date
semantic-release-bot
b838851ebe chore: Release API v1.0.0-dev.2 [skip ci]
# api [1.0.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/api@1.0.0-dev.1...api@1.0.0-dev.2) (2026-02-27)

### Features

* Update to Patcher v22 ([#2939](https://github.com/ReVanced/revanced-manager/issues/2939)) ([8667051](8667051283))
2026-02-27 15:59:00 +00:00
Ax333l
00b3c59572 fix: Release builds not working 2026-02-27 16:50:49 +01:00
Ushie
fc30ba5e83 fix: Remove quotes from strings 2026-02-27 08:52:59 +03:00
Plance Shimney
32228404c4 fix: we actually don't want to exclude reflection symbols 2026-02-25 20:36:58 +03:00
Ushie
f19080a986 fix: Incorrect patcher string value 2026-02-23 01:29:48 +03:00
Tornike Khintibidze
813df46847 feat: Add announcements (#2948)
Co-authored-by: Ushie <ushiekane@gmail.com>
Co-authored-by: Ax333l <main@axelen.xyz>
2026-02-23 01:23:18 +03:00
Ushie
6f4219c01b fix: Never show again toggle doing nothing for Update dialog 2026-02-22 08:02:24 +03:00
Ax333l
8667051283 feat: Update to Patcher v22 (#2939) 2026-02-22 00:52:09 +01:00
rushii
e0ce6b4ef3 build: Optimize packaging rules (#2949) 2026-02-22 00:08:41 +01:00
semantic-release-bot
b50688ee10 chore: Release v1.26.0-dev.23 [skip ci]
# app [1.26.0-dev.23](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.22...v1.26.0-dev.23) (2026-02-19)

### Bug Fixes

* Flutter migration ([#2946](https://github.com/ReVanced/revanced-manager/issues/2946)) ([31a0f75](31a0f751aa))
2026-02-19 22:06:14 +00:00
Jānis
31a0f751aa fix: Flutter migration (#2946) 2026-02-19 22:57:23 +01:00
semantic-release-bot
4f65555b56 chore: Release v1.26.0-dev.22 [skip ci]
# app [1.26.0-dev.22](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.21...v1.26.0-dev.22) (2026-02-19)

### Bug Fixes

* Create modules path in root installer ([4fc2ba3](4fc2ba339b))
2026-02-19 17:46:45 +00:00
Ax333l
4fc2ba339b fix: Create modules path in root installer 2026-02-19 18:38:20 +01:00
semantic-release-bot
76d0f17770 chore: Release v1.26.0-dev.21 [skip ci]
# app [1.26.0-dev.21](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.20...v1.26.0-dev.21) (2026-02-19)

### Features

* Allow multiple downloaders per APK ([#2916](https://github.com/ReVanced/revanced-manager/issues/2916)) ([0f56377](0f56377214))
2026-02-19 17:31:54 +00:00
semantic-release-bot
de1d167a4b chore: Release API v1.0.0-dev.1 [skip ci]
# api 1.0.0-dev.1 (2026-02-19)

### Features

* Allow multiple downloaders per APK ([#2916](https://github.com/ReVanced/revanced-manager/issues/2916)) ([0f56377](0f56377214))
2026-02-19 17:30:48 +00:00
oSumAtrIX
0f56377214 feat: Allow multiple downloaders per APK (#2916)
Co-authored-by: Ax333l <main@axelen.xyz>
2026-02-19 18:18:14 +01:00
semantic-release-bot
7615453eec chore: Release v1.26.0-dev.20 [skip ci]
# app [1.26.0-dev.20](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.19...v1.26.0-dev.20) (2026-01-09)

### Bug Fixes

* Save FAB freaking out in select patches screen ([4c0b6b0](4c0b6b02e9))
2026-01-09 20:41:33 +00:00
Ax333l
4c0b6b02e9 fix: Save FAB freaking out in select patches screen 2026-01-09 21:33:08 +01:00
Ax333l
fe84b22b6f chore: update dependencies and fix deprecations 2026-01-09 19:36:04 +01:00
semantic-release-bot
1b21f5d4ab chore: Release v1.26.0-dev.19 [skip ci]
# app [1.26.0-dev.19](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.18...v1.26.0-dev.19) (2026-01-08)

### Bug Fixes

* **locales:** use buildconfig instead of generating kt file ([72b1db9](72b1db9a2f))
2026-01-08 22:35:21 +00:00
Ax333l
72b1db9a2f fix(locales): use buildconfig instead of generating kt file 2026-01-08 23:27:02 +01:00
semantic-release-bot
2805ac6540 chore: Release v1.26.0-dev.18 [skip ci]
# app [1.26.0-dev.18](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.17...v1.26.0-dev.18) (2026-01-08)

### Bug Fixes

* Prevent trailing comma when no locales are generated ([b16931c](b16931ca79))

### Features

* Add language settings ([#2913](https://github.com/ReVanced/revanced-manager/issues/2913)) ([df31b39](df31b39cc8))
2026-01-08 21:12:25 +00:00
Robert
b16931ca79 fix: Prevent trailing comma when no locales are generated 2026-01-08 22:04:03 +01:00
Ushie
dfeca09d00 ci: Switch to using crowdin.yml to specify filename 2026-01-07 23:50:57 +03:00
Ushie
44c06e2197 ci: Use a clearer file name for source file to display in Crowdin 2026-01-07 23:43:40 +03:00
Ushie
df31b39cc8 feat: Add language settings (#2913) 2026-01-07 22:54:48 +03:00
semantic-release-bot
25d82e869c chore: Release v1.26.0-dev.17 [skip ci]
# app [1.26.0-dev.17](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.16...v1.26.0-dev.17) (2026-01-06)

### Bug Fixes

* allow updating patches on metered networks ([9d9a0e8](9d9a0e81db))
2026-01-06 21:45:09 +00:00
Ax333l
9d9a0e81db fix: allow updating patches on metered networks 2026-01-06 22:37:25 +01:00
semantic-release-bot
ffa42099e3 chore: Release v1.26.0-dev.16 [skip ci]
# app [1.26.0-dev.16](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.15...v1.26.0-dev.16) (2025-12-30)

### Features

* Show patches as individual steps in patcher screen ([#2889](https://github.com/ReVanced/revanced-manager/issues/2889)) ([11dd6e4](11dd6e4064))
2025-12-30 00:16:11 +00:00
Robert
11dd6e4064 feat: Show patches as individual steps in patcher screen (#2889)
Co-authored-by: Ax333l <main@axelen.xyz>
2025-12-30 01:08:54 +01:00
semantic-release-bot
35fb59b31d chore: Release v1.26.0-dev.15 [skip ci]
# app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29)

### Bug Fixes

* install dialog getting stuck ([#2900](https://github.com/ReVanced/revanced-manager/issues/2900)) ([18a4df9](18a4df9af9))
2025-12-29 22:49:24 +00:00
Ax333l
18a4df9af9 fix: install dialog getting stuck (#2900) 2025-12-29 23:42:14 +01:00
semantic-release-bot
bd69b45a69 chore: Release v1.26.0-dev.14 [skip ci]
# app [1.26.0-dev.14](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.13...v1.26.0-dev.14) (2025-12-28)

### Bug Fixes

* Update selected patch count when SelectionState changes ([#2896](https://github.com/ReVanced/revanced-manager/issues/2896)) ([0d26df0](0d26df03f4))
2025-12-28 18:20:22 +00:00
Robert
0d26df03f4 fix: Update selected patch count when SelectionState changes (#2896) 2025-12-28 19:13:00 +01:00
semantic-release-bot
c436a7a100 chore: Release v1.26.0-dev.13 [skip ci]
# app [1.26.0-dev.13](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.12...v1.26.0-dev.13) (2025-12-17)

### Features

* Make patcher screen design more consistent with inspiration ([#2805](https://github.com/ReVanced/revanced-manager/issues/2805)) ([dbb6c01](dbb6c01e89))
2025-12-17 20:05:47 +00:00
Ushie
dbb6c01e89 feat: Make patcher screen design more consistent with inspiration (#2805) 2025-12-17 22:58:02 +03:00
semantic-release-bot
e0d529c2df chore: Release v1.26.0-dev.12 [skip ci]
# app [1.26.0-dev.12](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.11...v1.26.0-dev.12) (2025-12-17)

### Features

* Improve trust plugin dialog design ([#2420](https://github.com/ReVanced/revanced-manager/issues/2420)) ([0300da9](0300da9eac))
2025-12-17 16:04:25 +00:00
Ushie
0300da9eac feat: Improve trust plugin dialog design (#2420) 2025-12-17 18:54:04 +03:00
LisoUseInAIKyrios
4e5057ecad chore: Use raw string encoding to prevent AI translation errors (#2794)
Co-authored-by: Pun Butrach <pun.butrach@gmail.com>
2025-10-25 20:17:49 +07:00
Pun Butrach
1ef5c1c5c5 docs: Adjust feature request template to match other repository 2025-10-25 19:40:31 +07:00
Coby
39f52c1242 chore: Correct grammar mistakes (#2791)
Co-authored-by: Kobe <kobew5050@gmail.com>
2025-10-25 19:14:34 +07:00
semantic-release-bot
0c62837454 chore: Release v1.26.0-dev.11 [skip ci]
# app [1.26.0-dev.11](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.10...v1.26.0-dev.11) (2025-10-25)

### Features

* Add pure black theme ([#2824](https://github.com/ReVanced/revanced-manager/issues/2824)) ([3d75ffe](3d75ffe6a7))
2025-10-25 03:55:05 +00:00
alieRN
3d75ffe6a7 feat: Add pure black theme (#2824) 2025-10-25 10:46:50 +07:00
Coby
d188d5a410 docs: Fix typos and settings order (#2785)
Co-authored-by: Kobe <kobew5050@gmail.com>
Co-authored-by: Pun Butrach <pun.butrach@gmail.com>
2025-10-18 00:34:59 +07:00
semantic-release-bot
b43ac8a1af chore: Release v1.26.0-dev.10 [skip ci]
# app [1.26.0-dev.10](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.9...v1.26.0-dev.10) (2025-10-06)

### Bug Fixes

* prevent back presses during installation ([2ff7072](2ff70728b4))
2025-10-06 17:48:23 +00:00
Ax333l
2ff70728b4 fix: prevent back presses during installation 2025-10-06 19:40:21 +02:00
semantic-release-bot
e5097b5ecd chore: Release v1.26.0-dev.9 [skip ci]
# app [1.26.0-dev.9](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.8...v1.26.0-dev.9) (2025-10-06)

### Bug Fixes

* Instantly re-fetch patch bundle on pre-release preference update ([d5671db](d5671db3a7)), closes [#2784](https://github.com/ReVanced/revanced-manager/issues/2784)
2025-10-06 00:44:24 +00:00
121 changed files with 3206 additions and 1686 deletions

View File

@@ -1,3 +1,7 @@
name: ⭐ Feature request
description: Create a detailed request for a new feature.
title: 'feat: '
labels: ['Feature request']
body:
- type: markdown
attributes:

43
.github/workflows/pull_strings.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Pull strings
on:
schedule:
- cron: "0 0 * * 0"
workflow_dispatch:
jobs:
pull:
name: Pull strings
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: dev
clean: true
- name: Pull strings
uses: crowdin/github-action@v2
with:
config: crowdin.yml
upload_sources: false
download_translations: true
skip_ref_checkout: true
localization_branch_name: feat/translations
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Open pull request
if: github.event_name == 'workflow_dispatch'
uses: repo-sync/pull-request@v2
with:
source_branch: feat/translations
destination_branch: dev
pr_title: "chore: Sync translations"
pr_body: "Sync translations from [crowdin.com/project/revanced](https://crowdin.com/project/revanced)"

26
.github/workflows/push_strings.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Push strings
on:
workflow_dispatch:
push:
branches:
- dev
paths:
- app/src/main/res/values/strings.xml
jobs:
push:
name: Push strings
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Push strings
uses: crowdin/github-action@v2
with:
config: crowdin.yml
upload_sources: true
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -73,7 +73,7 @@ ReVanced Manager is an application that uses [ReVanced Patcher](https://github.c
Some of the features ReVanced Manager provides are:
- ⬇️ **Download**: Automatically download apps using the ReVanced Manager downloader plugin system
- ⬇️ **Download**: Automatically download apps using the ReVanced Manager downloader system
- 💉 **Patch**: Select and apply patches to any Android app
- 🛠️ **Customize**: Manage patches, apps, signing, themes, updates, and many more settings

13
api/CHANGELOG.md Normal file
View File

@@ -0,0 +1,13 @@
# api [1.0.0-dev.2](https://github.com/ReVanced/revanced-manager/compare/api@1.0.0-dev.1...api@1.0.0-dev.2) (2026-02-27)
### Features
* Update to Patcher v22 ([#2939](https://github.com/ReVanced/revanced-manager/issues/2939)) ([8667051](https://github.com/ReVanced/revanced-manager/commit/8667051283f934a32ac7b7cc76178397dc45a0e6))
# api 1.0.0-dev.1 (2026-02-19)
### Features
* Allow multiple downloaders per APK ([#2916](https://github.com/ReVanced/revanced-manager/issues/2916)) ([0f56377](https://github.com/ReVanced/revanced-manager/commit/0f56377214fae05693a21177daf7534c103df74e))

View File

@@ -1,19 +1,19 @@
public abstract interface class app/revanced/manager/plugin/downloader/BaseDownloadScope : app/revanced/manager/plugin/downloader/Scope {
public abstract interface class app/revanced/manager/downloader/BaseDownloadScope : app/revanced/manager/downloader/Scope {
}
public final class app/revanced/manager/plugin/downloader/ConstantsKt {
public static final field PLUGIN_HOST_PERMISSION Ljava/lang/String;
public final class app/revanced/manager/downloader/ConstantsKt {
public static final field DOWNLOADER_HOST_PERMISSION Ljava/lang/String;
}
public final class app/revanced/manager/plugin/downloader/DownloadUrl : android/os/Parcelable {
public final class app/revanced/manager/downloader/DownloadUrl : android/os/Parcelable {
public static final field $stable I
public static final field CREATOR Landroid/os/Parcelable$Creator;
public fun <init> (Ljava/lang/String;Ljava/util/Map;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/util/Map;
public final fun copy (Ljava/lang/String;Ljava/util/Map;)Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public static synthetic fun copy$default (Lapp/revanced/manager/plugin/downloader/DownloadUrl;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public final fun copy (Ljava/lang/String;Ljava/util/Map;)Lapp/revanced/manager/downloader/DownloadUrl;
public static synthetic fun copy$default (Lapp/revanced/manager/downloader/DownloadUrl;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lapp/revanced/manager/downloader/DownloadUrl;
public final fun describeContents ()I
public fun equals (Ljava/lang/Object;)Z
public final fun getHeaders ()Ljava/util/Map;
@@ -24,58 +24,53 @@ public final class app/revanced/manager/plugin/downloader/DownloadUrl : android/
public final fun writeToParcel (Landroid/os/Parcel;I)V
}
public final class app/revanced/manager/plugin/downloader/DownloadUrl$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public synthetic fun newArray (I)[Ljava/lang/Object;
}
public final class app/revanced/manager/plugin/downloader/Downloader {
public final class app/revanced/manager/downloader/Downloader {
public static final field $stable I
}
public final class app/revanced/manager/plugin/downloader/DownloaderBuilder {
public final class app/revanced/manager/downloader/DownloaderBuilder {
public static final field $stable I
}
public final class app/revanced/manager/plugin/downloader/DownloaderKt {
public static final fun Downloader (Lkotlin/jvm/functions/Function1;)Lapp/revanced/manager/plugin/downloader/DownloaderBuilder;
public abstract interface annotation class app/revanced/manager/downloader/DownloaderHostApi : java/lang/annotation/Annotation {
}
public final class app/revanced/manager/plugin/downloader/DownloaderScope : app/revanced/manager/plugin/downloader/Scope {
public final class app/revanced/manager/downloader/DownloaderKt {
public static final fun Downloader (ILkotlin/jvm/functions/Function1;)Lapp/revanced/manager/downloader/DownloaderBuilder;
}
public final class app/revanced/manager/downloader/DownloaderScope : app/revanced/manager/downloader/Scope {
public static final field $stable I
public final fun download (Lkotlin/jvm/functions/Function3;)V
public final fun get (Lkotlin/jvm/functions/Function4;)V
public fun getDownloaderPackageName ()Ljava/lang/String;
public fun getHostPackageName ()Ljava/lang/String;
public fun getPluginPackageName ()Ljava/lang/String;
public final fun useService (Landroid/content/Intent;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class app/revanced/manager/plugin/downloader/ExtensionsKt {
public static final fun download (Lapp/revanced/manager/plugin/downloader/DownloaderScope;Lkotlin/jvm/functions/Function4;)V
public final class app/revanced/manager/downloader/ExtensionsKt {
public static final fun download (Lapp/revanced/manager/downloader/DownloaderScope;Lkotlin/jvm/functions/Function4;)V
}
public abstract interface class app/revanced/manager/plugin/downloader/GetScope : app/revanced/manager/plugin/downloader/Scope {
public abstract interface class app/revanced/manager/downloader/GetScope : app/revanced/manager/downloader/Scope {
public abstract fun requestStartActivity (Landroid/content/Intent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public abstract interface class app/revanced/manager/plugin/downloader/InputDownloadScope : app/revanced/manager/plugin/downloader/BaseDownloadScope {
public abstract interface class app/revanced/manager/downloader/InputDownloadScope : app/revanced/manager/downloader/BaseDownloadScope {
}
public abstract interface class app/revanced/manager/plugin/downloader/OutputDownloadScope : app/revanced/manager/plugin/downloader/BaseDownloadScope {
public abstract interface class app/revanced/manager/downloader/OutputDownloadScope : app/revanced/manager/downloader/BaseDownloadScope {
public abstract fun reportSize (JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class app/revanced/manager/plugin/downloader/Package : android/os/Parcelable {
public final class app/revanced/manager/downloader/Package : android/os/Parcelable {
public static final field $stable I
public static final field CREATOR Landroid/os/Parcelable$Creator;
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lapp/revanced/manager/plugin/downloader/Package;
public static synthetic fun copy$default (Lapp/revanced/manager/plugin/downloader/Package;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/revanced/manager/plugin/downloader/Package;
public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lapp/revanced/manager/downloader/Package;
public static synthetic fun copy$default (Lapp/revanced/manager/downloader/Package;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/revanced/manager/downloader/Package;
public final fun describeContents ()I
public fun equals (Ljava/lang/Object;)Z
public final fun getName ()Ljava/lang/String;
@@ -85,98 +80,79 @@ public final class app/revanced/manager/plugin/downloader/Package : android/os/P
public final fun writeToParcel (Landroid/os/Parcel;I)V
}
public final class app/revanced/manager/plugin/downloader/Package$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/Package;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/Package;
public synthetic fun newArray (I)[Ljava/lang/Object;
}
public abstract interface annotation class app/revanced/manager/plugin/downloader/PluginHostApi : java/lang/annotation/Annotation {
}
public abstract interface class app/revanced/manager/plugin/downloader/Scope {
public abstract interface class app/revanced/manager/downloader/Scope {
public abstract fun getDownloaderPackageName ()Ljava/lang/String;
public abstract fun getHostPackageName ()Ljava/lang/String;
public abstract fun getPluginPackageName ()Ljava/lang/String;
}
public abstract class app/revanced/manager/plugin/downloader/UserInteractionException : java/lang/Exception {
public abstract class app/revanced/manager/downloader/UserInteractionException : java/lang/Exception {
public static final field $stable I
public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
}
public abstract class app/revanced/manager/plugin/downloader/UserInteractionException$Activity : app/revanced/manager/plugin/downloader/UserInteractionException {
public abstract class app/revanced/manager/downloader/UserInteractionException$Activity : app/revanced/manager/downloader/UserInteractionException {
public static final field $stable I
public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
}
public final class app/revanced/manager/plugin/downloader/UserInteractionException$Activity$Cancelled : app/revanced/manager/plugin/downloader/UserInteractionException$Activity {
public final class app/revanced/manager/downloader/UserInteractionException$Activity$Cancelled : app/revanced/manager/downloader/UserInteractionException$Activity {
public static final field $stable I
}
public final class app/revanced/manager/plugin/downloader/UserInteractionException$Activity$NotCompleted : app/revanced/manager/plugin/downloader/UserInteractionException$Activity {
public final class app/revanced/manager/downloader/UserInteractionException$Activity$NotCompleted : app/revanced/manager/downloader/UserInteractionException$Activity {
public static final field $stable I
public final fun getIntent ()Landroid/content/Intent;
public final fun getResultCode ()I
}
public final class app/revanced/manager/plugin/downloader/UserInteractionException$RequestDenied : app/revanced/manager/plugin/downloader/UserInteractionException {
public final class app/revanced/manager/downloader/UserInteractionException$RequestDenied : app/revanced/manager/downloader/UserInteractionException {
public static final field $stable I
}
public final class app/revanced/manager/plugin/downloader/webview/APIKt {
public static final fun WebViewDownloader (Lkotlin/jvm/functions/Function4;)Lapp/revanced/manager/plugin/downloader/DownloaderBuilder;
public static final fun runWebView (Lapp/revanced/manager/plugin/downloader/GetScope;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final class app/revanced/manager/downloader/webview/APIKt {
public static final fun WebViewDownloader (ILkotlin/jvm/functions/Function4;)Lapp/revanced/manager/downloader/DownloaderBuilder;
public static final fun runWebView (Lapp/revanced/manager/downloader/GetScope;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public class app/revanced/manager/plugin/downloader/webview/IWebView$Default : app/revanced/manager/plugin/downloader/webview/IWebView {
public class app/revanced/manager/downloader/webview/IWebView$Default : app/revanced/manager/downloader/webview/IWebView {
public fun <init> ()V
public fun asBinder ()Landroid/os/IBinder;
public fun finish ()V
public fun load (Ljava/lang/String;)V
}
public abstract class app/revanced/manager/plugin/downloader/webview/IWebView$Stub : android/os/Binder, app/revanced/manager/plugin/downloader/webview/IWebView {
public abstract class app/revanced/manager/downloader/webview/IWebView$Stub : android/os/Binder, app/revanced/manager/downloader/webview/IWebView {
public fun <init> ()V
public fun asBinder ()Landroid/os/IBinder;
public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/plugin/downloader/webview/IWebView;
public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/downloader/webview/IWebView;
public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z
}
public class app/revanced/manager/plugin/downloader/webview/IWebViewEvents$Default : app/revanced/manager/plugin/downloader/webview/IWebViewEvents {
public class app/revanced/manager/downloader/webview/IWebViewEvents$Default : app/revanced/manager/downloader/webview/IWebViewEvents {
public fun <init> ()V
public fun asBinder ()Landroid/os/IBinder;
public fun download (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
public fun pageLoad (Ljava/lang/String;)V
public fun ready (Lapp/revanced/manager/plugin/downloader/webview/IWebView;)V
public fun ready (Lapp/revanced/manager/downloader/webview/IWebView;)V
}
public abstract class app/revanced/manager/plugin/downloader/webview/IWebViewEvents$Stub : android/os/Binder, app/revanced/manager/plugin/downloader/webview/IWebViewEvents {
public abstract class app/revanced/manager/downloader/webview/IWebViewEvents$Stub : android/os/Binder, app/revanced/manager/downloader/webview/IWebViewEvents {
public fun <init> ()V
public fun asBinder ()Landroid/os/IBinder;
public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/plugin/downloader/webview/IWebViewEvents;
public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/downloader/webview/IWebViewEvents;
public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z
}
public final class app/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters;
public synthetic fun newArray (I)[Ljava/lang/Object;
}
public abstract interface class app/revanced/manager/plugin/downloader/webview/WebViewCallbackScope : app/revanced/manager/plugin/downloader/Scope {
public abstract interface class app/revanced/manager/downloader/webview/WebViewCallbackScope : app/revanced/manager/downloader/Scope {
public abstract fun finish (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun load (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class app/revanced/manager/plugin/downloader/webview/WebViewScope : app/revanced/manager/plugin/downloader/Scope {
public final class app/revanced/manager/downloader/webview/WebViewScope : app/revanced/manager/downloader/Scope {
public static final field $stable I
public final fun download (Lkotlin/jvm/functions/Function5;)V
public fun getDownloaderPackageName ()Ljava/lang/String;
public fun getHostPackageName ()Ljava/lang/String;
public fun getPluginPackageName ()Ljava/lang/String;
public final fun pageLoad (Lkotlin/jvm/functions/Function3;)V
}

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
@@ -17,9 +19,20 @@ dependencies {
implementation(libs.appcompat)
}
kotlin {
jvmToolchain(17)
compilerOptions {
jvmTarget = JvmTarget.JVM_17
freeCompilerArgs.addAll(
"-Xexplicit-backing-fields",
"-Xcontext-parameters",
)
}
}
android {
namespace = "app.revanced.manager.plugin.downloader"
compileSdk = 35
namespace = "app.revanced.manager.downloader"
compileSdk = 36
defaultConfig {
minSdk = 26
@@ -42,17 +55,13 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
aidl = true
}
}
apiValidation {
nonPublicMarkers += "app.revanced.manager.plugin.downloader.PluginHostApi"
nonPublicMarkers += "app.revanced.manager.downloader.DownloaderHostApi"
}
publishing {

1
api/gradle.properties Normal file
View File

@@ -0,0 +1 @@
version = 1.0.0-dev.2

View File

@@ -0,0 +1,8 @@
// IWebView.aidl
package app.revanced.manager.downloader.webview;
@JavaPassthrough(annotation="@app.revanced.manager.downloader.DownloaderHostApi")
oneway interface IWebView {
void load(String url);
void finish();
}

View File

@@ -0,0 +1,11 @@
// IWebViewEvents.aidl
package app.revanced.manager.downloader.webview;
import app.revanced.manager.downloader.webview.IWebView;
@JavaPassthrough(annotation="@app.revanced.manager.downloader.DownloaderHostApi")
oneway interface IWebViewEvents {
void ready(IWebView iface);
void pageLoad(String url);
void download(String url, String mimetype, String userAgent);
}

View File

@@ -1,8 +0,0 @@
// IWebView.aidl
package app.revanced.manager.plugin.downloader.webview;
@JavaPassthrough(annotation="@app.revanced.manager.plugin.downloader.PluginHostApi")
oneway interface IWebView {
void load(String url);
void finish();
}

View File

@@ -1,11 +0,0 @@
// IWebViewEvents.aidl
package app.revanced.manager.plugin.downloader.webview;
import app.revanced.manager.plugin.downloader.webview.IWebView;
@JavaPassthrough(annotation="@app.revanced.manager.plugin.downloader.PluginHostApi")
oneway interface IWebViewEvents {
void ready(IWebView iface);
void pageLoad(String url);
void download(String url, String mimetype, String userAgent);
}

View File

@@ -0,0 +1,7 @@
package app.revanced.manager.downloader
/**
* The permission ID of the special downloader host permission. Only ReVanced Manager will have this permission.
* Downloader UI activities and internal services can be protected using this permission.
*/
const val DOWNLOADER_HOST_PERMISSION = "app.revanced.manager.permission.DOWNLOADER_HOST"

View File

@@ -1,4 +1,4 @@
package app.revanced.manager.plugin.downloader
package app.revanced.manager.downloader
import android.content.ComponentName
import android.content.Context
@@ -7,6 +7,7 @@ import android.content.ServiceConnection
import android.os.IBinder
import android.app.Activity
import android.os.Parcelable
import androidx.annotation.StringRes
import kotlinx.coroutines.withTimeout
import java.io.InputStream
import java.io.OutputStream
@@ -15,10 +16,10 @@ import kotlin.coroutines.suspendCoroutine
@RequiresOptIn(
level = RequiresOptIn.Level.ERROR,
message = "This API is only intended for plugin hosts, don't use it in a plugin.",
message = "This API is only intended for downloader hosts, don't use it in a downloader.",
)
@Retention(AnnotationRetention.BINARY)
annotation class PluginHostApi
annotation class DownloaderHostApi
/**
* The base interface for all DSL scopes.
@@ -30,9 +31,9 @@ interface Scope {
val hostPackageName: String
/**
* The package name of the plugin.
* The package name of the downloader.
*/
val pluginPackageName: String
val downloaderPackageName: String
}
/**
@@ -43,7 +44,7 @@ interface GetScope : Scope {
* Ask the user to perform some required interaction in the activity specified by the provided [Intent].
* This function returns normally with the resulting [Intent] when the activity finishes with code [Activity.RESULT_OK].
*
* @throws UserInteractionException.RequestDenied User decided to skip this plugin.
* @throws UserInteractionException.RequestDenied User decided to skip this downloader.
* @throws UserInteractionException.Activity.Cancelled The activity was cancelled.
* @throws UserInteractionException.Activity.NotCompleted The activity finished with an unknown result code.
*/
@@ -67,14 +68,14 @@ class DownloaderScope<T : Parcelable> internal constructor(
private val scopeImpl: Scope,
internal val context: Context
) : Scope by scopeImpl {
// Returning an InputStream is the primary way for plugins to implement the download function, but we also want to offer an OutputStream API since using InputStream might not be convenient in all cases.
// It is much easier to implement the main InputStream API on top of OutputStreams compared to doing it the other way around, which is why we are using OutputStream here. This detail is not visible to plugins.
// Returning an InputStream is the primary way for a downloader to implement the download function, but we also want to offer an OutputStream API since using InputStream might not be convenient in all cases.
// It is much easier to implement the main InputStream API on top of OutputStreams compared to doing it the other way around, which is why we are using OutputStream here.
internal var download: (suspend OutputDownloadScope.(T, OutputStream) -> Unit)? = null
internal var get: (suspend GetScope.(String, String?) -> GetResult<T>?)? = null
private val inputDownloadScopeImpl = object : InputDownloadScope, Scope by scopeImpl {}
/**
* Define the download block of the plugin.
* Define the download block of the downloader.
*/
fun download(block: suspend InputDownloadScope.(data: T) -> DownloadResult) {
download = { app, outputStream ->
@@ -88,7 +89,7 @@ class DownloaderScope<T : Parcelable> internal constructor(
}
/**
* Define the get block of the plugin.
* Define the get block of the downloader.
* The block should return null if the app cannot be found. The version in the result must match the version argument unless it is null.
*/
fun get(block: suspend GetScope.(packageName: String, version: String?) -> GetResult<T>?) {
@@ -122,44 +123,53 @@ class DownloaderScope<T : Parcelable> internal constructor(
}
}
class DownloaderBuilder<T : Parcelable> internal constructor(private val block: DownloaderScope<T>.() -> Unit) {
@PluginHostApi
class DownloaderBuilder<T : Parcelable> internal constructor(
@param:StringRes private val name: Int,
private val block: DownloaderScope<T>.() -> Unit
) {
@DownloaderHostApi
fun build(scopeImpl: Scope, context: Context) =
with(DownloaderScope<T>(scopeImpl, context)) {
block()
Downloader(
download = download!!,
get = get!!
get = get!!,
name = name,
)
}
}
class Downloader<T : Parcelable> internal constructor(
@property:PluginHostApi val get: suspend GetScope.(packageName: String, version: String?) -> GetResult<T>?,
@property:PluginHostApi val download: suspend OutputDownloadScope.(data: T, outputStream: OutputStream) -> Unit
@property:DownloaderHostApi val get: suspend GetScope.(packageName: String, version: String?) -> GetResult<T>?,
@property:DownloaderHostApi val download: suspend OutputDownloadScope.(data: T, outputStream: OutputStream) -> Unit,
@property:DownloaderHostApi @param:StringRes val name: Int,
)
/**
* Define a downloader plugin.
* Define a downloader.
*/
fun <T : Parcelable> Downloader(block: DownloaderScope<T>.() -> Unit) = DownloaderBuilder(block)
fun <T : Parcelable> Downloader(@StringRes name: Int, block: DownloaderScope<T>.() -> Unit) =
DownloaderBuilder(name, block)
/**
* @see GetScope.requestStartActivity
*/
sealed class UserInteractionException(message: String) : Exception(message) {
class RequestDenied @PluginHostApi constructor() :
class RequestDenied @DownloaderHostApi constructor() :
UserInteractionException("Request denied by user")
sealed class Activity(message: String) : UserInteractionException(message) {
class Cancelled @PluginHostApi constructor() : Activity("Interaction cancelled")
class Cancelled @DownloaderHostApi constructor() : Activity("Interaction cancelled")
/**
* @param resultCode The result code of the activity.
* @param intent The [Intent] of the activity.
*/
class NotCompleted @PluginHostApi constructor(val resultCode: Int, val intent: Intent?) :
class NotCompleted @DownloaderHostApi constructor(
val resultCode: Int,
val intent: Intent?
) :
Activity("Unexpected activity result code: $resultCode")
}
}

View File

@@ -1,4 +1,4 @@
package app.revanced.manager.plugin.downloader
package app.revanced.manager.downloader
import android.app.Activity
import android.app.Service
@@ -28,7 +28,7 @@ fun <T : Parcelable> DownloaderScope<T>.download(block: suspend OutputDownloadSc
*/
suspend inline fun <reified ACTIVITY : Activity> GetScope.requestStartActivity() =
requestStartActivity(
Intent().apply { setClassName(pluginPackageName, ACTIVITY::class.qualifiedName!!) }
Intent().apply { setClassName(downloaderPackageName, ACTIVITY::class.qualifiedName!!) }
)
/**
@@ -38,5 +38,5 @@ suspend inline fun <reified ACTIVITY : Activity> GetScope.requestStartActivity()
suspend inline fun <reified SERVICE : Service, R : Any?> DownloaderScope<*>.useService(
noinline block: suspend (IBinder) -> R
) = useService(
Intent().apply { setClassName(pluginPackageName, SERVICE::class.qualifiedName!!) }, block
Intent().apply { setClassName(downloaderPackageName, SERVICE::class.qualifiedName!!) }, block
)

View File

@@ -1,4 +1,4 @@
package app.revanced.manager.plugin.downloader
package app.revanced.manager.downloader
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@@ -7,7 +7,7 @@ import java.net.URI
/**
* A simple parcelable data class for storing a package name and version.
* This can be used as the data type for plugins that only need a name and version to implement their [DownloaderScope.download] function.
* This can be used as the data type for downloaders that only need a name and version to implement their [DownloaderScope.download] function.
*
* @param name The package name.
* @param version The version.

View File

@@ -1,12 +1,13 @@
package app.revanced.manager.plugin.downloader.webview
package app.revanced.manager.downloader.webview
import android.content.Intent
import app.revanced.manager.plugin.downloader.DownloadUrl
import app.revanced.manager.plugin.downloader.DownloaderScope
import app.revanced.manager.plugin.downloader.GetScope
import app.revanced.manager.plugin.downloader.Scope
import app.revanced.manager.plugin.downloader.Downloader
import app.revanced.manager.plugin.downloader.PluginHostApi
import androidx.annotation.StringRes
import app.revanced.manager.downloader.DownloadUrl
import app.revanced.manager.downloader.DownloaderScope
import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.Scope
import app.revanced.manager.downloader.Downloader
import app.revanced.manager.downloader.DownloaderHostApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -32,7 +33,7 @@ interface WebViewCallbackScope<T> : Scope {
suspend fun load(url: String)
}
@OptIn(PluginHostApi::class)
@OptIn(DownloaderHostApi::class)
class WebViewScope<T> internal constructor(
coroutineScope: CoroutineScope,
private val scopeImpl: Scope,
@@ -110,7 +111,7 @@ private value class Container<U>(val value: U)
* @param title The string displayed in the action bar.
* @param block The control block.
*/
@OptIn(PluginHostApi::class)
@OptIn(DownloaderHostApi::class)
suspend fun <T> GetScope.runWebView(
title: String,
block: suspend WebViewScope<T>.() -> InitialUrl
@@ -143,11 +144,9 @@ suspend fun <T> GetScope.runWebView(
*
* @see runWebView
*/
fun WebViewDownloader(block: suspend WebViewScope<DownloadUrl>.(packageName: String, version: String?) -> InitialUrl?) =
Downloader<DownloadUrl> {
val label = context.applicationInfo.loadLabel(
context.packageManager
).toString()
fun WebViewDownloader(@StringRes name: Int, block: suspend WebViewScope<DownloadUrl>.(packageName: String, version: String?) -> InitialUrl?) =
Downloader(name) {
val label = context.getString(name)
get { packageName, version ->
class ReturnNull : Exception()

View File

@@ -1,4 +1,4 @@
package app.revanced.manager.plugin.downloader.webview
package app.revanced.manager.downloader.webview
import android.annotation.SuppressLint
import android.os.Bundle
@@ -20,15 +20,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.R
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.downloader.R
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@OptIn(PluginHostApi::class)
@PluginHostApi
@OptIn(DownloaderHostApi::class)
@DownloaderHostApi
class WebViewActivity : ComponentActivity() {
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
@@ -110,7 +110,7 @@ class WebViewActivity : ComponentActivity() {
}
}
@OptIn(PluginHostApi::class)
@OptIn(DownloaderHostApi::class)
internal class WebViewModel : ViewModel() {
init {
CookieManager.getInstance().apply {

View File

@@ -1,7 +0,0 @@
package app.revanced.manager.plugin.downloader
/**
* The permission ID of the special plugin host permission. Only ReVanced Manager will have this permission.
* Plugin UI activities and internal services can be protected using this permission.
*/
const val PLUGIN_HOST_PERMISSION = "app.revanced.manager.permission.PLUGIN_HOST"

View File

@@ -1,3 +1,113 @@
# app [1.26.0-dev.23](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.22...v1.26.0-dev.23) (2026-02-19)
### Bug Fixes
* Flutter migration ([#2946](https://github.com/ReVanced/revanced-manager/issues/2946)) ([31a0f75](https://github.com/ReVanced/revanced-manager/commit/31a0f751aaff3233f50821963f28454d239318b2))
# app [1.26.0-dev.22](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.21...v1.26.0-dev.22) (2026-02-19)
### Bug Fixes
* Create modules path in root installer ([4fc2ba3](https://github.com/ReVanced/revanced-manager/commit/4fc2ba339b6d275fb9da8b65ff86b74a6594436d))
# app [1.26.0-dev.21](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.20...v1.26.0-dev.21) (2026-02-19)
### Features
* Allow multiple downloaders per APK ([#2916](https://github.com/ReVanced/revanced-manager/issues/2916)) ([0f56377](https://github.com/ReVanced/revanced-manager/commit/0f56377214fae05693a21177daf7534c103df74e))
# app [1.26.0-dev.20](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.19...v1.26.0-dev.20) (2026-01-09)
### Bug Fixes
* Save FAB freaking out in select patches screen ([4c0b6b0](https://github.com/ReVanced/revanced-manager/commit/4c0b6b02e95a8d6f655bcf5c25493b1f9a4a4dcd))
# app [1.26.0-dev.19](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.18...v1.26.0-dev.19) (2026-01-08)
### Bug Fixes
* **locales:** use buildconfig instead of generating kt file ([72b1db9](https://github.com/ReVanced/revanced-manager/commit/72b1db9a2f33ab5d5fffd8ba83c05901eff19bea))
# app [1.26.0-dev.18](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.17...v1.26.0-dev.18) (2026-01-08)
### Bug Fixes
* Prevent trailing comma when no locales are generated ([b16931c](https://github.com/ReVanced/revanced-manager/commit/b16931ca79d5ce4d17c75f6dd3bf6f976b8ff7be))
### Features
* Add language settings ([#2913](https://github.com/ReVanced/revanced-manager/issues/2913)) ([df31b39](https://github.com/ReVanced/revanced-manager/commit/df31b39cc8c1fbf00bc3301468e8e7e4b283caf2))
# app [1.26.0-dev.17](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.16...v1.26.0-dev.17) (2026-01-06)
### Bug Fixes
* allow updating patches on metered networks ([9d9a0e8](https://github.com/ReVanced/revanced-manager/commit/9d9a0e81dbc9e73e6e3181f6bea9cabb69e49ea8))
# app [1.26.0-dev.16](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.15...v1.26.0-dev.16) (2025-12-30)
### Features
* Show patches as individual steps in patcher screen ([#2889](https://github.com/ReVanced/revanced-manager/issues/2889)) ([11dd6e4](https://github.com/ReVanced/revanced-manager/commit/11dd6e4064099427a8c9bc6f225a19412e5c70e2))
# app [1.26.0-dev.15](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.14...v1.26.0-dev.15) (2025-12-29)
### Bug Fixes
* install dialog getting stuck ([#2900](https://github.com/ReVanced/revanced-manager/issues/2900)) ([18a4df9](https://github.com/ReVanced/revanced-manager/commit/18a4df9af9cac120fdb8e4ff7aadd2e2a8d5c1a6))
# app [1.26.0-dev.14](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.13...v1.26.0-dev.14) (2025-12-28)
### Bug Fixes
* Update selected patch count when SelectionState changes ([#2896](https://github.com/ReVanced/revanced-manager/issues/2896)) ([0d26df0](https://github.com/ReVanced/revanced-manager/commit/0d26df03f463195dae550240c7f652680763079c))
# app [1.26.0-dev.13](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.12...v1.26.0-dev.13) (2025-12-17)
### Features
* Make patcher screen design more consistent with inspiration ([#2805](https://github.com/ReVanced/revanced-manager/issues/2805)) ([dbb6c01](https://github.com/ReVanced/revanced-manager/commit/dbb6c01e89a5e710185ff4304de0ac9e19bed053))
# app [1.26.0-dev.12](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.11...v1.26.0-dev.12) (2025-12-17)
### Features
* Improve trust plugin dialog design ([#2420](https://github.com/ReVanced/revanced-manager/issues/2420)) ([0300da9](https://github.com/ReVanced/revanced-manager/commit/0300da9eac6c0fc29dbbb66622c0d52f4cf68934))
# app [1.26.0-dev.11](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.10...v1.26.0-dev.11) (2025-10-25)
### Features
* Add pure black theme ([#2824](https://github.com/ReVanced/revanced-manager/issues/2824)) ([3d75ffe](https://github.com/ReVanced/revanced-manager/commit/3d75ffe6a7a39efdebe13dbd07c937c1de409ead))
# app [1.26.0-dev.10](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.9...v1.26.0-dev.10) (2025-10-06)
### Bug Fixes
* prevent back presses during installation ([2ff7072](https://github.com/ReVanced/revanced-manager/commit/2ff70728b490b92f212a82dcf599bc0c23f589e7))
# app [1.26.0-dev.9](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.8...v1.26.0-dev.9) (2025-10-06)
### Bug Fixes
* Instantly re-fetch patch bundle on pre-release preference update ([d5671db](https://github.com/ReVanced/revanced-manager/commit/d5671db3a77541c07bbbb4c3baca02f3ba0703f2)), closes [#2784](https://github.com/ReVanced/revanced-manager/issues/2784)
# app [1.26.0-dev.8](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.7...v1.26.0-dev.8) (2025-10-06)

View File

@@ -1,4 +1,7 @@
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
import io.github.z4kn4fein.semver.toVersion
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import kotlin.random.Random
plugins {
@@ -9,6 +12,7 @@ plugins {
alias(libs.plugins.compose.compiler)
alias(libs.plugins.devtools)
alias(libs.plugins.about.libraries)
alias(libs.plugins.about.libraries.android)
signing
}
@@ -81,7 +85,8 @@ dependencies {
implementation(libs.koin.workmanager)
// Licenses
implementation(libs.about.libraries)
implementation(libs.about.libraries.core)
implementation(libs.about.libraries.m3)
// Ktor
implementation(libs.ktor.core)
@@ -108,6 +113,10 @@ dependencies {
// Compose Icons
implementation(libs.compose.icons.fontawesome)
// Ackpine
implementation(libs.ackpine.core)
implementation(libs.ackpine.ktx)
}
buildscript {
@@ -122,30 +131,42 @@ buildscript {
android {
namespace = "app.revanced.manager"
compileSdk = 35
compileSdk = 36
buildToolsVersion = "35.0.1"
defaultConfig {
applicationId = "app.revanced.manager"
applicationId = "app.revanced.manager.flutter"
minSdk = 26
targetSdk = 35
val versionStr = if (version == "unspecified") "1.0.0" else version.toString()
versionName = versionStr
versionCode = with(versionStr.toVersion()) {
major * 10_000_000 +
minor * 10_000 +
major * 100_000_000 +
minor * 100_000 +
patch * 100 +
(preRelease?.substringAfterLast('.')?.toInt() ?: 99)
}
vectorDrawables.useSupportLibrary = true
val resDir = file("src/main/res")
val locales = resDir.listFiles()
.orEmpty()
//noinspection WrongGradleMethod
.filter { it.isDirectory && it.name.matches(Regex("values-[a-z]{2}(-r[A-Z]{2})?")) }
//noinspection WrongGradleMethod
.map { it.name.removePrefix("values-").replace("-r", "-") }
.sorted()
//noinspection WrongGradleMethod
.joinToString(prefix = "{", separator = ",", postfix = "}") { "\"$it\"" }
buildConfigField("String[]", "SUPPORTED_LOCALES", locales)
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
resValue("string", "app_name", "ReVanced Manager (Debug)")
isPseudoLocalesEnabled = true
buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L")
}
@@ -196,41 +217,18 @@ android {
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
}
androidResources {
generateLocaleConfig = true
}
externalNativeBuild {
@@ -239,10 +237,76 @@ android {
version = "3.22.1"
}
}
packaging {
resources {
// Useless files
excludes += "/XPP3_*_VERSION"
excludes += "/font-awesome-license.txt"
excludes += "/smali.properties"
excludes += "/baksmali.properties"
excludes += "/properties/apktool.properties"
excludes += "/org/antlr/**"
excludes += "/org/mockito/**"
excludes += "/org/bouncycastle/pqc/**.properties"
excludes += "/org/bouncycastle/x509/**.properties"
excludes += "/META-INF/INDEX.LIST"
excludes += "/META-INF/**/*.txt"
excludes += "/META-INF/**/*.properties"
excludes += "/META-INF/DEPENDENCIES"
// Desktop AAPT binaries
excludes += "/prebuilt/**"
}
jniLibs {
// 32-bit x86 is dead
excludes += "/lib/x86/*.so"
// Equivalent of AndroidManifest's extractNativeLibs=true to ensure libs are compressed
useLegacyPackaging = true
}
}
}
androidComponents {
onVariants(selector().withBuildType("release")) {
it.packaging.resources.excludes.apply {
// Debug metadata
add("/META-INF/*.version")
add("/META-INF/*.kotlin_module")
add("/kotlin-tooling-metadata.json")
// Kotlin debugging (https://github.com/Kotlin/kotlinx.coroutines/issues/2274)
add("/DebugProbesKt.bin")
}
}
}
kotlin {
jvmToolchain(17)
compilerOptions {
jvmTarget = JvmTarget.JVM_17
freeCompilerArgs.addAll(
"-Xexplicit-backing-fields",
"-Xcontext-parameters",
)
}
}
configurations {
all {
// ReVanced Library has a dependency which conflicts with whatever this is. We don't use protobuf, so it should be fine.
exclude(group = "com.google.api.grpc", module = "proto-google-common-protos")
}
}
aboutLibraries {
library {
// Enable the duplication mode, allows to merge, or link dependencies which relate
duplicationMode = DuplicateMode.MERGE
// Configure the duplication rule, to match "duplicates" with
duplicationRule = DuplicateRule.EXACT
}
}
tasks {

View File

@@ -1 +1 @@
version = 1.26.0-dev.8
version = 1.26.0-dev.23

View File

@@ -1,7 +1,7 @@
-dontobfuscate
-keep class app.revanced.manager.patcher.runtime.process.* { *; }
-keep class app.revanced.manager.plugin.** { *; }
-keep class app.revanced.manager.downloader.** { *; }
-keep class app.revanced.patcher.** { *; }
-keep class com.android.tools.smali.** { *; }
-keep class kotlin.** { *; }
@@ -11,4 +11,7 @@
-dontwarn com.google.j2objc.annotations.*
-dontwarn java.awt.**
-dontwarn javax.**
-dontwarn org.slf4j.**
-dontwarn org.slf4j.**
-dontwarn org.immutables.value.Generated
-dontwarn reactor.blockhound.integration.BlockHoundIntegration

View File

@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "d0119047505da435972c5247181de675",
"identityHash": "c0060d66d8afa086f2632c4eb145ffc4",
"entities": [
{
"tableName": "patch_bundles",
@@ -21,10 +21,9 @@
"notNull": true
},
{
"fieldPath": "version",
"fieldPath": "versionHash",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
"affinity": "TEXT"
},
{
"fieldPath": "source",
@@ -44,9 +43,7 @@
"columnNames": [
"uid"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "patch_selections",
@@ -127,7 +124,6 @@
"patch_name"
]
},
"indices": [],
"foreignKeys": [
{
"table": "patch_selections",
@@ -177,9 +173,7 @@
"package_name",
"version"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "installed_app",
@@ -215,9 +209,7 @@
"columnNames": [
"current_package_name"
]
},
"indices": [],
"foreignKeys": []
}
},
{
"tableName": "applied_patch",
@@ -378,7 +370,6 @@
"key"
]
},
"indices": [],
"foreignKeys": [
{
"table": "option_groups",
@@ -394,7 +385,7 @@
]
},
{
"tableName": "trusted_downloader_plugins",
"tableName": "trusted_downloaders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `signature` BLOB NOT NULL, PRIMARY KEY(`package_name`))",
"fields": [
{
@@ -415,15 +406,12 @@
"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')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c0060d66d8afa086f2632c4eb145ffc4')"
]
}
}

View File

@@ -3,13 +3,13 @@
xmlns:tools="http://schemas.android.com/tools">
<permission
android:name="app.revanced.manager.permission.PLUGIN_HOST"
android:name="app.revanced.manager.permission.DOWNLOADER_HOST"
android:protectionLevel="signature"
android:label="@string/plugin_host_permission_label"
android:description="@string/plugin_host_permission_description"
android:label="@string/downloader_host_permission_label"
android:description="@string/downloader_host_permission_description"
/>
<uses-permission android:name="app.revanced.manager.permission.PLUGIN_HOST" />
<uses-permission android:name="app.revanced.manager.permission.DOWNLOADER_HOST" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -49,10 +49,7 @@
</intent-filter>
</activity>
<activity android:name=".plugin.downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
<service android:name=".service.InstallService" />
<service android:name=".service.UninstallService" />
<activity android:name=".downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
@@ -75,5 +72,15 @@
android:value="androidx.startup"
tools:node="remove" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="ru.solrudev.ackpine.AckpineInitializer"
tools:node="remove" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,4 @@
// ProgressEventParcel.aidl
package app.revanced.manager.patcher;
parcelable ProgressEventParcel;

View File

@@ -1,11 +1,12 @@
// IPatcherEvents.aidl
package app.revanced.manager.patcher.runtime.process;
import app.revanced.manager.patcher.ProgressEventParcel;
// 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);
void event(in ProgressEventParcel event);
// The patching process has ended. The exceptionStackTrace is null if it finished successfully.
void finished(String exceptionStackTrace);
}

View File

@@ -1,13 +1,10 @@
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.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
@@ -25,6 +22,8 @@ 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.Announcement
import app.revanced.manager.ui.model.navigation.Announcements
import app.revanced.manager.ui.model.navigation.AppSelector
import app.revanced.manager.ui.model.navigation.ComplexParameter
import app.revanced.manager.ui.model.navigation.Dashboard
@@ -33,6 +32,8 @@ import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.model.navigation.Update
import app.revanced.manager.ui.screen.AnnouncementScreen
import app.revanced.manager.ui.screen.AnnouncementsScreen
import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstalledAppInfoScreen
@@ -59,11 +60,11 @@ 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() {
class MainActivity : AppCompatActivity() {
@ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -75,23 +76,14 @@ class MainActivity : ComponentActivity() {
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) {
}
}
val pureBlackTheme by vm.prefs.pureBlackTheme.getAsState()
ReVancedManagerTheme(
darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK,
dynamicColor = dynamicColor
dynamicColor = dynamicColor,
pureBlackTheme = pureBlackTheme
) {
ReVancedManager(vm)
}
@@ -116,7 +108,7 @@ private fun ReVancedManager(vm: MainViewModel) {
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) {
composable<Dashboard> {
DashboardScreen(
@@ -127,11 +119,17 @@ private fun ReVancedManager(vm: MainViewModel) {
onUpdateClick = {
navController.navigate(Update())
},
onDownloaderPluginClick = {
onDownloaderClick = {
navController.navigate(Settings.Downloads)
},
onAppClick = { packageName ->
navController.navigate(InstalledApplicationInfo(packageName))
},
onAnnouncementsClick = {
navController.navigate(Announcements)
},
onAnnouncementClick = { announcement ->
navController.navigateComplex(Announcement, announcement)
}
)
}
@@ -177,13 +175,29 @@ private fun ReVancedManager(vm: MainViewModel) {
)
}
composable<Announcements> {
AnnouncementsScreen(
onBackClick = navController::popBackStack,
onAnnouncementClick = { announcement ->
navController.navigateComplex(Announcement, announcement)
}
)
}
composable<Announcement> {
AnnouncementScreen(
onBackClick = navController::popBackStack,
announcement = it.getComplexArg()
)
}
navigation<SelectedApplicationInfo>(startDestination = SelectedApplicationInfo.Main) {
composable<SelectedApplicationInfo.Main> {
val parentBackStackEntry = navController.navGraphEntry(it)
val data =
parentBackStackEntry.getComplexArg<SelectedApplicationInfo.ViewModelParams>()
val viewModel =
koinNavViewModel<SelectedAppInfoViewModel>(viewModelStoreOwner = parentBackStackEntry) {
koinViewModel<SelectedAppInfoViewModel>(viewModelStoreOwner = parentBackStackEntry) {
parametersOf(data)
}
@@ -224,7 +238,7 @@ private fun ReVancedManager(vm: MainViewModel) {
composable<SelectedApplicationInfo.PatchesSelector> {
val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
val selectedAppInfoVm = koinViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)
@@ -241,7 +255,7 @@ private fun ReVancedManager(vm: MainViewModel) {
composable<SelectedApplicationInfo.RequiredOptions> {
val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
val selectedAppInfoVm = koinViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
)

View File

@@ -7,7 +7,7 @@ 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.DownloaderRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
@@ -29,7 +29,7 @@ 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 downloaderRepository: DownloaderRepository by inject()
private val fs: Filesystem by inject()
override fun onCreate() {
@@ -48,7 +48,8 @@ class ManagerApplication : Application() {
workerModule,
viewModelModule,
databaseModule,
rootModule
rootModule,
ackpineModule
)
}
@@ -69,7 +70,7 @@ class ManagerApplication : Application() {
prefs.preload()
}
scope.launch(Dispatchers.Default) {
downloaderPluginRepository.reload()
downloaderRepository.reload()
}
scope.launch(Dispatchers.Default) {
with(patchBundleRepository) {

View File

@@ -15,5 +15,5 @@ class NetworkInfo(app: Application) {
/**
* Returns true if it is safe to download large files.
*/
fun isSafe() = isConnected() && isUnmetered()
fun isSafe(ignoreMetered: Boolean) = isConnected() && (ignoreMetered || isUnmetered())
}

View File

@@ -16,12 +16,12 @@ 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 app.revanced.manager.data.room.downloader.TrustedDownloader
import app.revanced.manager.data.room.downloader.TrustedDownloaderDao
import kotlin.random.Random
@Database(
entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class, TrustedDownloaderPlugin::class],
entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class, TrustedDownloader::class],
version = 1
)
@TypeConverters(Converters::class)
@@ -31,7 +31,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun downloadedAppDao(): DownloadedAppDao
abstract fun installedAppDao(): InstalledAppDao
abstract fun optionDao(): OptionDao
abstract fun trustedDownloaderPluginDao(): TrustedDownloaderPluginDao
abstract fun trustedDownloaderDao(): TrustedDownloaderDao
companion object {
fun generateUid() = Random.Default.nextInt()

View File

@@ -1,11 +1,11 @@
package app.revanced.manager.data.room.plugins
package app.revanced.manager.data.room.downloader
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "trusted_downloader_plugins")
class TrustedDownloaderPlugin(
@Entity(tableName = "trusted_downloaders")
class TrustedDownloader(
@PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "signature") val signature: ByteArray
)

View File

@@ -0,0 +1,22 @@
package app.revanced.manager.data.room.downloader
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
@Dao
interface TrustedDownloaderDao {
@Query("SELECT signature FROM trusted_downloaders WHERE package_name = :packageName")
suspend fun getTrustedSignature(packageName: String): ByteArray?
@Upsert
suspend fun upsertTrust(downloader: TrustedDownloader)
@Query("DELETE FROM trusted_downloaders WHERE package_name = :packageName")
suspend fun remove(packageName: String)
@Transaction
@Query("DELETE FROM trusted_downloaders WHERE package_name IN (:packageNames)")
suspend fun removeAll(packageNames: Set<String>)
}

View File

@@ -5,8 +5,6 @@ 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
@@ -36,7 +34,7 @@ import kotlin.reflect.typeOf
data class Option(
@ColumnInfo(name = "group") val group: Int,
@ColumnInfo(name = "patch_name") val patchName: String,
@ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "key") val name: String,
// Encoded as Json.
@ColumnInfo(name = "value") val value: SerializedValue,
) {

View File

@@ -1,22 +0,0 @@
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<String>)
}

View File

@@ -0,0 +1,19 @@
package app.revanced.manager.di
import android.content.Context
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
import ru.solrudev.ackpine.installer.PackageInstaller
import ru.solrudev.ackpine.uninstaller.PackageUninstaller
val ackpineModule = module {
fun provideInstaller(context: Context) = PackageInstaller.getInstance(context)
fun provideUninstaller(context: Context) = PackageUninstaller.getInstance(context)
single {
provideInstaller(androidContext())
}
single {
provideUninstaller(androidContext())
}
}

View File

@@ -11,6 +11,7 @@ import org.koin.dsl.module
val repositoryModule = module {
singleOf(::ReVancedAPI)
singleOf(::AnnouncementRepository)
singleOf(::Filesystem) {
createdAtStart()
}
@@ -21,7 +22,7 @@ val repositoryModule = module {
// It is best to load patch bundles ASAP
createdAtStart()
}
singleOf(::DownloaderPluginRepository)
singleOf(::DownloaderRepository)
singleOf(::WorkerRepository)
singleOf(::DownloadedAppRepository)
singleOf(::InstalledAppRepository)

View File

@@ -1,7 +1,7 @@
package app.revanced.manager.di
import app.revanced.manager.ui.viewmodel.*
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.*
import org.koin.dsl.module
val viewModelModule = module {
@@ -14,6 +14,7 @@ val viewModelModule = module {
viewModelOf(::AppSelectorViewModel)
viewModelOf(::PatcherViewModel)
viewModelOf(::UpdateViewModel)
viewModelOf(::AnnouncementsViewModel)
viewModelOf(::ChangelogsViewModel)
viewModelOf(::ImportExportViewModel)
viewModelOf(::AboutViewModel)

View File

@@ -121,7 +121,7 @@ class RootInstaller(
execute("pm install \"${stockApp.absolutePath}\"").assertSuccess("Failed to install stock app")
}
remoteFS.getFile(modulePath).mkdir()
remoteFS.getFile(modulePath).mkdirs()
listOf(
"service.sh",

View File

@@ -9,6 +9,7 @@ class PreferencesManager(
context: Context
) : BasePreferencesManager(context, "settings") {
val dynamicColor = booleanPreference("dynamic_color", true)
val pureBlackTheme = booleanPreference("pure_black_theme", false)
val theme = enumPreference("theme", Theme.SYSTEM)
val api = stringPreference("api_url", "https://api.revanced.app")
@@ -19,6 +20,8 @@ class PreferencesManager(
val keystoreAlias = stringPreference("keystore_alias", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
val readAnnouncements = longSetPreference("read_announcements", emptySet())
val selectedAnnouncementTags = stringSetPreference("selected_announcement_tags", setOf("revanced", "manager"))
val firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
@@ -30,7 +33,9 @@ class PreferencesManager(
val disableUniversalPatchCheck = booleanPreference("disable_patch_universal_check", false)
val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true)
val acknowledgedDownloaderPlugins = stringSetPreference("acknowledged_downloader_plugins", emptySet())
val acknowledgedDownloaders = stringSetPreference("acknowledged_downloaders", emptySet())
val showDeveloperSettings = booleanPreference("show_developer_settings", context.isDebuggable)
val allowMeteredNetworks = booleanPreference("allow_metered_networks", false)
}

View File

@@ -29,6 +29,9 @@ abstract class BasePreferencesManager(private val context: Context, name: String
protected fun stringSetPreference(key: String, default: Set<String>) =
StringSetPreference(dataStore, key, default)
protected fun longSetPreference(key: String, default: Set<Long>) =
LongSetPreference(dataStore, key, default)
protected fun booleanPreference(key: String, default: Boolean) =
BooleanPreference(dataStore, key, default)
@@ -56,9 +59,13 @@ class EditorContext(private val prefs: MutablePreferences) {
get() = prefs.run { read() }
set(value) = prefs.run { write(value) }
operator fun Preference<Set<String>>.plusAssign(value: String) = prefs.run {
operator fun <T> Preference<Set<T>>.plusAssign(value: T) = prefs.run {
write(read() + value)
}
operator fun <T> Preference<Set<T>>.minusAssign(value: T) = prefs.run {
write(read() subtract setOf(value))
}
}
abstract class Preference<T>(
@@ -125,6 +132,19 @@ class StringSetPreference(
override val key = stringSetPreferencesKey(key)
}
class LongSetPreference(
dataStore: DataStore<Preferences>,
key: String,
default: Set<Long>
) : Preference<Set<Long>>(dataStore, default) {
private val key = stringSetPreferencesKey(key)
override fun Preferences.read() = this[key]?.mapTo(mutableSetOf()) { it.toLong() } ?: default
override fun MutablePreferences.write(value: Set<Long>) {
this[key] = value.mapTo(mutableSetOf()) { it.toString() }
}
}
class BooleanPreference(
dataStore: DataStore<Preferences>,
key: String,

View File

@@ -0,0 +1,34 @@
package app.revanced.manager.domain.repository
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.network.dto.ReVancedAnnouncementTag
import app.revanced.manager.network.utils.getOrNull
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class AnnouncementRepository(
private val api: ReVancedAPI
) {
private val mutex = Mutex()
private var cachedAnnouncements: List<ReVancedAnnouncement>? = null
private var cachedTags: List<ReVancedAnnouncementTag>? = null
suspend fun getAnnouncements(forceRefresh: Boolean = false): List<ReVancedAnnouncement>? {
mutex.withLock {
if (cachedAnnouncements == null || forceRefresh) {
cachedAnnouncements = api.getAnnouncements().getOrNull()
}
return cachedAnnouncements
}
}
suspend fun getTags(forceRefresh: Boolean = false): List<ReVancedAnnouncementTag>? {
mutex.withLock {
if (cachedTags == null || forceRefresh) {
cachedTags = api.getAnnouncementTags().getOrNull()
}
return cachedTags
}
}
}

View File

@@ -6,8 +6,8 @@ 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.network.downloader.LoadedDownloader
import app.revanced.manager.downloader.OutputDownloadScope
import app.revanced.manager.util.PM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.channelFlow
@@ -36,7 +36,7 @@ class DownloadedAppRepository(
private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first()
suspend fun download(
plugin: LoadedDownloaderPlugin,
downloader: LoadedDownloader,
data: Parcelable,
expectedPackageName: String,
expectedVersion: String?,
@@ -55,7 +55,7 @@ class DownloadedAppRepository(
channelFlow {
val scope = object : OutputDownloadScope {
override val pluginPackageName = plugin.packageName
override val downloaderPackageName = downloader.packageName
override val hostPackageName = app.packageName
override suspend fun reportSize(size: Long) {
require(size > 0) { "Size must be greater than zero" }
@@ -87,7 +87,7 @@ class DownloadedAppRepository(
)
}
}
plugin.download(scope, data, stream)
downloader.download(scope, data, stream)
}
}
.conflate()

View File

@@ -1,168 +0,0 @@
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<String, DownloaderPluginState>())
val pluginStates = _pluginStates.asStateFlow()
val loadedPluginsFlow = pluginStates.map { states ->
states.values.filterIsInstance<DownloaderPluginState.Loaded>().map { it.plugin }
}
private val acknowledgedDownloaderPlugins = prefs.acknowledgedDownloaderPlugins
private val installedPluginPackageNames = MutableStateFlow(emptySet<String>())
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<LoadedDownloaderPlugin, Parcelable> {
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<Parcelable> }
?: throw Exception("Could not find a valid downloader implementation in class $canonicalName")
}
}

View File

@@ -0,0 +1,186 @@
package app.revanced.manager.domain.repository
import android.annotation.SuppressLint
import android.app.Application
import android.content.pm.PackageInfo
import android.os.Parcelable
import android.util.Log
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.downloader.TrustedDownloader
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.network.downloader.DownloaderPackageState
import app.revanced.manager.network.downloader.LoadedDownloader
import app.revanced.manager.network.downloader.ParceledDownloaderData
import app.revanced.manager.downloader.DownloaderBuilder
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.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(DownloaderHostApi::class)
class DownloaderRepository(
private val pm: PM,
private val prefs: PreferencesManager,
private val app: Application,
db: AppDatabase
) {
private val trustDao = db.trustedDownloaderDao()
private val _downloaderPackageStates =
MutableStateFlow(emptyMap<String, DownloaderPackageState>())
val downloaderPackageStates = _downloaderPackageStates.asStateFlow()
val loadedDownloadersFlow = downloaderPackageStates.map { states ->
states.values.filterIsInstance<DownloaderPackageState.Loaded>().flatMap { it.downloaders }
}
private val acknowledgedPackageNames = prefs.acknowledgedDownloaders
private val installedDownloaderPackageNames = MutableStateFlow(emptySet<String>())
val newDownloaderPackageNames = combine(
installedDownloaderPackageNames,
acknowledgedPackageNames.flow
) { installed, acknowledged ->
installed subtract acknowledged
}
suspend fun reload() {
val downloaderPackages =
withContext(Dispatchers.IO) {
pm.getPackagesWithFeature(DOWNLOADER_FEATURE)
.associate { it.packageName to loadPackage(it) }
}
_downloaderPackageStates.value = downloaderPackages
installedDownloaderPackageNames.value = downloaderPackages.keys
val acknowledgedDownloader = this@DownloaderRepository.acknowledgedPackageNames.get()
val uninstalledDownloader =
acknowledgedDownloader subtract installedDownloaderPackageNames.value
if (uninstalledDownloader.isNotEmpty()) {
Log.d(tag, "Uninstalled downloader: ${uninstalledDownloader.joinToString(", ")}")
this@DownloaderRepository.acknowledgedPackageNames.update(acknowledgedDownloader subtract uninstalledDownloader)
trustDao.removeAll(uninstalledDownloader)
}
}
fun unwrapParceledData(data: ParceledDownloaderData): Pair<LoadedDownloader, Parcelable> {
val state =
(_downloaderPackageStates.value[data.downloaderPackageName] as? DownloaderPackageState.Loaded)
?: throw Exception("Downloader package name ${data.downloaderPackageName} is not available")
val downloader = state.downloaders.firstOrNull { it.className == data.downloaderClassName }
?: throw Exception("No downloader with name ${data.downloaderClassName} found in ${data.downloaderPackageName}")
return downloader to data.unwrapWith(state.classLoader)
}
private suspend fun loadPackage(packageInfo: PackageInfo): DownloaderPackageState {
val packageName = packageInfo.packageName
try {
if (!verify(packageName)) return DownloaderPackageState.Untrusted
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(tag, "Got exception while verifying downloader $packageName", e)
return DownloaderPackageState.Failed(e)
}
return try {
val downloaderContext = app.createPackageContext(packageName, 0)
val classNamesResId =
@SuppressLint("DiscouragedApi") downloaderContext.resources.getIdentifier(
CLASSES_RESOURCE_NAME,
"array",
downloaderContext.packageName
)
if (classNamesResId == 0) throw Exception("Class names resource not found (array/$CLASSES_RESOURCE_NAME)")
val classNames = downloaderContext.resources.getStringArray(classNamesResId)
val classLoader =
PathClassLoader(packageInfo.applicationInfo!!.sourceDir, app.classLoader)
val scopeImpl = object : Scope {
override val hostPackageName = app.packageName
override val downloaderPackageName = downloaderContext.packageName
}
DownloaderPackageState.Loaded(
classNames.map { className ->
val downloader = classLoader
.loadClass(className)
.getDownloaderBuilder()
.build(
scopeImpl = scopeImpl,
context = downloaderContext
)
LoadedDownloader(
packageName,
className,
downloaderContext.getString(downloader.name),
packageInfo.versionName!!,
downloader.get,
downloader.download
)
},
classLoader,
with(pm) { packageInfo.label() }
)
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
Log.e(tag, "Failed to load downloader $packageName", t)
DownloaderPackageState.Failed(t)
}
}
suspend fun trustPackage(packageName: String) {
trustDao.upsertTrust(
TrustedDownloader(
packageName,
pm.getSignature(packageName).toByteArray()
)
)
reload()
prefs.edit {
acknowledgedPackageNames += packageName
}
}
suspend fun revokeTrustForPackage(packageName: String) =
trustDao.remove(packageName).also { reload() }
suspend fun acknowledgeAll() =
acknowledgedPackageNames.update(installedDownloaderPackageNames.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 DOWNLOADER_FEATURE = "app.revanced.manager.downloader"
const val CLASSES_RESOURCE_NAME = "app.revanced.manager.downloader.classes"
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<Parcelable> }
?: throw Exception("Could not find a valid downloader implementation in class $canonicalName")
}
}

View File

@@ -38,6 +38,7 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import java.io.InputStream
import kotlin.collections.joinToString
@@ -115,32 +116,18 @@ class PatchBundleRepository(
Log.d(tag, "Bundle: $it")
}
val sources = entities.associate { it.uid to it.load() }.toPersistentMap()
val sources = entities
.associateTo(mutableMapOf()) { it.uid to it.load() }
sources.forEach syncName@{ (uid, src) ->
val newName = src.patchBundle?.manifestAttributes?.name?.takeIf { it != src.name }
?: return@syncName
val hasOutOfDateNames = sources.values.any { it.isNameOutOfDate }
if (hasOutOfDateNames) dispatchAction(
"Sync names"
) { state ->
val nameChanges = state.sources.mapNotNull { (_, src) ->
if (!src.isNameOutOfDate) return@mapNotNull null
val newName = src.patchBundle?.manifestAttributes?.name?.takeIf { it != src.name }
?: return@mapNotNull null
src.uid to newName
}
val sources = state.sources.toMutableMap()
val info = state.info.toMutableMap()
nameChanges.forEach { (uid, name) ->
updateDb(uid) { it.copy(name = name) }
sources[uid] = sources[uid]!!.copy(name = name)
info[uid] = info[uid]?.copy(name = name) ?: return@forEach
}
State(sources.toPersistentMap(), info.toPersistentMap())
updateDb(uid) { it.copy(name = newName) }
sources[uid] = src.copy(name = newName)
}
val info = loadMetadata(sources).toPersistentMap()
return State(sources, info)
val info = loadMetadata(sources).toPersistentMap()
return State(sources.toPersistentMap(), info)
}
suspend fun reload() = dispatchAction("Full reload") {
@@ -157,38 +144,46 @@ class PatchBundleRepository(
return all
}
private suspend fun loadMetadata(sources: Map<Int, PatchBundleSource>): Map<Int, PatchBundleInfo.Global> {
private suspend fun loadMetadata(sources: MutableMap<Int, PatchBundleSource>): Map<Int, PatchBundleInfo.Global> {
// Map bundles -> sources
val map = sources.mapNotNull { (_, src) ->
(src.patchBundle ?: return@mapNotNull null) to src
}.toMap()
val metadata = try {
PatchBundle.Loader.metadata(map.keys)
runInterruptible(Dispatchers.Default) {
PatchBundle.Loader.metadata(map.keys)
}
} catch (e: CancellationException) {
throw e
} catch (error: Throwable) {
val uids = map.values.map { it.uid }
dispatchAction("Mark bundles as failed") { state ->
state.copy(sources = state.sources.mutate {
uids.forEach { uid ->
it[uid] = it[uid]?.copy(error = error) ?: return@forEach
}
})
sources.entries.forEach { entry ->
entry.setValue(entry.value.copy(error = error))
}
Log.e(tag, "Failed to load bundles", error)
emptyMap()
}
return metadata.entries.associate { (bundle, patches) ->
val src = map[bundle]!!
src.uid to PatchBundleInfo.Global(
src.name,
bundle.manifestAttributes?.version,
src.uid,
patches
)
val output = buildMap {
metadata.forEach { (bundle, result) ->
val src = map[bundle]!!
val error = result.exceptionOrNull()
if (error != null) {
sources[src.uid] = src.copy(error = error)
return@forEach
}
this[src.uid] = PatchBundleInfo.Global(
src.name,
bundle.manifestAttributes?.version,
src.uid,
result.getOrThrow().toList()
)
}
}
return output
}
suspend fun isVersionAllowed(packageName: String, version: String) =
@@ -286,35 +281,36 @@ class PatchBundleRepository(
State(sources.toPersistentMap(), info.toPersistentMap())
}
suspend fun createLocal(createStream: suspend () -> InputStream) = dispatchAction("Add bundle") {
with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) {
try {
createStream().use { patches -> replace(patches) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Got exception while importing bundle", e)
withContext(Dispatchers.Main) {
app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage()))
suspend fun createLocal(createStream: suspend () -> InputStream) =
dispatchAction("Add bundle") {
with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) {
try {
createStream().use { patches -> replace(patches) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Got exception while importing bundle", e)
withContext(Dispatchers.Main) {
app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage()))
}
deleteLocalFile()
}
deleteLocalFile()
}
}
doReload()
}
doReload()
}
suspend fun createRemote(url: String, autoUpdate: Boolean) =
dispatchAction("Add bundle ($url)") { state ->
val src = createEntity("", SourceInfo.from(url), autoUpdate).load() as RemotePatchBundle
update(src)
update(src, force = true)
state.copy(sources = state.sources.put(src.uid, src))
}
suspend fun reloadApiBundles() = dispatchAction("Reload API bundles") {
this@PatchBundleRepository.sources.first().filterIsInstance<APIPatchBundle>().forEach {
with(it) { deleteLocalFile() }
updateDb(it.uid) { it.copy(versionHash = null) }
this@PatchBundleRepository.sources.first().filterIsInstance<APIPatchBundle>().forEach { src ->
with(src) { deleteLocalFile() }
updateDb(src.uid) { it.copy(versionHash = null) }
}
doReload()
@@ -329,32 +325,38 @@ class PatchBundleRepository(
state.copy(sources = state.sources.put(uid, newSrc))
}
suspend fun update(vararg sources: RemotePatchBundle, showToast: Boolean = false) {
suspend fun update(
vararg sources: RemotePatchBundle,
showToast: Boolean = false,
force: Boolean = false
) {
val uids = sources.map { it.uid }.toSet()
store.dispatch(Update(showToast = showToast) { it.uid in uids })
store.dispatch(Update(showToast = showToast, force = force) { it.uid in uids })
}
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true))
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true, redownload = true))
/**
* Updates all bundles that should be automatically updated.
*/
suspend fun updateCheck() = store.dispatch(Update { it.autoUpdate })
suspend fun updateCheck() =
store.dispatch(Update(force = prefs.allowMeteredNetworks.get()) { it.autoUpdate })
private inner class Update(
private val force: Boolean = false,
private val redownload: Boolean = false,
private val showToast: Boolean = false,
private val predicate: (bundle: RemotePatchBundle) -> Boolean = { true },
) : Action<State> {
private suspend fun toast(@StringRes id: Int, vararg args: Any?) =
withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) }
override fun toString() = if (force) "Redownload remote bundles" else "Update check"
override fun toString() = if (redownload) "Redownload remote bundles" else "Update check"
override suspend fun ActionContext.execute(
current: State
) = coroutineScope {
if (!networkInfo.isSafe()) {
if (!networkInfo.isSafe(force)) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope current
}
@@ -367,7 +369,7 @@ class PatchBundleRepository(
Log.d(tag, "Updating patch bundle: ${it.name}")
val newVersion = with(it) {
if (force) downloadLatest() else update()
if (redownload) downloadLatest() else update()
} ?: return@async null
it to newVersion

View File

@@ -35,15 +35,15 @@ class PatchOptionsRepository(db: AppDatabase) {
bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf)
val option =
bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.key == dbOption.key }
bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.name == dbOption.name }
if (option != null) {
try {
deserializedPatchOptions[option.key] =
deserializedPatchOptions[option.name] =
dbOption.value.deserializeFor(option)
} catch (e: Option.SerializationException) {
Log.w(
tag,
"Option ${dbOption.patchName}:${option.key} could not be deserialized",
"Option ${dbOption.patchName}:${option.name} could not be deserialized",
e
)
}

View File

@@ -2,6 +2,8 @@ package app.revanced.manager.network.api
import app.revanced.manager.BuildConfig
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.network.dto.ReVancedAnnouncementTag
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.dto.ReVancedGitRepository
import app.revanced.manager.network.dto.ReVancedInfo
@@ -29,6 +31,10 @@ class ReVancedAPI(
private suspend inline fun <reified T> request(route: String) = request<T>(apiUrl(), route)
suspend fun getAnnouncements() = request<List<ReVancedAnnouncement>>("announcements")
suspend fun getAnnouncementTags() = request<List<ReVancedAnnouncementTag>>("announcements/tags")
suspend fun getAppUpdate() =
getLatestAppInfo().getOrThrow().takeIf { it.version.removePrefix("v") != BuildConfig.VERSION_NAME }

View File

@@ -0,0 +1,13 @@
package app.revanced.manager.network.downloader
sealed interface DownloaderPackageState {
data object Untrusted : DownloaderPackageState
data class Loaded(
val downloaders: List<LoadedDownloader>,
val classLoader: ClassLoader,
val name: String
) : DownloaderPackageState
data class Failed(val throwable: Throwable) : DownloaderPackageState
}

View File

@@ -1,9 +0,0 @@
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
}

View File

@@ -1,15 +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 app.revanced.manager.downloader.OutputDownloadScope
import app.revanced.manager.downloader.GetScope
import java.io.OutputStream
class LoadedDownloaderPlugin(
class LoadedDownloader(
val packageName: String,
val className: String,
val name: String,
val version: String,
val get: suspend GetScope.(packageName: String, version: String?) -> Pair<Parcelable, String?>?,
val download: suspend OutputDownloadScope.(data: Parcelable, outputStream: OutputStream) -> Unit,
val classLoader: ClassLoader
val download: suspend OutputDownloadScope.(data: Parcelable, outputStream: OutputStream) -> Unit
)

View File

@@ -10,20 +10,22 @@ import kotlinx.parcelize.Parcelize
* A container for [Parcelable] data returned from downloader. Instances of this class can be safely stored in a bundle without needing to set the [ClassLoader].
*/
class ParceledDownloaderData private constructor(
val pluginPackageName: String,
val downloaderPackageName: String,
val downloaderClassName: String,
private val bundle: Bundle
) : Parcelable {
constructor(plugin: LoadedDownloaderPlugin, data: Parcelable) : this(
plugin.packageName,
constructor(downloader: LoadedDownloader, data: Parcelable) : this(
downloader.packageName,
downloader.className,
createBundle(data)
)
fun unwrapWith(plugin: LoadedDownloaderPlugin): Parcelable {
bundle.classLoader = plugin.classLoader
fun unwrapWith(classLoader: ClassLoader): Parcelable {
bundle.classLoader = classLoader
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val className = bundle.getString(CLASS_NAME_KEY)!!
val clazz = plugin.classLoader.loadClass(className)
val clazz = classLoader.loadClass(className)
bundle.getParcelable(DATA_KEY, clazz)!! as Parcelable
} else @Suppress("Deprecation") bundle.getParcelable(DATA_KEY)!!

View File

@@ -0,0 +1,23 @@
package app.revanced.manager.network.dto
import android.os.Parcelable
import kotlinx.datetime.LocalDateTime
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class ReVancedAnnouncement(
val id: Long,
val author: String,
val title: String,
val content: String,
val tags: List<String>,
val attachments: List<String>,
@SerialName("created_at")
val createdAt: LocalDateTime,
@SerialName("archived_at")
val archivedAt: LocalDateTime,
val level: Int,
) : Parcelable

View File

@@ -0,0 +1,8 @@
package app.revanced.manager.network.dto
import kotlinx.serialization.Serializable
@Serializable
data class ReVancedAnnouncementTag(
val name: String
)

View File

@@ -16,8 +16,11 @@ 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 io.ktor.utils.io.exhausted
import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.io.asSink
import kotlinx.serialization.json.Json
import java.io.File
import java.io.OutputStream
@@ -69,14 +72,12 @@ class HttpService(
) {
http.prepareGet(builder).execute { httpResponse ->
if (httpResponse.status.isSuccess()) {
val channel: ByteReadChannel = httpResponse.body()
withContext(Dispatchers.IO) {
while (!channel.isClosedForRead) {
val channel: ByteReadChannel = httpResponse.body()
val sink = outputStream.asSink()
while (!channel.exhausted()) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (packet.isNotEmpty) {
val bytes = packet.readBytes()
outputStream.write(bytes)
}
packet.transferTo(sink)
}
}

View File

@@ -0,0 +1,78 @@
package app.revanced.manager.patcher
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
sealed class ProgressEvent : Parcelable {
abstract val stepId: StepId?
data class Started(override val stepId: StepId) : ProgressEvent()
data class Progress(
override val stepId: StepId,
val current: Long? = null,
val total: Long? = null,
val message: String? = null,
) : ProgressEvent()
data class Completed(
override val stepId: StepId,
) : ProgressEvent()
data class Failed(
override val stepId: StepId?,
val error: RemoteError,
) : ProgressEvent()
}
/**
* Parcelable wrapper for [ProgressEvent].
*
* Required because AIDL does not support sealed classes.
*/
@Parcelize
data class ProgressEventParcel(val event: ProgressEvent) : Parcelable
fun ProgressEventParcel.toEvent(): ProgressEvent = event
fun ProgressEvent.toParcel(): ProgressEventParcel = ProgressEventParcel(this)
@Parcelize
sealed class StepId : Parcelable {
data object DownloadAPK : StepId()
data object LoadPatches : StepId()
data object ReadAPK : StepId()
data object ExecutePatches : StepId()
data class ExecutePatch(val index: Int) : StepId()
data object WriteAPK : StepId()
data object SignAPK : StepId()
}
@Parcelize
data class RemoteError(
val type: String,
val message: String?,
val stackTrace: String,
) : Parcelable
fun Exception.toRemoteError() = RemoteError(
type = this::class.java.name,
message = this.message,
stackTrace = this.stackTraceToString(),
)
inline fun <T> runStep(
stepId: StepId,
onEvent: (ProgressEvent) -> Unit,
block: () -> T,
): T = try {
onEvent(ProgressEvent.Started(stepId))
val value = block()
onEvent(ProgressEvent.Completed(stepId))
value
} catch (error: Exception) {
onEvent(ProgressEvent.Failed(stepId, error.toRemoteError()))
throw error
}

View File

@@ -1,132 +1,115 @@
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.Session.Companion.component1
import app.revanced.manager.patcher.Session.Companion.component2
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.PatchesResult
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchResult
import app.revanced.patcher.patcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
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<Patch<*>>
internal typealias PatchList = List<Patch>
private typealias Patcher = (emit: (PatchResult) -> Unit) -> PatchesResult
class Session(
cacheDir: String,
frameworkDir: String,
aaptPath: String,
private val androidContext: Context,
private val frameworkDir: String,
private val aaptPath: String,
private val logger: Logger,
private val input: File,
private val onPatchCompleted: suspend () -> Unit,
private val onProgress: (name: String?, state: State?, message: String?) -> Unit
private val onEvent: (ProgressEvent) -> 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
)
private suspend fun applyPatchesVerbose(patcher: Patcher, indices: Map<Patch, Int>) =
withContext(
Dispatchers.Default
) {
val context = currentCoroutineContext()
patcher { (patch, exception) ->
// Make the patching process cancelable.
context.ensureActive()
this().collect { (patch, exception) ->
if (patch !in selectedPatches) return@collect
val index = indices[patch] ?: return@patcher
if (exception != null) {
updateProgress(
name = androidContext.getString(R.string.failed_to_execute_patch, patch.name),
state = State.FAILED,
message = exception.stackTraceToString()
if (exception != null) {
onEvent(
ProgressEvent.Failed(
StepId.ExecutePatch(index),
exception.toRemoteError(),
)
)
logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString())
throw exception
}
onEvent(
ProgressEvent.Completed(
StepId.ExecutePatch(index),
)
)
logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString())
throw exception
logger.info("${patch.name} succeeded")
}
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
val indices = HashMap<Patch, Int>(selectedPatches.size)
selectedPatches.forEachIndexed { idx, patch -> indices[patch] = idx }
java.util.logging.Logger.getLogger("").apply {
handlers.forEach {
it.close()
removeHandler(it)
val result = runStep(StepId.ExecutePatches, onEvent) {
java.util.logging.Logger.getLogger("").apply {
handlers.forEach {
it.close()
removeHandler(it)
}
addHandler(logger.handler)
}
addHandler(logger.handler)
}
with(patcher) {
logger.info("Merging integrations")
this += selectedPatches.toSet()
val patcher = patcher(
apkFile = input,
temporaryFilesPath = tempDir,
frameworkFileDirectory = frameworkDir,
aaptBinaryPath = File(aaptPath)
) { _packageName, _version ->
selectedPatches.toSet()
}
logger.info("Applying patches...")
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
applyPatchesVerbose(patcher, indices)
}
logger.info("Writing patched files...")
val result = patcher.get()
runStep(StepId.WriteAPK, onEvent) {
logger.info("Writing patched files...")
val patched = tempDir.resolve("result.apk")
withContext(Dispatchers.IO) {
Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
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)
}
}
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 {

View File

@@ -2,7 +2,8 @@ package app.revanced.manager.patcher.patch
import kotlinx.parcelize.IgnoredOnParcel
import android.os.Parcelable
import app.revanced.patcher.patch.loadPatchesFromDex
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.loadPatches
import kotlinx.parcelize.Parcelize
import java.io.File
import java.io.IOException
@@ -54,31 +55,45 @@ data class PatchBundle(val patchesJar: String) : Parcelable {
)
object Loader {
private fun patches(bundles: Iterable<PatchBundle>) =
loadPatchesFromDex(
bundles.map { File(it.patchesJar) }.toSet()
).byPatchesFile.mapKeys { (file, _) ->
val absPath = file.absolutePath
bundles.single { absPath == it.patchesJar }
private fun patches(bundles: Iterable<PatchBundle>) = buildMap {
val bundleMap = bundles.associateBy { it.patchesJar }
loadPatches(
*bundleMap.keys.map(::File).toTypedArray(),
onFailedToLoad = { file, throwable ->
this[bundleMap[file.absolutePath]!!] = Result.failure(throwable)
}
).patchesByFile.forEach { (file, patches) ->
putIfAbsent(bundleMap[file.absolutePath]!!, Result.success(patches))
}
}
fun metadata(bundles: Iterable<PatchBundle>): Map<PatchBundle, Result<Set<PatchInfo>>> =
patches(bundles).mapValues { (_, result) ->
result.map { patches ->
patches.mapTo(
HashSet(patches.size),
::PatchInfo
)
}
}
fun metadata(bundles: Iterable<PatchBundle>) =
patches(bundles).mapValues { (_, patches) -> patches.map(::PatchInfo) }
fun patches(bundles: Iterable<PatchBundle>, packageName: String): Map<PatchBundle, Set<Patch>> =
patches(bundles).mapValues { (_, result) ->
val patches = result.getOrDefault(emptySet())
fun patches(bundles: Iterable<PatchBundle>, packageName: String) =
patches(bundles).mapValues { (_, patches) ->
patches.filter { patch ->
patches.filterTo(HashSet(patches.size)) { patch ->
val compatiblePackages = patch.compatiblePackages
?: // The patch has no compatibility constraints, which means it is universal.
return@filter true
return@filterTo true
if (!compatiblePackages.any { (name, _) -> name == packageName }) {
// Patch is not compatible with this package.
return@filter false
return@filterTo false
}
true
}.toSet()
}
}
}
}
}

View File

@@ -137,7 +137,7 @@ sealed class PatchBundleInfo {
it.options.all option@{ option ->
if (!option.required || option.default != null) return@option true
option.key in opts
option.name in opts
}
}
}

View File

@@ -17,7 +17,7 @@ data class PatchInfo(
val compatiblePackages: ImmutableList<CompatiblePackage>?,
val options: ImmutableList<Option<*>>?
) {
constructor(patch: Patch<*>) : this(
constructor(patch: Patch) : this(
patch.name.orEmpty(),
patch.description,
patch.use,
@@ -49,7 +49,7 @@ data class PatchInfo(
* The resulting patch cannot be executed.
* This is necessary because some functions in ReVanced Library only accept full [Patch] objects.
*/
fun toPatcherPatch(): Patch<*> =
fun toPatcherPatch(): Patch =
resourcePatch(name = name, description = description, use = include) {
compatiblePackages?.let { pkgs ->
compatibleWith(*pkgs.map { it.packageName to it.versions }.toTypedArray())
@@ -65,8 +65,7 @@ data class CompatiblePackage(
@Immutable
data class Option<T>(
val title: String,
val key: String,
val name: String,
val description: String,
val required: Boolean,
val type: KType,
@@ -75,8 +74,7 @@ data class Option<T>(
val validator: (T?) -> Boolean,
) {
constructor(option: PatchOption<T>) : this(
option.title ?: option.key,
option.key,
option.name,
option.description.orEmpty(),
option.required,
option.type,

View File

@@ -1,11 +1,12 @@
package app.revanced.manager.patcher.runtime
import android.content.Context
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.worker.ProgressEventHandler
import app.revanced.manager.ui.model.State
import app.revanced.manager.patcher.runStep
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import java.io.File
@@ -13,7 +14,7 @@ import java.io.File
/**
* Simple [Runtime] implementation that runs the patcher using coroutines.
*/
class CoroutineRuntime(private val context: Context) : Runtime(context) {
class CoroutineRuntime(context: Context) : Runtime(context) {
override suspend fun execute(
inputFile: String,
outputFile: String,
@@ -21,47 +22,50 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
onEvent: (ProgressEvent) -> Unit,
) {
val selectedBundles = selectedPatches.keys
val bundles = bundles()
val uids = bundles.entries.associate { (key, value) -> value to key }
val patchList = runStep(StepId.LoadPatches, onEvent) {
val selectedBundles = selectedPatches.keys
val bundles = bundles()
val uids = bundles.entries.associate { (key, value) -> value to key }
val allPatches =
PatchBundle.Loader.patches(bundles.values, packageName)
.mapKeys { (b, _) -> uids[b]!! }
.filterKeys { it in selectedBundles }
val allPatches =
PatchBundle.Loader.patches(bundles.values, packageName)
.mapKeys { (b, _) -> uids[b]!! }
.filterKeys { it in selectedBundles }
val patchList = selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { it.name in selected }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
}
val patchList = selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { it.name in selected }
?: 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
// 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
}
}
}
patchList
}
onProgress(null, State.COMPLETED, null) // Loading patches
val session = runStep(StepId.ReadAPK, onEvent) {
Session(
cacheDir,
frameworkPath,
aaptPath,
logger,
File(inputFile),
onEvent,
)
}
Session(
cacheDir,
frameworkPath,
aaptPath,
context,
logger,
File(inputFile),
onPatchCompleted = onPatchCompleted,
onProgress
).use { session ->
session.run(
session.use { s ->
s.run(
File(outputFile),
patchList
)

View File

@@ -10,12 +10,13 @@ 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.ProgressEvent
import app.revanced.manager.patcher.ProgressEventParcel
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.patcher.toEvent
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
@@ -66,8 +67,7 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
onEvent: (ProgressEvent) -> Unit,
) = coroutineScope {
// Get the location of our own Apk.
val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir
@@ -111,7 +111,6 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
}
val patching = CompletableDeferred<Unit>()
val scope = this
launch(Dispatchers.IO) {
val binder = awaitBinderConnection()
@@ -124,13 +123,10 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
val eventHandler = object : IPatcherEvents.Stub() {
override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg)
override fun patchSucceeded() {
scope.launch { onPatchCompleted() }
override fun event(event: ProgressEventParcel?) {
event?.let { onEvent(it.toEvent()) }
}
override fun progress(name: String?, state: String?, msg: String?) =
onProgress(name, state?.let { enumValueOf<State>(it) }, msg)
override fun finished(exceptionStackTrace: String?) {
binder.exit()

View File

@@ -4,9 +4,9 @@ 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.ProgressEvent
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
@@ -34,7 +34,6 @@ sealed class Runtime(context: Context) : KoinComponent {
selectedPatches: PatchSelection,
options: Options,
logger: Logger,
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
onEvent: (ProgressEvent) -> Unit,
)
}

View File

@@ -8,12 +8,15 @@ import android.os.Build
import android.os.Bundle
import android.os.Looper
import app.revanced.manager.BuildConfig
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.StepId
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.runStep
import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.ui.model.State
import app.revanced.manager.patcher.toParcel
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -24,7 +27,7 @@ import kotlin.system.exitProcess
/**
* The main class that runs inside the runner process launched by [ProcessRuntime].
*/
class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
class PatcherProcess() : IPatcherProcess.Stub() {
private var eventBinder: IPatcherEvents? = null
private val scope =
@@ -46,6 +49,8 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
override fun exit() = exitProcess(0)
override fun start(parameters: Parameters, events: IPatcherEvents) {
fun onEvent(event: ProgressEvent) = events.event(event.toParcel())
eventBinder = events
scope.launch {
@@ -56,38 +61,42 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB")
val allPatches = PatchBundle.Loader.patches(parameters.configurations.map { it.bundle }, parameters.packageName)
val patchList = parameters.configurations.flatMap { config ->
val patches = (allPatches[config.bundle] ?: return@flatMap emptyList())
val patchList = runStep(StepId.LoadPatches, ::onEvent) {
val allPatches = PatchBundle.Loader.patches(
parameters.configurations.map { it.bundle },
parameters.packageName
)
parameters.configurations.flatMap { config ->
val patches = (allPatches[config.bundle] ?: return@flatMap emptyList())
.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.")
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
opts.forEach { (key, value) ->
patchOptions[key] = value
}
}
}
patches.values
patches.values
}
}
events.progress(null, State.COMPLETED.name, null) // Loading patches
val session = runStep(StepId.ReadAPK, ::onEvent) {
Session(
cacheDir = parameters.cacheDir,
aaptPath = parameters.aaptPath,
frameworkDir = parameters.frameworkDir,
logger = logger,
input = File(parameters.inputFile),
onEvent = ::onEvent,
)
}
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 {
session.use {
it.run(File(parameters.outputFile), patchList)
}
@@ -119,7 +128,7 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
}
}
val ipcInterface = PatcherProcess(appContext)
val ipcInterface = PatcherProcess()
appContext.sendBroadcast(Intent().apply {
action = ProcessRuntime.CONNECT_TO_APP_ACTION

View File

@@ -24,19 +24,22 @@ 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.DownloaderRepository
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.network.downloader.LoadedDownloader
import app.revanced.manager.patcher.ProgressEvent
import app.revanced.manager.patcher.StepId
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.runStep
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.patcher.toRemoteError
import app.revanced.manager.downloader.GetScope
import app.revanced.manager.downloader.DownloaderHostApi
import app.revanced.manager.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
@@ -48,9 +51,7 @@ 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)
@OptIn(DownloaderHostApi::class)
class PatcherWorker(
context: Context,
parameters: WorkerParameters
@@ -58,7 +59,7 @@ class PatcherWorker(
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 downloaderRepository: DownloaderRepository by inject()
private val downloadedAppRepository: DownloadedAppRepository by inject()
private val pm: PM by inject()
private val fs: Filesystem by inject()
@@ -71,11 +72,9 @@ class PatcherWorker(
val selectedPatches: PatchSelection,
val options: Options,
val logger: Logger,
val onDownloadProgress: suspend (Pair<Long, Long?>?) -> Unit,
val onPatchCompleted: suspend () -> Unit,
val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
val handleStartActivityRequest: suspend (LoadedDownloader, Intent) -> ActivityResult,
val setInputFile: suspend (File) -> Unit,
val onProgress: ProgressEventHandler
val onEvent: (ProgressEvent) -> Unit,
) {
val packageName get() = input.packageName
}
@@ -140,10 +139,6 @@ class PatcherWorker(
}
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 {
@@ -155,59 +150,73 @@ class PatcherWorker(
}
}
suspend fun download(plugin: LoadedDownloaderPlugin, data: Parcelable) =
suspend fun download(downloader: LoadedDownloader, data: Parcelable) =
downloadedAppRepository.download(
plugin,
downloader,
data,
args.packageName,
args.input.version,
prefs.suggestedVersionSafeguard.get(),
!prefs.disablePatchVersionCompatCheck.get(),
onDownload = args.onDownloadProgress
).also {
args.setInputFile(it)
updateProgress(state = State.COMPLETED) // Download APK
}
onDownload = { progress ->
args.onEvent(
ProgressEvent.Progress(
stepId = StepId.DownloadAPK,
current = progress.first,
total = progress.second
)
)
}
).also { args.setInputFile(it) }
val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> {
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(selectedApp.data)
runStep(StepId.DownloadAPK, args.onEvent) {
val (downloader, data) = downloaderRepository.unwrapParceledData(
selectedApp.data
)
download(plugin, data)
download(downloader, 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
)
runStep(StepId.DownloadAPK, args.onEvent) {
downloaderRepository.loadedDownloadersFlow.first()
.firstNotNullOfOrNull { downloader ->
try {
val getScope = object : GetScope {
override val downloaderPackageName = downloader.packageName
override val hostPackageName =
applicationContext.packageName
override suspend fun requestStartActivity(intent: Intent): Intent? {
val result =
args.handleStartActivityRequest(downloader, 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.")
withContext(Dispatchers.IO) {
downloader.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(downloader, data) }
} ?: throw Exception("App is not available.")
}
}
is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) }
@@ -227,12 +236,12 @@ class PatcherWorker(
args.selectedPatches,
args.options,
args.logger,
args.onPatchCompleted,
args.onProgress
args.onEvent,
)
keystoreManager.sign(patchedApk, File(args.output))
updateProgress(state = State.COMPLETED) // Signing
runStep(StepId.SignAPK, args.onEvent) {
keystoreManager.sign(patchedApk, File(args.output))
}
Log.i(tag, "Patching succeeded".logFmt())
Result.success()
@@ -241,11 +250,21 @@ class PatcherWorker(
tag,
"An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()
)
updateProgress(state = State.FAILED, message = e.originalStackTrace)
args.onEvent(
ProgressEvent.Failed(
null,
e.toRemoteError()
)
) // Fallback if exception doesn't occur within step
Result.failure()
} catch (e: Exception) {
Log.e(tag, "An exception occurred while patching".logFmt(), e)
updateProgress(state = State.FAILED, message = e.stackTraceToString())
args.onEvent(
ProgressEvent.Failed(
null,
e.toRemoteError()
)
) // Fallback if exception doesn't occur within step
Result.failure()
} finally {
patchedApk.delete()

View File

@@ -1,53 +0,0 @@
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"
}
}

View File

@@ -16,7 +16,7 @@ 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
import com.eygraber.compose.placeholder.material3.placeholder
@Composable
fun AppIcon(

View File

@@ -16,7 +16,7 @@ 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 com.eygraber.compose.placeholder.material3.placeholder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

View File

@@ -1,5 +1,6 @@
package app.revanced.manager.ui.component
import android.annotation.SuppressLint
import android.content.pm.PackageInstaller
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
@@ -79,7 +80,7 @@ private fun installerStatusDialogButton(
enum class DialogKind(
val flag: Int,
val title: Int,
@StringRes val contentStringResId: Int,
@param:StringRes val contentStringResId: Int,
val icon: ImageVector = Icons.Outlined.ErrorOutline,
val confirmButton: InstallerStatusDialogButton = installerStatusDialogButton(R.string.ok),
val dismissButton: InstallerStatusDialogButton? = null,
@@ -133,10 +134,8 @@ enum class DialogKind(
title = R.string.installation_storage_issue_dialog_title,
contentStringResId = R.string.installation_storage_issue_description,
),
@RequiresApi(34)
FAILURE_TIMEOUT(
flag = PackageInstaller.STATUS_FAILURE_TIMEOUT,
flag = @SuppressLint("InlinedApi") PackageInstaller.STATUS_FAILURE_TIMEOUT,
title = R.string.installation_timeout_dialog_title,
contentStringResId = R.string.installation_timeout_description,
confirmButton = installerStatusDialogButton(R.string.install_app) { model ->

View File

@@ -14,7 +14,7 @@ fun LoadingIndicator(
progress: () -> Float? = { null },
color: Color = ProgressIndicatorDefaults.circularColor,
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
trackColor: Color = ProgressIndicatorDefaults.circularTrackColor,
trackColor: Color = ProgressIndicatorDefaults.circularIndeterminateTrackColor,
strokeCap: StrokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap
) {
progress()?.let {

View File

@@ -18,8 +18,6 @@ fun 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),

View File

@@ -29,7 +29,8 @@ fun SearchBar(
) {
val colors = SearchBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
dividerColor = MaterialTheme.colorScheme.outline
dividerColor = MaterialTheme.colorScheme.outline,
inputFieldColors = SearchBarDefaults.inputFieldColors()
)
val keyboardController = LocalSoftwareKeyboardController.current

View File

@@ -31,7 +31,8 @@ fun SearchView(
) {
val colors = SearchBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
dividerColor = MaterialTheme.colorScheme.outline
dividerColor = MaterialTheme.colorScheme.outline,
inputFieldColors = SearchBarDefaults.inputFieldColors()
)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current

View File

@@ -212,7 +212,7 @@ fun PatchItem(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = option.title,
text = option.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)

View File

@@ -1,7 +1,7 @@
package app.revanced.manager.ui.component.patcher
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -12,7 +12,6 @@ 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
@@ -21,6 +20,7 @@ import androidx.compose.material3.Icon
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.remember
@@ -39,11 +39,9 @@ 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 app.revanced.manager.ui.model.Step
import java.util.Locale
import kotlin.math.floor
@@ -52,21 +50,10 @@ import kotlin.math.floor
fun Steps(
category: StepCategory,
steps: List<Step>,
stepCount: Pair<Int, Int>? = null,
stepProgressProvider: StepProgressProvider
isExpanded: Boolean = false,
onExpand: () -> Unit,
onClick: () -> Unit
) {
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
@@ -76,62 +63,69 @@ fun Steps(
}
}
val filteredSteps = remember(steps) {
val failedCount = steps.count { it.state == State.FAILED }
steps.filter { step ->
// Show hidden steps if it's the only failed step.
!step.hide || (step.state == State.FAILED && failedCount == 1)
}
}
LaunchedEffect(state) {
if (state == State.RUNNING || state == State.FAILED)
onExpand()
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clip(MaterialTheme.shapes.large)
.fillMaxWidth()
.background(cardColor)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { expanded = !expanded }
.background(categoryColor)
.clickable(true, onClick = onClick)
.fillMaxWidth()
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(16.dp)
) {
StepIcon(state = state, size = 24.dp)
StepIcon(state = state, size = 24.dp)
Text(stringResource(category.displayName))
Text(stringResource(category.displayName))
Spacer(modifier = Modifier.weight(1f))
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 = "${filteredSteps.count { it.state == State.COMPLETED }}/${filteredSteps.size}",
style = MaterialTheme.typography.labelSmall
)
Text(
text = stepProgress,
style = MaterialTheme.typography.labelSmall
)
ArrowButton(modifier = Modifier.size(24.dp), expanded = expanded, onClick = null)
}
ArrowButton(modifier = Modifier.size(24.dp), expanded = isExpanded, onClick = null)
}
AnimatedVisibility(visible = expanded) {
AnimatedVisibility(visible = isExpanded) {
Column(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.background(MaterialTheme.colorScheme.background.copy(0.6f))
.fillMaxWidth()
.padding(top = 10.dp)
) {
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"
}
filteredSteps.forEachIndexed { index, step ->
val (progress, progressText) = step.progress?.let { (current, total) ->
if (total != null) current.toFloat() / total.toFloat() to "${current.megaBytes}/${total.megaBytes} MB"
else null to "${current.megaBytes} MB"
} ?: (null to null)
SubStep(
name = step.name,
name = step.title,
state = step.state,
message = step.message,
progress = progress,
progressText = progressText
progressText = progressText,
isFirst = index == 0,
isLast = index == filteredSteps.lastIndex,
)
}
}
@@ -145,7 +139,9 @@ fun SubStep(
state: State,
message: String? = null,
progress: Float? = null,
progressText: String? = null
progressText: String? = null,
isFirst: Boolean = false,
isLast: Boolean = false,
) {
var messageExpanded by rememberSaveable { mutableStateOf(true) }
@@ -156,22 +152,22 @@ fun SubStep(
clickable { messageExpanded = !messageExpanded }
else this
}
.padding(top = if (isFirst) 10.dp else 8.dp, bottom = if (isLast) 20.dp else 8.dp)
.padding(horizontal = 20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center
) {
StepIcon(state, progress, size = 20.dp)
}
StepIcon(
size = 18.dp,
state = state,
progress = progress,
)
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, true),
@@ -201,7 +197,7 @@ fun SubStep(
text = message.orEmpty(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(horizontal = 52.dp, vertical = 8.dp)
modifier = Modifier.padding(horizontal = 36.dp, vertical = 8.dp)
)
}
}
@@ -211,40 +207,44 @@ fun SubStep(
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
Crossfade(targetState = state, label = "State CrossFade") { state ->
when (state) {
State.COMPLETED -> Icon(
Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.step_completed),
tint = Color(0xFF59B463),
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.onSurface.copy(.2f),
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
)
}
}
}
}

View File

@@ -205,7 +205,7 @@ fun <T : Any> OptionItem(
WithOptionEditor(editor, option, value, setValue, selectionWarningEnabled) {
ListItem(
modifier = Modifier.clickable(onClick = ::clickAction),
headlineContent = { Text(option.title) },
headlineContent = { Text(option.name) },
supportingContent = {
Column {
Text(option.description)
@@ -250,7 +250,7 @@ private object StringOptionEditor : OptionEditor<String> {
AlertDialog(
onDismissRequest = scope.dismissDialog,
title = { Text(scope.option.title) },
title = { Text(scope.option.name) },
text = {
OutlinedTextField(
value = fieldValue,
@@ -330,7 +330,7 @@ private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
@Composable
override fun Dialog(scope: OptionEditorScope<T>) {
NumberDialog(scope.option.title, scope.value, scope.option.validator) {
NumberDialog(scope.option.name, scope.value, scope.option.validator) {
if (it == null) return@NumberDialog scope.dismissDialog()
scope.submitDialog(it)
@@ -455,7 +455,7 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
Text(stringResource(R.string.cancel))
}
},
title = { Text(scope.option.title) },
title = { Text(scope.option.name) },
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
text = {
val presets = remember(scope.option.presets) {
@@ -496,8 +496,7 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
private class ListOptionEditor<T : Serializable>(private val elementEditor: OptionEditor<T>) :
OptionEditor<List<T>> {
private fun createElementOption(option: Option<List<T>>) = Option<T>(
option.title,
option.key,
option.name,
option.description,
option.required,
option.type.arguments.first().type!!,
@@ -566,7 +565,7 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
R.plurals.selected_count,
deletionTargets.size,
deletionTargets.size
) else scope.option.title,
) else scope.option.name,
onBackClick = back,
backIcon = {
if (deleteMode) {

View File

@@ -3,9 +3,10 @@ package app.revanced.manager.ui.model
import android.os.Parcelable
import androidx.annotation.StringRes
import app.revanced.manager.R
import app.revanced.manager.patcher.StepId
import kotlinx.parcelize.Parcelize
enum class StepCategory(@StringRes val displayName: Int) {
enum class StepCategory(@param:StringRes val displayName: Int) {
PREPARING(R.string.patcher_step_group_preparing),
PATCHING(R.string.patcher_step_group_patching),
SAVING(R.string.patcher_step_group_saving)
@@ -15,19 +16,20 @@ enum class State {
WAITING, RUNNING, FAILED, COMPLETED
}
enum class ProgressKey {
DOWNLOAD
}
interface StepProgressProvider {
val downloadProgress: Pair<Long, Long?>?
}
@Parcelize
data class Step(
val name: String,
val id: StepId,
val title: String,
val category: StepCategory,
val state: State = State.WAITING,
val message: String? = null,
val progressKey: ProgressKey? = null
) : Parcelable
val progress: Pair<Long, Long?>? = null,
val hide: Boolean = false,
) : Parcelable
fun Step.withState(
state: State = this.state,
message: String? = this.message,
progress: Pair<Long, Long?>? = this.progress
) = copy(state = state, message = message, progress = progress)

View File

@@ -1,6 +1,7 @@
package app.revanced.manager.ui.model.navigation
import android.os.Parcelable
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
@@ -22,6 +23,12 @@ data class InstalledApplicationInfo(val packageName: String)
@Serializable
data class Update(val downloadOnScreenEntry: Boolean = false)
@Serializable
data object Announcements
@Serializable
data object Announcement : ComplexParameter<ReVancedAnnouncement>
@Serializable
data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.ViewModelParams> {
@Parcelize

View File

@@ -0,0 +1,136 @@
package app.revanced.manager.ui.screen
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TwoRowsTopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.children
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.util.relativeTime
import org.intellij.lang.annotations.Language
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AnnouncementScreen(
onBackClick: () -> Unit,
announcement: ReVancedAnnouncement
) {
val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(
canScroll = {
scrollState.canScrollBackward || scrollState.canScrollForward
}
)
val textColor = MaterialTheme.colorScheme.onSurface
val linkColor = MaterialTheme.colorScheme.primary
Scaffold(
topBar = {
TwoRowsTopAppBar(
title = { expanded ->
Text(
text = announcement.title,
style = if (expanded) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.titleMedium
)
},
subtitle = {
val createDate = announcement.createdAt.relativeTime(LocalContext.current)
Text("$createDate · ${announcement.author}")
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
AndroidView(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection)
.verticalScroll(scrollState)
.padding(horizontal = 10.dp),
factory = {
val webView = WebView(it).apply {
setBackgroundColor(0)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
isLongClickable = false
setOnLongClickListener { true }
isHapticFeedbackEnabled = false
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
// Disable WebView's internal scrolling
@SuppressLint("ClickableViewAccessibility")
setOnTouchListener { _, event ->
event.action == MotionEvent.ACTION_MOVE
}
}
FrameLayout(it).apply {
addView(webView)
}
},
update = {
val webView = it.children.first() as WebView
@Language("HTML")
val style = """
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
color: ${textColor.toCss()};
}
a {
color: ${linkColor.toCss()};
}
</style>
</head>
<body>
${announcement.content}
</body>
</html>
""".trimIndent()
webView.loadData(style, "text/html", "UTF-8")
},
onRelease = {
val webView = it.children.first() as WebView
webView.destroy()
}
)
}
}
private fun Color.toCss(): String {
return "rgba(${red * 255f}, ${green * 255f}, ${blue * 255f}, $alpha)"
}

View File

@@ -0,0 +1,329 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material3.Badge
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.viewmodel.AnnouncementsViewModel
import app.revanced.manager.util.relativeTime
import app.revanced.manager.util.transparentListItemColors
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.koin.androidx.compose.koinViewModel
import kotlin.time.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnnouncementsScreen(
onBackClick: () -> Unit,
onAnnouncementClick: (ReVancedAnnouncement) -> Unit,
vm: AnnouncementsViewModel = koinViewModel(),
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
var showFilterSheet by rememberSaveable { mutableStateOf(false) }
val tags by vm.tags.collectAsStateWithLifecycle()
val selectedTags by vm.selectedTags.getAsState()
val showArchived by vm.showArchived.collectAsStateWithLifecycle()
val announcements by vm.announcements.collectAsStateWithLifecycle(emptyList())
if (showFilterSheet) {
FilterBottomSheet(
onDismissRequest = { showFilterSheet = false },
tags = tags.orEmpty(),
selectedTags = selectedTags,
showArchived = showArchived,
onShowArchivedChange = { vm.showArchived.value = it },
onReset = vm::resetTagSelection,
changeSelection = vm::changeTagSelection
)
}
Scaffold(
topBar = {
AppTopBar(
title = { Text(stringResource(R.string.announcements)) },
onBackClick = onBackClick,
actions = {
if (tags != null) {
IconButton(onClick = { showFilterSheet = true }) {
Icon(
imageVector = Icons.Outlined.FilterAlt,
contentDescription = stringResource(R.string.announcements_filter_tag)
)
}
}
},
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
val readAnnouncements by vm.readAnnouncements.getAsState()
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.nestedScroll(scrollBehavior.nestedScrollConnection),
verticalArrangement = if (announcements.isNullOrEmpty()) Arrangement.Center else Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
announcements?.let { announcements ->
if (announcements.isEmpty()) {
item {
Text(
text = stringResource(id = R.string.no_announcements_found),
style = MaterialTheme.typography.titleLarge
)
}
} else {
itemsIndexed(
items = announcements,
key = { _, announcement ->
announcement.id
}
) { i, announcement ->
if (i != 0) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth()
)
}
AnnouncementCard(
modifier = Modifier
.fillMaxWidth(),
onClick = {
vm.markAnnouncementRead(announcement.id)
onAnnouncementClick(announcement)
},
title = announcement.title,
date = announcement.createdAt.relativeTime(LocalContext.current),
author = announcement.author,
content = announcement.content,
unread = announcement.id !in readAnnouncements,
archived = announcement.archivedAt.toInstant(TimeZone.UTC) < Clock.System.now()
)
}
}
} ?: item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
LoadingIndicator()
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FilterBottomSheet(
onDismissRequest: () -> Unit,
tags: List<String>,
selectedTags: Set<String>,
showArchived: Boolean,
onShowArchivedChange: (Boolean) -> Unit,
onReset: () -> Unit,
changeSelection: (String) -> Unit
) {
ModalBottomSheet(onDismissRequest = onDismissRequest) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.announcements_filter_tag),
style = MaterialTheme.typography.titleMedium
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
tags.forEach { tag ->
FilterChip(
selected = tag in selectedTags,
onClick = {
changeSelection(tag)
},
label = { Text(tag) }
)
}
}
ListItem(
modifier = Modifier.clickable(onClick = { onShowArchivedChange(!showArchived) }),
headlineContent = { Text(text = stringResource(R.string.announcements_show_archived)) },
trailingContent = {
Switch(
checked = showArchived,
onCheckedChange = onShowArchivedChange
)
},
colors = transparentListItemColors
)
TextButton(modifier = Modifier.align(Alignment.End), onClick = onReset) {
Text(stringResource(R.string.reset))
}
}
}
}
@Composable
private fun AnnouncementCard(
modifier: Modifier = Modifier,
onClick: () -> Unit,
title: String,
date: String,
author: String,
content: String,
unread: Boolean,
archived: Boolean
) {
Column(
modifier = modifier
.clickable(onClick = onClick)
.background(if (unread) MaterialTheme.colorScheme.surfaceContainerLow else MaterialTheme.colorScheme.surface)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = if (unread) FontWeight.ExtraBold else null
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "$date • $author",
style = MaterialTheme.typography.labelMedium,
fontWeight = if (unread) FontWeight.ExtraBold else null
)
if (archived) {
Icon(
Icons.Outlined.Inventory2,
contentDescription = null,
modifier = Modifier.size(12.dp)
)
}
if (unread) {
Badge(modifier = Modifier.size(6.dp))
}
}
}
Icon(Icons.Default.ChevronRight, contentDescription = null)
}
// TODO add announcement summary
// val textColor = MaterialTheme.colorScheme.onSurface
// val linkColor = MaterialTheme.colorScheme.primary
// AndroidView(
// factory = {
// WebView(it).apply {
// setBackgroundColor(0)
// isVerticalScrollBarEnabled = false
// isHorizontalScrollBarEnabled = false
// isLongClickable = false
// setOnLongClickListener { true }
// isHapticFeedbackEnabled = false
//
// // Disable WebView's internal scrolling
// @SuppressLint("ClickableViewAccessibility")
// setOnTouchListener { _, event ->
// event.action == MotionEvent.ACTION_MOVE
// }
// }
// },
// update = {
// @Language("HTML")
// val body = """
// <html>
// <head>
// <meta name="viewport" content="width=device-width, initial-scale=1" />
// <style>
// * {
// font-size: 12px;
// font-weight: normal;
// }
// body {
// margin: 0;
// padding: 0;
// color: ${textColor.toCss()};
// overflow: hidden;
// display: -webkit-box;
// -webkit-box-orient: vertical;
// -webkit-line-clamp: 3;
// text-overflow: ellipsis;
// }
// a {
// color: ${linkColor.toCss()};
// }
// </style>
// </head>
// <body>
// $content
// </body>
// </html>
// """.trimIndent()
//
// it.loadData(body, "text/html", "UTF-8")
// },
// onReset = {},
// onRelease = { it.destroy() }
// )
}
}
//private fun Color.toCss(): String {
// return "rgba(${red * 255f}, ${green * 255f}, ${blue * 255f}, $alpha)"
//}

View File

@@ -19,11 +19,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Source
@@ -36,7 +38,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TabRow
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
@@ -53,10 +55,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
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.network.dto.ReVancedAnnouncement
import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar
@@ -90,16 +94,19 @@ fun DashboardScreen(
onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit,
onUpdateClick: () -> Unit,
onDownloaderPluginClick: () -> Unit,
onAnnouncementsClick: () -> Unit,
onAnnouncementClick: (ReVancedAnnouncement) -> Unit,
onDownloaderClick: () -> Unit,
onAppClick: (String) -> Unit
) {
var selectedSourceCount by rememberSaveable { mutableIntStateOf(0) }
val bundlesSelectable by remember { derivedStateOf { selectedSourceCount > 0 } }
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle(
val showNewDownloaderNotification by vm.newDownloadersAvailable.collectAsStateWithLifecycle(
false
)
val androidContext = LocalContext.current
val resources = LocalResources.current
val composableScope = rememberCoroutineScope()
val pagerState = rememberPagerState(
initialPage = DashboardPage.DASHBOARD.ordinal,
@@ -128,17 +135,16 @@ fun DashboardScreen(
)
}
var showUpdateDialog by rememberSaveable { mutableStateOf(vm.prefs.showManagerUpdateDialogOnLaunch.getBlocking()) }
val availableUpdate by remember {
derivedStateOf { vm.updatedManagerVersion.takeIf { showUpdateDialog } }
}
var showUpdateDialog by rememberSaveable { mutableStateOf(true) }
val showManagerUpdateDialogOnLaunch by vm.prefs.showManagerUpdateDialogOnLaunch.getAsState()
val availableUpdate = vm.updatedManagerVersion
availableUpdate?.let { version ->
if (showUpdateDialog && showManagerUpdateDialogOnLaunch && availableUpdate != null) {
AvailableUpdateDialog(
onDismiss = { showUpdateDialog = false },
setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch,
onConfirm = onUpdateClick,
newVersion = version
newVersion = availableUpdate
)
}
@@ -218,6 +224,20 @@ fun DashboardScreen(
}
}
}
IconButton(onClick = onAnnouncementsClick) {
BadgedBox(
badge = {
if (vm.unreadAnnouncement != null) {
Badge(modifier = Modifier.size(6.dp))
}
}
) {
Icon(
Icons.Outlined.Notifications,
stringResource(R.string.announcements)
)
}
}
IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
}
@@ -234,7 +254,7 @@ fun DashboardScreen(
when (pagerState.currentPage) {
DashboardPage.DASHBOARD.ordinal -> {
if (availablePatches < 1) {
androidContext.toast(androidContext.getString(R.string.no_patch_found))
androidContext.toast(resources.getString(R.string.no_patch_found))
composableScope.launch {
pagerState.animateScrollToPage(
DashboardPage.BUNDLES.ordinal
@@ -259,7 +279,7 @@ fun DashboardScreen(
}
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
TabRow(
SecondaryTabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
@@ -307,20 +327,42 @@ fun DashboardScreen(
)
}
} else null,
if (showNewDownloaderPluginsNotification) {
if (showNewDownloaderNotification) {
{
NotificationCard(
text = stringResource(R.string.new_downloader_plugins_notification),
text = stringResource(R.string.new_downloader_notification),
icon = Icons.Outlined.Download,
modifier = Modifier.clickable(onClick = onDownloaderPluginClick),
modifier = Modifier.clickable(onClick = onDownloaderClick),
actions = {
TextButton(onClick = vm::ignoreNewDownloaderPlugins) {
TextButton(onClick = vm::ignoreNewDownloaders) {
Text(stringResource(R.string.dismiss))
}
}
)
}
} else null
} else null,
vm.unreadAnnouncement?.let { announcement ->
{
NotificationCard(
text = stringResource(R.string.new_announcement, announcement.title),
icon = Icons.Filled.Notifications,
actions = {
TextButton(onClick = vm::markUnreadAnnouncementRead) {
Text(stringResource(R.string.dismiss))
}
TextButton(
onClick = {
vm.markUnreadAnnouncementRead()
onAnnouncementClick(announcement)
}
) {
Text(stringResource(R.string.view_announcement))
}
},
isWarning = announcement.level > 0
)
}
}
)
HorizontalPager(

View File

@@ -40,6 +40,7 @@ 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.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
@@ -55,6 +56,7 @@ import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.viewmodel.PatcherViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.toast
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -68,6 +70,7 @@ fun PatcherScreen(
}
val context = LocalContext.current
val resources = LocalResources.current
val exportApkLauncher =
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), viewModel::export)
@@ -76,18 +79,17 @@ fun PatcherScreen(
var showInstallPicker by rememberSaveable { mutableStateOf(false) }
var showDismissConfirmationDialog by rememberSaveable { mutableStateOf(false) }
fun onPageBack() {
if(patcherSucceeded == null)
showDismissConfirmationDialog = true
else
onLeave()
fun onPageBack() = when {
patcherSucceeded == null -> showDismissConfirmationDialog = true
viewModel.isInstalling -> context.toast(resources.getString(R.string.patcher_install_in_progress))
else -> onLeave()
}
BackHandler(onBack = ::onPageBack)
val steps by remember {
derivedStateOf {
viewModel.steps.groupBy { it.category }
viewModel.steps.groupBy { it.category }.toList()
}
}
@@ -148,7 +150,7 @@ fun PatcherScreen(
},
title = { Text(title) },
text = {
Text(stringResource(R.string.plugin_activity_dialog_body))
Text(stringResource(R.string.downloader_activity_dialog_body))
}
)
}
@@ -213,6 +215,12 @@ fun PatcherScreen(
.padding(paddingValues)
.fillMaxSize()
) {
var expandedCategory by rememberSaveable { mutableStateOf<StepCategory?>(null) }
val expandCategory: (StepCategory?) -> Unit = { category ->
expandedCategory = category
}
LinearProgressIndicator(
progress = { viewModel.progress },
modifier = Modifier.fillMaxWidth()
@@ -224,14 +232,17 @@ fun PatcherScreen(
contentPadding = PaddingValues(16.dp)
) {
items(
items = steps.toList(),
items = steps,
key = { it.first }
) { (category, steps) ->
Steps(
category = category,
steps = steps,
stepCount = if (category == StepCategory.PATCHING) viewModel.patchesProgress else null,
stepProgressProvider = viewModel
isExpanded = expandedCategory == category,
onExpand = { expandCategory(category) },
onClick = {
expandCategory(if (expandedCategory == category) null else category)
}
)
}
}

View File

@@ -40,7 +40,7 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -49,10 +49,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -81,9 +83,11 @@ import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.transparentListItemColors
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, FlowPreview::class)
@Composable
fun PatchesSelectorScreen(
onSave: (PatchSelection?, Options) -> Unit,
@@ -231,7 +235,8 @@ fun PatchesSelectorScreen(
viewModel.selectionWarningEnabled -> showSelectionWarning = true
// Show universal warning if universal patch is selected and the toggle is off
patch.compatiblePackages == null && viewModel.universalPatchWarningEnabled -> showUniversalWarning = true
patch.compatiblePackages == null && viewModel.universalPatchWarningEnabled -> showUniversalWarning =
true
// Toggle the patch otherwise
else -> viewModel.togglePatch(uid, patch)
@@ -360,6 +365,21 @@ fun PatchesSelectorScreen(
) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
val isScrollingUp =
patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp()
val expanded by produceState(true, isScrollingUp) {
val state = isScrollingUp ?: return@produceState
value = state.value
// Use snapshotFlow and sample to prevent the value from changing too often.
snapshotFlow { state.value }
.sample(333L)
.collect {
value = it
}
}
HapticExtendedFloatingActionButton(
text = {
Text(
@@ -375,8 +395,7 @@ fun PatchesSelectorScreen(
contentDescription = stringResource(R.string.save)
)
},
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp
?: true,
expanded = expanded,
onClick = {
onSave(viewModel.getCustomSelection(), viewModel.getOptions())
}
@@ -392,7 +411,7 @@ fun PatchesSelectorScreen(
.padding(top = 16.dp)
) {
if (bundles.size > 1) {
ScrollableTabRow(
SecondaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
@@ -621,17 +640,17 @@ private fun OptionsDialog(
) {
if (patch.options == null) return@LazyColumnWithScrollbar
items(patch.options, key = { it.key }) { option ->
val key = option.key
items(patch.options, key = { it.name }) { option ->
val name = option.name
val value =
if (values == null || !values.contains(key)) option.default else values[key]
if (values == null || !values.contains(name)) option.default else values[name]
@Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = {
set(key, it)
set(name, it)
},
selectionWarningEnabled = selectionWarningEnabled
)

View File

@@ -13,7 +13,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.SecondaryScrollableTabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
@@ -106,7 +106,7 @@ fun RequiredOptionsScreen(
.padding(paddingValues)
) {
if (list.isEmpty()) return@Column
else if (list.size > 1) ScrollableTabRow(
else if (list.size > 1) SecondaryScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
@@ -144,16 +144,16 @@ fun RequiredOptionsScreen(
val values = vm.getOptions(bundle.uid, it)
it.options?.forEach { option ->
val key = option.key
val name = option.name
val value =
if (values == null || key !in values) option.default else values[key]
if (values == null || name !in values) option.default else values[name]
@Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = { new ->
vm.setOption(bundle.uid, it, key, new)
vm.setOption(bundle.uid, it, name, new)
},
selectionWarningEnabled = vm.selectionWarningEnabled
)

View File

@@ -25,12 +25,14 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -38,7 +40,7 @@ import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.network.downloader.LoadedDownloader
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar
@@ -67,6 +69,7 @@ fun SelectedAppInfoScreen(
vm: SelectedAppInfoViewModel
) {
val context = LocalContext.current
val resources = LocalResources.current
val networkInfo = koinInject<NetworkInfo>()
val networkConnected = remember { networkInfo.isConnected() }
val networkMetered = remember { !networkInfo.isUnmetered() }
@@ -76,16 +79,16 @@ fun SelectedAppInfoScreen(
val bundles by vm.bundleInfoFlow.collectAsStateWithLifecycle(emptyList())
val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
val patches = remember(bundles, allowIncompatiblePatches) {
vm.getPatches(bundles, allowIncompatiblePatches)
}
val selectedPatchCount = remember(patches) {
patches.values.sumOf { it.size }
val patches by remember {
derivedStateOf {
vm.getPatches(bundles, allowIncompatiblePatches)
}
}
val selectedPatchCount = patches.values.sumOf { it.size }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = vm::handlePluginActivityResult
onResult = vm::handleDownloaderActivityResult
)
EventEffect(flow = vm.launchActivityFlow) { intent ->
launcher.launch(intent)
@@ -117,7 +120,7 @@ fun SelectedAppInfoScreen(
},
onClick = patchClick@{
if (selectedPatchCount == 0) {
context.toast(context.getString(R.string.no_patches_selected))
context.toast(resources.getString(R.string.no_patches_selected))
return@patchClick
}
@@ -139,22 +142,22 @@ fun SelectedAppInfoScreen(
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
val plugins by vm.plugins.collectAsStateWithLifecycle(emptyList())
val downloaders by vm.downloaders.collectAsStateWithLifecycle(emptyList())
if (vm.showSourceSelector) {
val requiredVersion by vm.requiredVersion.collectAsStateWithLifecycle(null)
AppSourceSelectorDialog(
plugins = plugins,
downloaders = downloaders,
installedApp = vm.installedAppData,
searchApp = SelectedApp.Search(
vm.packageName,
vm.desiredVersion
),
activeSearchJob = vm.activePluginAction,
activeSearchJob = vm.activeDownloader,
hasRoot = vm.hasRoot,
onDismissRequest = vm::dismissSourceSelector,
onSelectPlugin = vm::searchUsingPlugin,
onSelectDownloader = vm::searchUsingDownloader,
requiredVersion = requiredVersion,
onSelect = {
vm.selectedApp = it
@@ -200,8 +203,8 @@ fun SelectedAppInfoScreen(
is SelectedApp.Installed -> stringResource(R.string.apk_source_installed)
is SelectedApp.Download -> stringResource(
R.string.apk_source_downloader,
plugins.find { it.packageName == app.data.pluginPackageName }?.name
?: app.data.pluginPackageName
downloaders.find { it.packageName == app.data.downloaderPackageName && it.name == app.data.downloaderClassName }?.name
?: app.data.downloaderPackageName
)
is SelectedApp.Local -> stringResource(R.string.apk_source_local)
@@ -278,14 +281,14 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
@Composable
private fun AppSourceSelectorDialog(
plugins: List<LoadedDownloaderPlugin>,
downloaders: List<LoadedDownloader>,
installedApp: Pair<SelectedApp.Installed, InstalledApp?>?,
searchApp: SelectedApp.Search,
activeSearchJob: String?,
activeSearchJob: LoadedDownloader?,
hasRoot: Boolean,
requiredVersion: String?,
onDismissRequest: () -> Unit,
onSelectPlugin: (LoadedDownloaderPlugin) -> Unit,
onSelectDownloader: (LoadedDownloader) -> Unit,
onSelect: (SelectedApp) -> Unit,
) {
val canSelect = activeSearchJob == null
@@ -302,15 +305,15 @@ private fun AppSourceSelectorDialog(
text = {
LazyColumn {
item(key = "auto") {
val hasPlugins = plugins.isNotEmpty()
val hasDownloader = downloaders.isNotEmpty()
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && hasPlugins) { onSelect(searchApp) }
.enabled(hasPlugins),
.clickable(enabled = canSelect && hasDownloader) { onSelect(searchApp) }
.enabled(hasDownloader),
headlineContent = { Text(stringResource(R.string.app_source_dialog_option_auto)) },
supportingContent = {
Text(
if (hasPlugins)
if (hasDownloader)
stringResource(R.string.app_source_dialog_option_auto_description)
else
stringResource(R.string.app_source_dialog_option_auto_unavailable)
@@ -348,11 +351,17 @@ private fun AppSourceSelectorDialog(
}
}
items(plugins, key = { "plugin_${it.packageName}" }) { plugin ->
items(
downloaders,
key = { "downloader_${it.packageName}_${it.className}" }) { downloader ->
ListItem(
modifier = Modifier.clickable(enabled = canSelect) { onSelectPlugin(plugin) },
headlineContent = { Text(plugin.name) },
trailingContent = (@Composable { LoadingIndicator() }).takeIf { activeSearchJob == plugin.packageName },
modifier = Modifier.clickable(enabled = canSelect) {
onSelectDownloader(
downloader
)
},
headlineContent = { Text(downloader.name) },
trailingContent = (@Composable { LoadingIndicator() }).takeIf { activeSearchJob == downloader },
colors = transparentListItemColors
)
}

View File

@@ -22,8 +22,8 @@ import app.revanced.manager.ui.model.navigation.Settings
import org.koin.compose.koinInject
private data class Section(
@StringRes val name: Int,
@StringRes val description: Int,
@param:StringRes val name: Int,
@param:StringRes val description: Int,
val image: ImageVector,
val destination: Settings.Destination,
)

View File

@@ -1,5 +1,6 @@
package app.revanced.manager.ui.screen.settings
import android.annotation.SuppressLint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
@@ -40,6 +41,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics
@@ -67,8 +69,9 @@ fun AboutSettingsScreen(
viewModel: AboutViewModel = koinViewModel()
) {
val context = LocalContext.current
val resources = LocalResources.current
// painterResource() is broken on release builds for some reason.
val icon = rememberDrawablePainter(drawable = remember {
val icon = rememberDrawablePainter(drawable = remember(resources) {
AppCompatResources.getDrawable(context, R.drawable.ic_logo_ring)
})
@@ -76,7 +79,7 @@ fun AboutSettingsScreen(
viewModel.socials.partition(ReVancedSocial::preferred)
}
val preferredSocialButtons = remember(preferredSocials, viewModel.donate, viewModel.contact) {
val preferredSocialButtons = remember(resources, preferredSocials, viewModel.donate, viewModel.contact) {
preferredSocials.map {
Triple(
getSocialIcon(it.name),
@@ -89,7 +92,7 @@ fun AboutSettingsScreen(
viewModel.donate?.let {
Triple(
Icons.Outlined.FavoriteBorder,
context.getString(R.string.donate),
resources.getString(R.string.donate),
third = {
context.openUrl(it)
}
@@ -98,7 +101,7 @@ fun AboutSettingsScreen(
viewModel.contact?.let {
Triple(
Icons.Outlined.MailOutline,
context.getString(R.string.contact),
resources.getString(R.string.contact),
third = {
context.openUrl("mailto:$it")
}
@@ -131,7 +134,7 @@ fun AboutSettingsScreen(
stringResource(R.string.contributors_description),
third = nav@{
if (!viewModel.isConnected) {
context.toast(context.getString(R.string.no_network_toast))
context.toast(resources.getString(R.string.no_network_toast))
return@nav
}
@@ -153,7 +156,7 @@ fun AboutSettingsScreen(
LaunchedEffect(developerTaps) {
if (developerTaps == 0) return@LaunchedEffect
if (showDeveloperSettings) {
snackbarHostState.showSnackbar(context.getString(R.string.developer_options_already_enabled))
snackbarHostState.showSnackbar(resources.getString(R.string.developer_options_already_enabled))
developerTaps = 0
return@LaunchedEffect
}
@@ -161,7 +164,7 @@ fun AboutSettingsScreen(
val remaining = DEVELOPER_OPTIONS_TAPS - developerTaps
if (remaining > 0) {
snackbarHostState.showSnackbar(
context.getString(
resources.getString(
R.string.developer_options_taps,
remaining
),
@@ -169,7 +172,7 @@ fun AboutSettingsScreen(
)
} else if (remaining == 0) {
viewModel.showDeveloperSettings.update(true)
snackbarHostState.showSnackbar(context.getString(R.string.developer_options_enabled))
snackbarHostState.showSnackbar(resources.getString(R.string.developer_options_enabled))
}
// Reset the counter

View File

@@ -38,6 +38,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -64,9 +65,10 @@ fun AdvancedSettingsScreen(
viewModel: AdvancedSettingsViewModel = koinViewModel()
) {
val context = LocalContext.current
val memoryLimit = remember {
val resources = LocalResources.current
val memoryLimit = remember(resources) {
val activityManager = context.getSystemService<ActivityManager>()!!
context.getString(
resources.getString(
R.string.device_memory_limit_format,
activityManager.memoryClass,
activityManager.largeMemoryClass
@@ -183,7 +185,7 @@ fun AdvancedSettingsScreen(
ClipData.newPlainText("Device Information", deviceContent)
)
context.toast(context.getString(R.string.toast_copied_to_clipboard))
context.toast(resources.getString(R.string.toast_copied_to_clipboard))
}.withHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
),
headlineContent = stringResource(R.string.about_device),

View File

@@ -2,6 +2,8 @@ package app.revanced.manager.ui.screen.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -10,10 +12,13 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -28,18 +33,19 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.downloader.DownloaderPluginState
import app.revanced.manager.network.downloader.DownloaderPackageState
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.DownloadsViewModel
@@ -52,8 +58,9 @@ fun DownloadsSettingsScreen(
onBackClick: () -> Unit,
viewModel: DownloadsViewModel = koinViewModel()
) {
val context = LocalContext.current
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle()
val downloaderStates by viewModel.downloaderStates.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) }
@@ -61,8 +68,8 @@ fun DownloadsSettingsScreen(
ConfirmDialog(
onDismiss = { showDeleteConfirmationDialog = false },
onConfirm = { viewModel.deleteApps() },
title = stringResource(R.string.downloader_plugin_delete_apps_title),
description = stringResource(R.string.downloader_plugin_delete_apps_description),
title = stringResource(R.string.downloader_delete_apps_title),
description = stringResource(R.string.downloader_delete_apps_description),
icon = Icons.Outlined.Delete
)
}
@@ -75,7 +82,7 @@ fun DownloadsSettingsScreen(
onBackClick = onBackClick,
actions = {
if (viewModel.appSelection.isNotEmpty()) {
IconButton(onClick = { showDeleteConfirmationDialog = true }) {
IconButton(onClick = { viewModel.deleteApps() }) {
Icon(Icons.Default.Delete, stringResource(R.string.delete))
}
}
@@ -85,17 +92,18 @@ fun DownloadsSettingsScreen(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
PullToRefreshBox(
onRefresh = viewModel::refreshPlugins,
isRefreshing = viewModel.isRefreshingPlugins,
onRefresh = viewModel::refreshDownloaders,
isRefreshing = viewModel.isRefreshingDownloaders,
modifier = Modifier.padding(paddingValues)
) {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize()
) {
item {
GroupHeader(stringResource(R.string.downloader_plugins))
GroupHeader(stringResource(R.string.downloaders))
}
pluginStates.forEach { (packageName, state) ->
downloaderStates.forEach { (packageName, state) ->
item(key = packageName) {
var showDialog by rememberSaveable {
mutableStateOf(false)
@@ -121,39 +129,46 @@ fun DownloadsSettingsScreen(
.digest(androidSignature.toByteArray())
hash.toHexString(format = HexFormat.UpperCase)
}
val appName = remember {
packageInfo.applicationInfo?.loadLabel(context.packageManager)
?.toString()
?: packageName
}
when (state) {
is DownloaderPluginState.Loaded -> TrustDialog(
title = R.string.downloader_plugin_revoke_trust_dialog_title,
is DownloaderPackageState.Loaded -> TrustDialog(
title = R.string.downloader_revoke_trust_dialog_title,
body = stringResource(
R.string.downloader_plugin_trust_dialog_body,
R.string.downloader_trust_dialog_body,
packageName,
signature
),
downloaderName = appName,
signature = signature,
onDismiss = ::dismiss,
onConfirm = {
viewModel.revokePluginTrust(packageName)
viewModel.revokeDownloaderTrust(packageName)
dismiss()
}
)
is DownloaderPluginState.Failed -> ExceptionViewerDialog(
is DownloaderPackageState.Failed -> ExceptionViewerDialog(
text = remember(state.throwable) {
state.throwable.stackTraceToString()
},
onDismiss = ::dismiss
)
is DownloaderPluginState.Untrusted -> TrustDialog(
title = R.string.downloader_plugin_trust_dialog_title,
is DownloaderPackageState.Untrusted -> TrustDialog(
title = R.string.downloader_trust_dialog_title,
body = stringResource(
R.string.downloader_plugin_trust_dialog_body,
packageName,
signature
R.string.downloader_trust_dialog_body
),
downloaderName = appName,
signature = signature,
onDismiss = ::dismiss,
onConfirm = {
viewModel.trustPlugin(packageName)
viewModel.trustDownloader(packageName)
dismiss()
}
)
@@ -168,21 +183,24 @@ fun DownloadsSettingsScreen(
style = MaterialTheme.typography.titleLarge
)
},
supportingContent = stringResource(
when (state) {
is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted
is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed
is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted
}
),
supportingContent = when (state) {
is DownloaderPackageState.Loaded -> StringBuilder(stringResource(R.string.downloader_state_trusted)).apply {
val names = state.downloaders.joinToString("\n") { it.name }
if (names.isNotEmpty()) append("\n\n$names")
}.toString()
is DownloaderPackageState.Failed -> stringResource(R.string.downloader_state_failed)
is DownloaderPackageState.Untrusted -> stringResource(R.string.downloader_state_untrusted)
},
trailingContent = { Text(packageInfo.versionName!!) }
)
}
}
if (pluginStates.isEmpty()) {
if (downloaderStates.isEmpty()) {
item {
Text(
stringResource(R.string.downloader_no_plugins_installed),
stringResource(R.string.no_downloader_installed),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
@@ -226,6 +244,8 @@ fun DownloadsSettingsScreen(
private fun TrustDialog(
@StringRes title: Int,
body: String,
downloaderName: String,
signature: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
@@ -238,10 +258,39 @@ private fun TrustDialog(
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dismiss))
Text(stringResource(R.string.cancel))
}
},
title = { Text(stringResource(title)) },
text = { Text(body) }
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(body)
Card {
Column(
Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(
R.string.downloader_trust_dialog_name,
downloaderName
),
)
OutlinedCard(
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
)
) {
Text(
stringResource(
R.string.downloader_trust_dialog_signature,
signature.chunked(2).joinToString(" ")
), modifier = Modifier.padding(12.dp)
)
}
}
}
}
}
)
}

View File

@@ -1,12 +1,15 @@
package app.revanced.manager.ui.screen.settings
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
@@ -18,6 +21,7 @@ import androidx.compose.material3.rememberTopAppBarState
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
@@ -37,6 +41,7 @@ import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.ui.viewmodel.GeneralSettingsViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -47,6 +52,7 @@ fun GeneralSettingsScreen(
val prefs = viewModel.prefs
val coroutineScope = viewModel.viewModelScope
var showThemePicker by rememberSaveable { mutableStateOf(false) }
var showLanguagePicker by rememberSaveable { mutableStateOf(false) }
if (showThemePicker) {
ThemePicker(
@@ -54,6 +60,17 @@ fun GeneralSettingsScreen(
onConfirm = { viewModel.setTheme(it) }
)
}
if (showLanguagePicker) {
LanguagePicker(
supportedLocales = viewModel.getSupportedLocales(),
currentLocale = viewModel.getCurrentLocale(),
onDismiss = { showLanguagePicker = false },
onConfirm = { viewModel.setLocale(it) },
getDisplayName = { viewModel.getLocaleDisplayName(it) }
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
Scaffold(
@@ -73,6 +90,24 @@ fun GeneralSettingsScreen(
) {
GroupHeader(stringResource(R.string.appearance))
val currentLocale = viewModel.getCurrentLocale()
val currentLanguageDisplay = remember(currentLocale) {
currentLocale?.let { viewModel.getLocaleDisplayName(it) }
}
SettingsListItem(
modifier = Modifier.clickable { showLanguagePicker = true },
headlineContent = stringResource(R.string.language),
supportingContent = stringResource(R.string.language_description),
trailingContent = {
FilledTonalButton(onClick = { showLanguagePicker = true }) {
Text(
currentLanguageDisplay
?: stringResource(R.string.language_system_default)
)
}
}
)
val theme by prefs.theme.getAsState()
SettingsListItem(
modifier = Modifier.clickable { showThemePicker = true },
@@ -96,6 +131,22 @@ fun GeneralSettingsScreen(
description = R.string.dynamic_color_description
)
}
AnimatedVisibility(theme != Theme.LIGHT) {
BooleanItem(
preference = prefs.pureBlackTheme,
coroutineScope = coroutineScope,
headline = R.string.pure_black_theme,
description = R.string.pure_black_theme_description
)
}
GroupHeader(stringResource(R.string.networking))
BooleanItem(
preference = prefs.allowMeteredNetworks,
coroutineScope = coroutineScope,
headline = R.string.allow_metered_networks,
description = R.string.allow_metered_networks_description
)
}
}
}
@@ -139,4 +190,64 @@ private fun ThemePicker(
}
}
)
}
@Composable
private fun LanguagePicker(
supportedLocales: List<Locale>,
currentLocale: Locale?,
onDismiss: () -> Unit,
onConfirm: (Locale?) -> Unit,
getDisplayName: (Locale) -> String
) {
var selectedLocale by remember { mutableStateOf(currentLocale) }
val systemDefaultString = stringResource(R.string.language_system_default)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.language)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedLocale = null },
verticalAlignment = Alignment.CenterVertically
) {
HapticRadioButton(
selected = selectedLocale == null,
onClick = { selectedLocale = null }
)
Text(systemDefaultString)
}
supportedLocales.forEach { locale ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedLocale = locale },
verticalAlignment = Alignment.CenterVertically
) {
HapticRadioButton(
selected = selectedLocale == locale,
onClick = { selectedLocale = locale }
)
Text(getDisplayName(locale))
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(selectedLocale)
onDismiss()
}
) {
Text(stringResource(R.string.apply))
}
}
)
}

View File

@@ -36,6 +36,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -64,6 +65,7 @@ fun ImportExportSettingsScreen(
vm: ImportExportViewModel = koinViewModel()
) {
val context = LocalContext.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope()
var selectorDialog by rememberSaveable { mutableStateOf<(@Composable () -> Unit)?>(null) }
@@ -108,7 +110,7 @@ fun ImportExportSettingsScreen(
vm.viewModelScope.launch {
uiSafe(context, R.string.failed_to_import_keystore, "Failed to import keystore") {
val result = vm.tryKeystoreImport(alias, pass)
if (!result) context.toast(context.getString(R.string.import_keystore_wrong_credentials))
if (!result) context.toast(resources.getString(R.string.import_keystore_wrong_credentials))
}
}
}
@@ -166,7 +168,7 @@ fun ImportExportSettingsScreen(
GroupItem(
onClick = {
if (!vm.canExport()) {
context.toast(context.getString(R.string.export_keystore_unavailable))
context.toast(resources.getString(R.string.export_keystore_unavailable))
return@GroupItem
}
exportKeystoreLauncher.launch("Manager.keystore")

View File

@@ -7,15 +7,18 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppScaffold
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.Scrollbar
import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults
import com.mikepenz.aboutlibraries.ui.compose.libraryColors
import com.mikepenz.aboutlibraries.ui.compose.android.produceLibraries
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.m3.chipColors
import com.mikepenz.aboutlibraries.ui.compose.m3.libraryColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -33,16 +36,23 @@ fun LicensesSettingsScreen(
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
val lazyListState = rememberLazyListState()
val libraries by produceLibraries(R.raw.aboutlibraries)
val chipColors = LibraryDefaults.chipColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
)
LibrariesContainer(
modifier = Modifier
.fillMaxSize(),
libraries = libraries,
lazyListState = lazyListState,
colors = LibraryDefaults.libraryColors(
backgroundColor = MaterialTheme.colorScheme.background,
contentColor = MaterialTheme.colorScheme.onBackground,
badgeBackgroundColor = MaterialTheme.colorScheme.primary,
badgeContentColor = MaterialTheme.colorScheme.onPrimary,
libraryBackgroundColor = MaterialTheme.colorScheme.background,
libraryContentColor = MaterialTheme.colorScheme.onBackground,
versionChipColors = chipColors,
licenseChipColors = chipColors,
fundingChipColors = chipColors,
)
)
Scrollbar(lazyListState = lazyListState, modifier = Modifier.padding(paddingValues))

View File

@@ -12,6 +12,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
@@ -33,6 +34,7 @@ fun UpdatesSettingsScreen(
vm: UpdatesSettingsViewModel = koinViewModel(),
) {
val context = LocalContext.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -57,7 +59,7 @@ fun UpdatesSettingsScreen(
modifier = Modifier.clickable {
coroutineScope.launch {
if (!vm.isConnected) {
context.toast(context.getString(R.string.no_network_toast))
context.toast(resources.getString(R.string.no_network_toast))
return@launch
}
if (vm.checkForUpdates()) onUpdateClick()
@@ -70,7 +72,7 @@ fun UpdatesSettingsScreen(
SettingsListItem(
modifier = Modifier.clickable {
if (!vm.isConnected) {
context.toast(context.getString(R.string.no_network_toast))
context.toast(resources.getString(R.string.no_network_toast))
return@clickable
}
onChangelogClick()

View File

@@ -80,6 +80,7 @@ private val LightColorScheme = lightColorScheme(
fun ReVancedManagerTheme(
darkTheme: Boolean,
dynamicColor: Boolean,
pureBlackTheme: Boolean,
content: @Composable () -> Unit
) {
val colorScheme = when {
@@ -93,6 +94,10 @@ fun ReVancedManagerTheme(
darkTheme -> DarkColorScheme
else -> LightColorScheme
}.let {
if (darkTheme && pureBlackTheme)
it.copy(background = Color.Black, surface = Color.Black)
else it
}
val view = LocalView.current

View File

@@ -0,0 +1,101 @@
package app.revanced.manager.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.AnnouncementRepository
import app.revanced.manager.network.dto.ReVancedAnnouncement
import kotlin.time.Clock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
class AnnouncementsViewModel(
private val announcementRepository: AnnouncementRepository,
private val network: NetworkInfo,
private val preferences: PreferencesManager
) : ViewModel() {
private val allAnnouncements = MutableStateFlow<List<ReVancedAnnouncement>?>(null)
private val _tags = MutableStateFlow<List<String>?>(null)
val tags get() = _tags.asStateFlow()
val selectedTags = preferences.selectedAnnouncementTags
val readAnnouncements = preferences.readAnnouncements
val showArchived = MutableStateFlow(false)
val announcements = combine(
allAnnouncements,
_tags,
selectedTags.flow,
showArchived
) { source, tags, selectedTags, showArchived ->
if (source == null) return@combine null
// Only filter by tags that actually exist
val availableTags = tags.orEmpty().toSet()
val validSelected = selectedTags.intersect(availableTags)
val visibleAnnouncements = if (showArchived) {
source
} else {
source.filter { announcement ->
announcement.archivedAt.toInstant(TimeZone.UTC) > Clock.System.now()
}
}
if (validSelected.isEmpty()) {
visibleAnnouncements
} else {
visibleAnnouncements.filter { announcement ->
announcement.tags.any(validSelected::contains)
}
}
}
init {
loadData()
}
fun markAnnouncementRead(id: Long) {
viewModelScope.launch {
preferences.edit {
preferences.readAnnouncements += id
}
}
}
fun changeTagSelection(tag: String) = viewModelScope.launch {
preferences.edit {
if (tag in selectedTags.value) selectedTags -= tag
else selectedTags += tag
}
}
fun resetTagSelection() = viewModelScope.launch {
selectedTags.update(preferences.selectedAnnouncementTags.default)
}
private fun loadData() {
viewModelScope.launch {
if (!network.isConnected()) {
allAnnouncements.value = emptyList()
_tags.value = emptyList()
return@launch
}
withContext(Dispatchers.IO) {
announcementRepository.getAnnouncements()?.let {
allAnnouncements.value = it
}
announcementRepository.getTags()?.let {
_tags.value = it.map { tag -> tag.name }
}
}
}
}
}

View File

@@ -54,6 +54,7 @@ class BundleListViewModel : ViewModel(), KoinComponent {
patchBundleRepository.update(
*getSelectedSources().filterIsInstance<RemotePatchBundle>().toTypedArray(),
showToast = true,
force = true
)
}
}
@@ -65,7 +66,7 @@ class BundleListViewModel : ViewModel(), KoinComponent {
fun update(src: PatchBundleSource) = viewModelScope.launch {
if (src !is RemotePatchBundle) return@launch
patchBundleRepository.update(src, showToast = true)
patchBundleRepository.update(src, showToast = true, force = true)
}
enum class Event {

View File

@@ -7,7 +7,6 @@ import android.net.Uri
import android.os.Build
import android.os.PowerManager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.getSystemService
@@ -15,26 +14,31 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.AnnouncementRepository
import app.revanced.manager.domain.repository.DownloaderRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAnnouncement
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import kotlin.time.Clock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class DashboardViewModel(
private val app: Application,
private val patchBundleRepository: PatchBundleRepository,
private val downloaderPluginRepository: DownloaderPluginRepository,
private val downloaderRepository: DownloaderRepository,
private val announcementRepository: AnnouncementRepository,
private val reVancedAPI: ReVancedAPI,
private val networkInfo: NetworkInfo,
val prefs: PreferencesManager,
@@ -45,8 +49,8 @@ class DashboardViewModel(
private val contentResolver: ContentResolver = app.contentResolver
private val powerManager = app.getSystemService<PowerManager>()!!
val newDownloaderPluginsAvailable =
downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() }
val newDownloadersAvailable =
downloaderRepository.newDownloaderPackageNames.map { it.isNotEmpty() }
/**
* Android 11 kills the app process after granting the "install apps" permission, which is a problem for the patcher screen.
@@ -61,18 +65,22 @@ class DashboardViewModel(
var showBatteryOptimizationsWarning by mutableStateOf(false)
private set
var unreadAnnouncement by mutableStateOf<ReVancedAnnouncement?>(null)
private set
private val bundleListEventsChannel = Channel<BundleListViewModel.Event>()
val bundleListEventsFlow = bundleListEventsChannel.receiveAsFlow()
init {
viewModelScope.launch {
checkForManagerUpdates()
checkForAnnouncements()
updateBatteryOptimizationsWarning()
}
}
fun ignoreNewDownloaderPlugins() = viewModelScope.launch {
downloaderPluginRepository.acknowledgeAllNewPlugins()
fun ignoreNewDownloaders() = viewModelScope.launch {
downloaderRepository.acknowledgeAll()
}
private suspend fun checkForManagerUpdates() {
@@ -83,6 +91,44 @@ class DashboardViewModel(
}
}
private suspend fun checkForAnnouncements() {
uiSafe(app, R.string.failed_to_check_updates, "Failed to check for announcements") {
val announcements = withContext(Dispatchers.IO) {
announcementRepository.getAnnouncements()
} ?: throw IllegalStateException("Announcements could not be retrieved")
val readAnnouncements = prefs.readAnnouncements.get()
if (readAnnouncements.isEmpty()) {
val announcementIds = announcements.mapTo(mutableSetOf()) { it.id }
prefs.readAnnouncements.update(announcementIds)
return@uiSafe
}
unreadAnnouncement = announcements.firstOrNull { announcement ->
val isNotArchived =
announcement.archivedAt.toInstant(TimeZone.UTC) > Clock.System.now()
val hasRelevantTag = "revanced" in announcement.tags ||
"manager" in announcement.tags
val isUnread = announcement.id !in readAnnouncements
isNotArchived && hasRelevantTag && isUnread
}
}
}
fun markUnreadAnnouncementRead() {
viewModelScope.launch {
unreadAnnouncement?.let {
prefs.edit {
prefs.readAnnouncements += it.id
}
}
unreadAnnouncement = null
}
}
fun updateBatteryOptimizationsWarning() {
showBatteryOptimizationsWarning =
!powerManager.isIgnoringBatteryOptimizations(app.packageName)

View File

@@ -1,6 +1,5 @@
package app.revanced.manager.ui.viewmodel
import android.content.pm.PackageInfo
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -8,7 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.DownloaderRepository
import app.revanced.manager.util.PM
import app.revanced.manager.util.mutableStateSetOf
import kotlinx.coroutines.Dispatchers
@@ -19,10 +18,10 @@ import kotlinx.coroutines.withContext
class DownloadsViewModel(
private val downloadedAppRepository: DownloadedAppRepository,
private val downloaderPluginRepository: DownloaderPluginRepository,
private val downloaderRepository: DownloaderRepository,
val pm: PM
) : ViewModel() {
val downloaderPluginStates = downloaderPluginRepository.pluginStates
val downloaderStates = downloaderRepository.downloaderPackageStates
val downloadedApps = downloadedAppRepository.getAll().map { downloadedApps ->
downloadedApps.sortedWith(
compareBy<DownloadedApp> {
@@ -32,7 +31,7 @@ class DownloadsViewModel(
}
val appSelection = mutableStateSetOf<DownloadedApp>()
var isRefreshingPlugins by mutableStateOf(false)
var isRefreshingDownloaders by mutableStateOf(false)
private set
fun toggleApp(downloadedApp: DownloadedApp) {
@@ -52,17 +51,17 @@ class DownloadsViewModel(
}
}
fun refreshPlugins() = viewModelScope.launch {
isRefreshingPlugins = true
downloaderPluginRepository.reload()
isRefreshingPlugins = false
fun refreshDownloaders() = viewModelScope.launch {
isRefreshingDownloaders = true
downloaderRepository.reload()
isRefreshingDownloaders = false
}
fun trustPlugin(packageName: String) = viewModelScope.launch {
downloaderPluginRepository.trustPackage(packageName)
fun trustDownloader(packageName: String) = viewModelScope.launch {
downloaderRepository.trustPackage(packageName)
}
fun revokePluginTrust(packageName: String) = viewModelScope.launch {
downloaderPluginRepository.revokeTrustForPackage(packageName)
fun revokeDownloaderTrust(packageName: String) = viewModelScope.launch {
downloaderRepository.revokeTrustForPackage(packageName)
}
}

View File

@@ -1,17 +1,26 @@
package app.revanced.manager.ui.viewmodel
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.util.SupportedLocales
import app.revanced.manager.util.resetListItemColorsCached
import kotlinx.coroutines.launch
import java.util.Locale
class GeneralSettingsViewModel(
private val app: Application,
val prefs: PreferencesManager
) : ViewModel() {
fun setTheme(theme: Theme) = viewModelScope.launch {
prefs.theme.update(theme)
resetListItemColorsCached()
}
fun getSupportedLocales() = SupportedLocales.getSupportedLocales(app)
fun getCurrentLocale() = SupportedLocales.getCurrentLocale()
fun setLocale(locale: Locale?) = SupportedLocales.setLocale(locale)
fun getLocaleDisplayName(locale: Locale) = SupportedLocales.getDisplayName(locale)
}

View File

@@ -36,8 +36,8 @@ import kotlin.io.path.deleteExisting
import kotlin.io.path.inputStream
sealed class ResetDialogState(
@StringRes val titleResId: Int,
@StringRes val descriptionResId: Int,
@param:StringRes val titleResId: Int,
@param:StringRes val descriptionResId: Int,
val onConfirm: () -> Unit,
val dialogOptionName: String? = null
) {

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