mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2026-03-13 08:41:57 +08:00
Compare commits
31 Commits
v1.26.0-de
...
v1.26.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e03f3666c8 | ||
|
|
323d2170ef | ||
|
|
940669b3e4 | ||
|
|
81e5a9315d | ||
|
|
b38d0b7022 | ||
|
|
b838851ebe | ||
|
|
00b3c59572 | ||
|
|
fc30ba5e83 | ||
|
|
32228404c4 | ||
|
|
f19080a986 | ||
|
|
813df46847 | ||
|
|
6f4219c01b | ||
|
|
8667051283 | ||
|
|
e0ce6b4ef3 | ||
|
|
b50688ee10 | ||
|
|
31a0f751aa | ||
|
|
4f65555b56 | ||
|
|
4fc2ba339b | ||
|
|
76d0f17770 | ||
|
|
de1d167a4b | ||
|
|
0f56377214 | ||
|
|
7615453eec | ||
|
|
4c0b6b02e9 | ||
|
|
fe84b22b6f | ||
|
|
1b21f5d4ab | ||
|
|
72b1db9a2f | ||
|
|
2805ac6540 | ||
|
|
b16931ca79 | ||
|
|
dfeca09d00 | ||
|
|
44c06e2197 | ||
|
|
df31b39cc8 |
43
.github/workflows/pull_strings.yml
vendored
Normal file
43
.github/workflows/pull_strings.yml
vendored
Normal 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
26
.github/workflows/push_strings.yml
vendored
Normal 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 }}
|
||||
@@ -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
13
api/CHANGELOG.md
Normal 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))
|
||||
110
api/api/api.api
110
api/api/api.api
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
1
api/gradle.properties
Normal file
@@ -0,0 +1 @@
|
||||
version = 1.0.0-dev.2
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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 {
|
||||
@@ -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"
|
||||
@@ -1,3 +1,81 @@
|
||||
# app [1.26.0-dev.26](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.25...v1.26.0-dev.26) (2026-02-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve InstalledAppsScreen caching/perf ([#2963](https://github.com/ReVanced/revanced-manager/issues/2963)) ([323d217](https://github.com/ReVanced/revanced-manager/commit/323d2170efa0e3f2c8d2245ff08a2e92c52cb9d7))
|
||||
|
||||
# app [1.26.0-dev.25](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.24...v1.26.0-dev.25) (2026-02-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve AppSelectorScreen caching/perf ([#2961](https://github.com/ReVanced/revanced-manager/issues/2961)) ([81e5a93](https://github.com/ReVanced/revanced-manager/commit/81e5a9315db4c858fa29163d15457af97163967a))
|
||||
|
||||
# app [1.26.0-dev.24](https://github.com/ReVanced/revanced-manager/compare/v1.26.0-dev.23...v1.26.0-dev.24) (2026-02-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Incorrect patcher string value ([f19080a](https://github.com/ReVanced/revanced-manager/commit/f19080a9862be4600860b67cd981b9a3a56b21e7))
|
||||
* Never show again toggle doing nothing for Update dialog ([6f4219c](https://github.com/ReVanced/revanced-manager/commit/6f4219c01ba43b70e130f210c8d48978a6056f68))
|
||||
* Release builds not working ([00b3c59](https://github.com/ReVanced/revanced-manager/commit/00b3c59572760993f69f2663456237faafff0283))
|
||||
* Remove quotes from strings ([fc30ba5](https://github.com/ReVanced/revanced-manager/commit/fc30ba5e83f27329b05dc81f484bb8f7471b6a99))
|
||||
* we actually don't want to exclude reflection symbols ([3222840](https://github.com/ReVanced/revanced-manager/commit/32228404c4ef38bb8931d5cfdf6a7aefde23a89c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add announcements ([#2948](https://github.com/ReVanced/revanced-manager/issues/2948)) ([813df46](https://github.com/ReVanced/revanced-manager/commit/813df46847620bd854dccc52ff244388b3385e17))
|
||||
* Update to Patcher v22 ([#2939](https://github.com/ReVanced/revanced-manager/issues/2939)) ([8667051](https://github.com/ReVanced/revanced-manager/commit/8667051283f934a32ac7b7cc76178397dc45a0e6))
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -126,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")
|
||||
}
|
||||
@@ -200,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 {
|
||||
@@ -243,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 {
|
||||
|
||||
@@ -1 +1 @@
|
||||
version = 1.26.0-dev.17
|
||||
version = 1.26.0-dev.26
|
||||
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@@ -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
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,7 +49,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".plugin.downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
|
||||
<activity android:name=".downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
|
||||
@@ -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,21 +76,10 @@ 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()
|
||||
val pureBlackTheme by vm.prefs.pureBlackTheme.getAsState()
|
||||
|
||||
EventEffect(vm.legacyImportActivityFlow) {
|
||||
try {
|
||||
launcher.launch(it)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
}
|
||||
}
|
||||
|
||||
ReVancedManagerTheme(
|
||||
darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK,
|
||||
dynamicColor = dynamicColor,
|
||||
@@ -118,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(
|
||||
@@ -129,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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -179,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)
|
||||
}
|
||||
|
||||
@@ -226,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)
|
||||
)
|
||||
|
||||
@@ -243,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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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() {
|
||||
@@ -70,7 +70,7 @@ class ManagerApplication : Application() {
|
||||
prefs.preload()
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
downloaderPluginRepository.reload()
|
||||
downloaderRepository.reload()
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
with(patchBundleRepository) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>)
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,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)
|
||||
@@ -31,7 +33,7 @@ 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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) =
|
||||
@@ -313,9 +308,9 @@ class PatchBundleRepository(
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)!!
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
package app.revanced.manager.network.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ReVancedAnnouncementTag(
|
||||
val name: String
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,66 +4,71 @@ import app.revanced.library.ApkUtils.applyTo
|
||||
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.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 frameworkDir: String,
|
||||
private val aaptPath: String,
|
||||
private val logger: Logger,
|
||||
private val input: File,
|
||||
private val onEvent: (ProgressEvent) -> Unit,
|
||||
) : Closeable {
|
||||
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) {
|
||||
this().collect { (patch, exception) ->
|
||||
val index = selectedPatches.indexOf(patch)
|
||||
if (index == -1) return@collect
|
||||
|
||||
if (exception != null) {
|
||||
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()
|
||||
|
||||
val index = indices[patch] ?: return@patcher
|
||||
|
||||
if (exception != null) {
|
||||
onEvent(
|
||||
ProgressEvent.Failed(
|
||||
StepId.ExecutePatch(index),
|
||||
exception.toRemoteError(),
|
||||
)
|
||||
)
|
||||
logger.error("${patch.name} failed:")
|
||||
logger.error(exception.stackTraceToString())
|
||||
throw exception
|
||||
}
|
||||
|
||||
onEvent(
|
||||
ProgressEvent.Failed(
|
||||
ProgressEvent.Completed(
|
||||
StepId.ExecutePatch(index),
|
||||
exception.toRemoteError(),
|
||||
)
|
||||
)
|
||||
logger.error("${patch.name} failed:")
|
||||
logger.error(exception.stackTraceToString())
|
||||
throw exception
|
||||
|
||||
logger.info("${patch.name} succeeded")
|
||||
}
|
||||
|
||||
onEvent(
|
||||
ProgressEvent.Completed(
|
||||
StepId.ExecutePatch(index),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info("${patch.name} succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun run(output: File, selectedPatches: PatchList) {
|
||||
runStep(StepId.ExecutePatches, onEvent) {
|
||||
val indices = HashMap<Patch, Int>(selectedPatches.size)
|
||||
selectedPatches.forEachIndexed { idx, patch -> indices[patch] = idx }
|
||||
|
||||
val result = runStep(StepId.ExecutePatches, onEvent) {
|
||||
java.util.logging.Logger.getLogger("").apply {
|
||||
handlers.forEach {
|
||||
it.close()
|
||||
@@ -73,18 +78,21 @@ class Session(
|
||||
addHandler(logger.handler)
|
||||
}
|
||||
|
||||
with(patcher) {
|
||||
logger.info("Merging integrations")
|
||||
this += selectedPatches.toSet()
|
||||
|
||||
logger.info("Applying patches...")
|
||||
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
|
||||
val patcher = patcher(
|
||||
apkFile = input,
|
||||
temporaryFilesPath = tempDir,
|
||||
frameworkFileDirectory = frameworkDir,
|
||||
aaptBinaryPath = File(aaptPath)
|
||||
) { _packageName, _version ->
|
||||
selectedPatches.toSet()
|
||||
}
|
||||
|
||||
logger.info("Applying patches...")
|
||||
applyPatchesVerbose(patcher, indices)
|
||||
}
|
||||
|
||||
runStep(StepId.WriteAPK, onEvent) {
|
||||
logger.info("Writing patched files...")
|
||||
val result = patcher.get()
|
||||
|
||||
val patched = tempDir.resolve("result.apk")
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -102,7 +110,6 @@ class Session(
|
||||
|
||||
override fun close() {
|
||||
tempDir.deleteRecursively()
|
||||
patcher.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,11 +24,11 @@ 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
|
||||
@@ -36,9 +36,9 @@ import app.revanced.manager.patcher.runStep
|
||||
import app.revanced.manager.patcher.runtime.CoroutineRuntime
|
||||
import app.revanced.manager.patcher.runtime.ProcessRuntime
|
||||
import app.revanced.manager.patcher.toRemoteError
|
||||
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.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.util.Options
|
||||
import app.revanced.manager.util.PM
|
||||
@@ -51,7 +51,7 @@ import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
@OptIn(PluginHostApi::class)
|
||||
@OptIn(DownloaderHostApi::class)
|
||||
class PatcherWorker(
|
||||
context: Context,
|
||||
parameters: WorkerParameters
|
||||
@@ -59,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()
|
||||
@@ -72,7 +72,7 @@ class PatcherWorker(
|
||||
val selectedPatches: PatchSelection,
|
||||
val options: Options,
|
||||
val logger: Logger,
|
||||
val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
|
||||
val handleStartActivityRequest: suspend (LoadedDownloader, Intent) -> ActivityResult,
|
||||
val setInputFile: suspend (File) -> Unit,
|
||||
val onEvent: (ProgressEvent) -> Unit,
|
||||
) {
|
||||
@@ -150,9 +150,9 @@ 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,
|
||||
@@ -172,27 +172,27 @@ class PatcherWorker(
|
||||
val inputFile = when (val selectedApp = args.input) {
|
||||
is SelectedApp.Download -> {
|
||||
runStep(StepId.DownloadAPK, args.onEvent) {
|
||||
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(
|
||||
val (downloader, data) = downloaderRepository.unwrapParceledData(
|
||||
selectedApp.data
|
||||
)
|
||||
|
||||
download(plugin, data)
|
||||
download(downloader, data)
|
||||
}
|
||||
}
|
||||
|
||||
is SelectedApp.Search -> {
|
||||
runStep(StepId.DownloadAPK, args.onEvent) {
|
||||
downloaderPluginRepository.loadedPluginsFlow.first()
|
||||
.firstNotNullOfOrNull { plugin ->
|
||||
downloaderRepository.loadedDownloadersFlow.first()
|
||||
.firstNotNullOfOrNull { downloader ->
|
||||
try {
|
||||
val getScope = object : GetScope {
|
||||
override val pluginPackageName = plugin.packageName
|
||||
override val downloaderPackageName = downloader.packageName
|
||||
override val hostPackageName =
|
||||
applicationContext.packageName
|
||||
|
||||
override suspend fun requestStartActivity(intent: Intent): Intent? {
|
||||
val result =
|
||||
args.handleStartActivityRequest(plugin, intent)
|
||||
args.handleStartActivityRequest(downloader, intent)
|
||||
return when (result.resultCode) {
|
||||
Activity.RESULT_OK -> result.data
|
||||
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
|
||||
@@ -204,7 +204,7 @@ class PatcherWorker(
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
plugin.get(
|
||||
downloader.get(
|
||||
getScope,
|
||||
selectedApp.packageName,
|
||||
selectedApp.version
|
||||
@@ -214,7 +214,7 @@ class PatcherWorker(
|
||||
throw e
|
||||
} catch (_: UserInteractionException) {
|
||||
null
|
||||
}?.let { (data, _) -> download(plugin, data) }
|
||||
}?.let { (data, _) -> download(downloader, data) }
|
||||
} ?: throw Exception("App is not available.")
|
||||
}
|
||||
}
|
||||
@@ -250,11 +250,21 @@ class PatcherWorker(
|
||||
tag,
|
||||
"An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()
|
||||
)
|
||||
args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step
|
||||
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)
|
||||
args.onEvent(ProgressEvent.Failed(null, e.toRemoteError())) // Fallback if exception doesn't occur within step
|
||||
args.onEvent(
|
||||
ProgressEvent.Failed(
|
||||
null,
|
||||
e.toRemoteError()
|
||||
)
|
||||
) // Fallback if exception doesn't occur within step
|
||||
Result.failure()
|
||||
} finally {
|
||||
patchedApk.delete()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,7 +6,7 @@ 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)"
|
||||
//}
|
||||
@@ -24,9 +24,9 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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
|
||||
@@ -68,20 +68,11 @@ fun AppSelectorScreen(
|
||||
uri?.let(vm::handleStorageResult)
|
||||
}
|
||||
|
||||
val suggestedVersions by vm.suggestedAppVersions.collectAsStateWithLifecycle(emptyMap())
|
||||
val suggestedVersions by vm.suggestedAppVersions.collectAsStateWithLifecycle()
|
||||
|
||||
var filterText by rememberSaveable { mutableStateOf("") }
|
||||
var search by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val appList by vm.appList.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val filteredAppList = remember(appList, filterText) {
|
||||
appList.filter { app ->
|
||||
(vm.loadLabel(app.packageInfo)).contains(
|
||||
filterText,
|
||||
true
|
||||
) or app.packageName.contains(filterText, true)
|
||||
}
|
||||
}
|
||||
val appList by vm.apps.collectAsStateWithLifecycle()
|
||||
val appsListFiltered by vm.filteredApps.collectAsStateWithLifecycle()
|
||||
|
||||
vm.nonSuggestedVersionDialogSubject?.let {
|
||||
NonSuggestedVersionDialog(
|
||||
@@ -90,17 +81,20 @@ fun AppSelectorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (search)
|
||||
if (search) {
|
||||
val filterText by vm.filterText.collectAsState()
|
||||
|
||||
SearchView(
|
||||
query = filterText,
|
||||
onQueryChange = { filterText = it },
|
||||
onQueryChange = vm::setFilterText,
|
||||
onActiveChange = { search = it },
|
||||
placeholder = { Text(stringResource(R.string.search_apps)) }
|
||||
) {
|
||||
if (appList.isNotEmpty() && filterText.isNotEmpty()) {
|
||||
val appsFiltered = appsListFiltered
|
||||
if (!appsFiltered.isNullOrEmpty() && filterText.isNotEmpty()) {
|
||||
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
|
||||
items(
|
||||
items = filteredAppList,
|
||||
items = appsFiltered,
|
||||
key = { it.packageName }
|
||||
) { app ->
|
||||
ListItem(
|
||||
@@ -109,9 +103,9 @@ fun AppSelectorScreen(
|
||||
},
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
app.packageInfo,
|
||||
null,
|
||||
Modifier.size(36.dp)
|
||||
packageInfo = app.packageInfo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = { AppLabel(app.packageInfo) },
|
||||
@@ -152,6 +146,7 @@ fun AppSelectorScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
|
||||
@@ -198,16 +193,33 @@ fun AppSelectorScreen(
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (appList.isNotEmpty()) {
|
||||
val apps = appList
|
||||
if (apps == null) {
|
||||
item(key = "LOADING") {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
}
|
||||
} else if (apps.isNotEmpty()) {
|
||||
items(
|
||||
items = appList,
|
||||
key = { it.packageName }
|
||||
items = apps,
|
||||
key = { "APP-" + it.packageName },
|
||||
contentType = { "APP" },
|
||||
) { app ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
onSelect(app.packageName)
|
||||
},
|
||||
leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
packageInfo = app.packageInfo,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = {
|
||||
AppLabel(
|
||||
app.packageInfo,
|
||||
@@ -231,15 +243,6 @@ fun AppSelectorScreen(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -30,7 +31,7 @@ fun InstalledAppsScreen(
|
||||
onAppClick: (InstalledApp) -> Unit,
|
||||
viewModel: InstalledAppsViewModel = koinViewModel()
|
||||
) {
|
||||
val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null)
|
||||
val installedApps by viewModel.apps.collectAsStateWithLifecycle()
|
||||
|
||||
Column {
|
||||
LazyColumnWithScrollbar(
|
||||
@@ -38,38 +39,40 @@ fun InstalledAppsScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top,
|
||||
) {
|
||||
installedApps?.let { installedApps ->
|
||||
if (installedApps.isNotEmpty()) {
|
||||
items(
|
||||
installedApps,
|
||||
key = { it.currentPackageName }
|
||||
) { installedApp ->
|
||||
viewModel.packageInfoMap[installedApp.currentPackageName].let { packageInfo ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onAppClick(installedApp) },
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
packageInfo,
|
||||
contentDescription = null,
|
||||
Modifier.size(36.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = { AppLabel(packageInfo, defaultText = null) },
|
||||
supportingContent = { Text(installedApp.currentPackageName) }
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.no_patched_apps_found),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
val apps = installedApps
|
||||
if (apps == null) {
|
||||
item(key = "LOADING") {
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else if (apps.isNotEmpty()) {
|
||||
items(
|
||||
apps,
|
||||
key = { "APP-" + it.currentPackageName },
|
||||
contentType = { "APP" },
|
||||
) { installedApp ->
|
||||
val packageInfo = viewModel.packageInfoMap[installedApp.currentPackageName]
|
||||
|
||||
} ?: item { LoadingIndicator() }
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onAppClick(installedApp) },
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
packageInfo,
|
||||
contentDescription = null,
|
||||
Modifier.size(36.dp)
|
||||
)
|
||||
},
|
||||
headlineContent = { AppLabel(packageInfo, defaultText = null) },
|
||||
supportingContent = { Text(installedApp.currentPackageName) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item(key = "NONE") {
|
||||
Text(
|
||||
text = stringResource(R.string.no_patched_apps_found),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -69,6 +70,7 @@ fun PatcherScreen(
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val exportApkLauncher =
|
||||
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), viewModel::export)
|
||||
|
||||
@@ -79,7 +81,7 @@ fun PatcherScreen(
|
||||
|
||||
fun onPageBack() = when {
|
||||
patcherSucceeded == null -> showDismissConfirmationDialog = true
|
||||
viewModel.isInstalling -> context.toast(context.getString(R.string.patcher_install_in_progress))
|
||||
viewModel.isInstalling -> context.toast(resources.getString(R.string.patcher_install_in_progress))
|
||||
else -> onLeave()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -32,6 +32,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 androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -39,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
|
||||
@@ -68,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() }
|
||||
@@ -86,7 +88,7 @@ fun SelectedAppInfoScreen(
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
onResult = vm::handlePluginActivityResult
|
||||
onResult = vm::handleDownloaderActivityResult
|
||||
)
|
||||
EventEffect(flow = vm.launchActivityFlow) { intent ->
|
||||
launcher.launch(intent)
|
||||
@@ -118,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
|
||||
}
|
||||
@@ -140,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
|
||||
@@ -201,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)
|
||||
@@ -279,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
|
||||
@@ -303,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)
|
||||
@@ -349,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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -39,7 +39,7 @@ 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
|
||||
@@ -60,7 +60,7 @@ fun DownloadsSettingsScreen(
|
||||
) {
|
||||
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) }
|
||||
|
||||
@@ -68,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
|
||||
)
|
||||
}
|
||||
@@ -92,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)
|
||||
@@ -135,39 +136,39 @@ fun DownloadsSettingsScreen(
|
||||
}
|
||||
|
||||
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
|
||||
),
|
||||
pluginName = appName,
|
||||
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
|
||||
R.string.downloader_trust_dialog_body
|
||||
),
|
||||
pluginName = appName,
|
||||
downloaderName = appName,
|
||||
signature = signature,
|
||||
onDismiss = ::dismiss,
|
||||
onConfirm = {
|
||||
viewModel.trustPlugin(packageName)
|
||||
viewModel.trustDownloader(packageName)
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
@@ -182,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
|
||||
)
|
||||
@@ -240,7 +244,7 @@ fun DownloadsSettingsScreen(
|
||||
private fun TrustDialog(
|
||||
@StringRes title: Int,
|
||||
body: String,
|
||||
pluginName: String,
|
||||
downloaderName: String,
|
||||
signature: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
@@ -268,8 +272,8 @@ private fun TrustDialog(
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.downloader_plugin_trust_dialog_plugin,
|
||||
pluginName
|
||||
R.string.downloader_trust_dialog_name,
|
||||
downloaderName
|
||||
),
|
||||
)
|
||||
OutlinedCard(
|
||||
@@ -279,7 +283,7 @@ private fun TrustDialog(
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.downloader_plugin_trust_dialog_signature,
|
||||
R.string.downloader_trust_dialog_signature,
|
||||
signature.chunked(2).joinToString(" ")
|
||||
), modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ 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
|
||||
@@ -19,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
|
||||
@@ -38,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
|
||||
@@ -48,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(
|
||||
@@ -55,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(
|
||||
@@ -74,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 },
|
||||
@@ -156,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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,12 @@ import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.toast
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
@@ -40,16 +44,51 @@ class AppSelectorViewModel(
|
||||
"input.apk"
|
||||
).also(File::delete)
|
||||
}
|
||||
val appList = pm.appList
|
||||
|
||||
val filterTextFlow = MutableStateFlow("")
|
||||
val filterText: StateFlow<String> = filterTextFlow
|
||||
|
||||
val apps = pm.appList.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
val filteredApps = filterText
|
||||
.combine(apps) { filter, apps ->
|
||||
if (apps == null || filter.isBlank()) {
|
||||
apps
|
||||
} else {
|
||||
apps.filter { app ->
|
||||
app.packageName.contains(filter, true) ||
|
||||
loadLabel(app.packageInfo).contains(filter)
|
||||
}
|
||||
}
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(
|
||||
stopTimeoutMillis = 0,
|
||||
replayExpirationMillis = 10_000,
|
||||
),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
private val storageSelectionChannel = Channel<SelectedApp.Local>()
|
||||
val storageSelectionFlow = storageSelectionChannel.receiveAsFlow()
|
||||
|
||||
val suggestedAppVersions = patchBundleRepository.suggestedVersions.flowOn(Dispatchers.Default)
|
||||
val suggestedAppVersions = patchBundleRepository.suggestedVersions.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = emptyMap(),
|
||||
)
|
||||
|
||||
var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp.Local?>(null)
|
||||
private set
|
||||
|
||||
fun setFilterText(filter: String) {
|
||||
filterTextFlow.value = filter
|
||||
}
|
||||
|
||||
fun loadLabel(app: PackageInfo?) = with(pm) { app?.label() ?: "Not installed" }
|
||||
|
||||
fun dismissNonSuggestedVersionDialog() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -5,13 +5,16 @@ import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.revanced.manager.data.room.apps.installed.InstallType
|
||||
import app.revanced.manager.domain.installer.RootServiceException
|
||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||
import app.revanced.manager.domain.installer.RootInstaller
|
||||
import app.revanced.manager.domain.installer.RootServiceException
|
||||
import app.revanced.manager.domain.repository.InstalledAppRepository
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.collectEach
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -20,32 +23,42 @@ class InstalledAppsViewModel(
|
||||
private val pm: PM,
|
||||
private val rootInstaller: RootInstaller
|
||||
) : ViewModel() {
|
||||
val apps = installedAppsRepository.getAll().flowOn(Dispatchers.IO)
|
||||
val apps = installedAppsRepository.getAll()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
val packageInfoMap = mutableStateMapOf<String, PackageInfo?>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
apps.collectEach { installedApp ->
|
||||
packageInfoMap[installedApp.currentPackageName] = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (
|
||||
installedApp.installType == InstallType.MOUNT && !rootInstaller.isAppInstalled(installedApp.currentPackageName)
|
||||
) {
|
||||
installedAppsRepository.delete(installedApp)
|
||||
return@withContext null
|
||||
}
|
||||
} catch (_: RootServiceException) { }
|
||||
apps.filterNotNull().collectLatest(::fetchPackageInfos)
|
||||
}
|
||||
}
|
||||
|
||||
val packageInfo = pm.getPackageInfo(installedApp.currentPackageName)
|
||||
|
||||
if (packageInfo == null && installedApp.installType != InstallType.MOUNT) {
|
||||
installedAppsRepository.delete(installedApp)
|
||||
private suspend fun fetchPackageInfos(apps: List<InstalledApp>) {
|
||||
for (app in apps) {
|
||||
packageInfoMap[app.currentPackageName] = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (app.installType == InstallType.MOUNT &&
|
||||
!rootInstaller.isAppInstalled(app.currentPackageName)
|
||||
) {
|
||||
installedAppsRepository.delete(app)
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
packageInfo
|
||||
} catch (_: RootServiceException) {
|
||||
}
|
||||
|
||||
val packageInfo = pm.getPackageInfo(app.currentPackageName)
|
||||
|
||||
if (packageInfo == null && app.installType != InstallType.MOUNT) {
|
||||
installedAppsRepository.delete(app)
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
packageInfo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
package app.revanced.manager.ui.viewmodel
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.data.room.apps.installed.InstallType
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
|
||||
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.InstalledAppRepository
|
||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||
import app.revanced.manager.domain.repository.PatchSelectionRepository
|
||||
import app.revanced.manager.domain.repository.SerializedSelection
|
||||
@@ -27,11 +28,16 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
private const val LEGACY_LIST_PREFIX = "VGhpcyBpcyB0aGUgcHJlZml4IGZvciBhIGxpc3Qu!"
|
||||
|
||||
class MainViewModel(
|
||||
private val patchBundleRepository: PatchBundleRepository,
|
||||
private val patchSelectionRepository: PatchSelectionRepository,
|
||||
private val downloadedAppRepository: DownloadedAppRepository,
|
||||
private val installedAppRepository: InstalledAppRepository,
|
||||
private val keystoreManager: KeystoreManager,
|
||||
private val app: Application,
|
||||
val prefs: PreferencesManager,
|
||||
@@ -39,8 +45,6 @@ class MainViewModel(
|
||||
) : ViewModel() {
|
||||
private val appSelectChannel = Channel<SelectedApp>()
|
||||
val appSelectFlow = appSelectChannel.receiveAsFlow()
|
||||
private val legacyImportActivityChannel = Channel<Intent>()
|
||||
val legacyImportActivityFlow = legacyImportActivityChannel.receiveAsFlow()
|
||||
|
||||
private suspend fun suggestedVersion(packageName: String) =
|
||||
patchBundleRepository.suggestedVersions.first()[packageName]
|
||||
@@ -72,43 +76,55 @@ class MainViewModel(
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
if (!prefs.firstLaunch.get()) return@launch
|
||||
legacyImportActivityChannel.send(Intent().apply {
|
||||
setClassName(
|
||||
"app.revanced.manager.flutter",
|
||||
"app.revanced.manager.flutter.ExportSettingsActivity"
|
||||
)
|
||||
})
|
||||
val flutterPrefs = app.getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
if (flutterPrefs.all.isNotEmpty()) applyLegacySettings(flutterPrefs)
|
||||
}
|
||||
}
|
||||
|
||||
fun applyLegacySettings(result: ActivityResult) {
|
||||
if (result.resultCode != Activity.RESULT_OK) {
|
||||
app.toast(app.getString(R.string.legacy_import_failed))
|
||||
Log.e(
|
||||
tag,
|
||||
"Got unknown result code while importing legacy settings: ${result.resultCode}"
|
||||
)
|
||||
return
|
||||
fun applyLegacySettings(flutterPrefs: SharedPreferences) {
|
||||
Log.d(tag, "Migrating flutter preferences")
|
||||
val data = JSONObject().apply {
|
||||
put("keystorePassword", "s3cur3p@ssw0rd")
|
||||
|
||||
val allEntries: Map<String, *> = flutterPrefs.all
|
||||
for ((key, value) in allEntries) {
|
||||
put(key.replace("flutter.", ""), value)
|
||||
}
|
||||
}
|
||||
|
||||
val jsonStr = result.data?.getStringExtra("data")
|
||||
if (jsonStr == null) {
|
||||
app.toast(app.getString(R.string.legacy_import_failed))
|
||||
Log.e(tag, "Legacy settings data is null")
|
||||
return
|
||||
val storedPatchesFile = File(app.filesDir.parentFile.absolutePath, "/app_flutter/selected-patches.json")
|
||||
val patches: SerializedSelection? =
|
||||
if (storedPatchesFile.exists()) {
|
||||
json.decodeFromString<SerializedSelection>(storedPatchesFile.readText())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val keystoreFile = File(app.getExternalFilesDir(null), "/revanced-manager.keystore")
|
||||
val keystore: ByteArray? = if (keystoreFile.exists()) {
|
||||
val bytes = keystoreFile.readBytes()
|
||||
|
||||
keystoreFile.delete()
|
||||
|
||||
bytes
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
flutterPrefs.edit(commit = true) { clear() }
|
||||
|
||||
val settings = try {
|
||||
json.decodeFromString<LegacySettings>(jsonStr)
|
||||
json.decodeFromString<LegacySettings>(data.toString())
|
||||
} catch (e: SerializationException) {
|
||||
app.toast(app.getString(R.string.legacy_import_failed))
|
||||
Log.e(tag, "Legacy settings data could not be deserialized", e)
|
||||
return
|
||||
}
|
||||
|
||||
applyLegacySettings(settings)
|
||||
applyLegacySettings(settings, patches, keystore)
|
||||
}
|
||||
|
||||
private fun applyLegacySettings(settings: LegacySettings) = viewModelScope.launch {
|
||||
private fun applyLegacySettings(settings: LegacySettings, patches: SerializedSelection?, keystore: ByteArray?) = viewModelScope.launch {
|
||||
settings.themeMode?.let { theme ->
|
||||
val themeMap = mapOf(
|
||||
0 to Theme.SYSTEM,
|
||||
@@ -144,20 +160,39 @@ class MainViewModel(
|
||||
settings.patchesChangeEnabled?.let { disableSelectionWarning ->
|
||||
prefs.disableSelectionWarning.update(disableSelectionWarning)
|
||||
}
|
||||
settings.keystore?.let { keystore ->
|
||||
val keystoreBytes = Base64.decode(keystore, Base64.DEFAULT)
|
||||
keystore?.let { keystoreBytes ->
|
||||
keystoreManager.import(
|
||||
"alias",
|
||||
settings.keystorePassword,
|
||||
keystoreBytes.inputStream()
|
||||
)
|
||||
}
|
||||
settings.patches?.let { selection ->
|
||||
patches?.let { selection ->
|
||||
patchSelectionRepository.import(0, selection)
|
||||
}
|
||||
settings.patchedApps?.let { apps ->
|
||||
json.decodeFromString<List<String>>(apps.removePrefix(LEGACY_LIST_PREFIX)).forEach { appJson ->
|
||||
val patchedApp = json.decodeFromString<LegacyPatchedApp>(appJson)
|
||||
installedAppRepository.addOrUpdate(
|
||||
patchedApp.packageName,
|
||||
patchedApp.packageName,
|
||||
patchedApp.version,
|
||||
if (patchedApp.isRooted) InstallType.MOUNT else InstallType.DEFAULT,
|
||||
mapOf(0 to patchedApp.appliedPatches.toSet())
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d(tag, "Imported legacy settings")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class LegacyPatchedApp(
|
||||
val packageName: String,
|
||||
val version: String,
|
||||
val isRooted: Boolean,
|
||||
val appliedPatches: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class LegacySettings(
|
||||
val keystorePassword: String,
|
||||
@@ -168,7 +203,6 @@ class MainViewModel(
|
||||
val experimentalPatchesEnabled: Boolean? = null,
|
||||
val patchesAutoUpdate: Boolean? = null,
|
||||
val patchesChangeEnabled: Boolean? = null,
|
||||
val keystore: String? = null,
|
||||
val patches: SerializedSelection? = null,
|
||||
val patchedApps: String? = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ 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.worker.PatcherWorker
|
||||
import app.revanced.manager.plugin.downloader.PluginHostApi
|
||||
import app.revanced.manager.plugin.downloader.UserInteractionException
|
||||
import app.revanced.manager.downloader.DownloaderHostApi
|
||||
import app.revanced.manager.downloader.UserInteractionException
|
||||
import app.revanced.manager.ui.model.InstallerModel
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.ui.model.State
|
||||
@@ -79,7 +79,7 @@ import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.time.Duration
|
||||
|
||||
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
|
||||
@OptIn(SavedStateHandleSaveableApi::class, DownloaderHostApi::class)
|
||||
class PatcherViewModel(
|
||||
private val input: Patcher.ViewModelParams
|
||||
) : ViewModel(), KoinComponent, InstallerModel {
|
||||
@@ -184,13 +184,13 @@ class PatcherViewModel(
|
||||
input.options,
|
||||
logger,
|
||||
setInputFile = { withContext(Dispatchers.Main) { inputFile = it } },
|
||||
handleStartActivityRequest = { plugin, intent ->
|
||||
handleStartActivityRequest = { downloader, intent ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (currentActivityRequest != null) throw Exception("Another request is already pending.")
|
||||
try {
|
||||
// Wait for the dialog interaction.
|
||||
val accepted = with(CompletableDeferred<Boolean>()) {
|
||||
currentActivityRequest = this to plugin.name
|
||||
currentActivityRequest = this to downloader.name
|
||||
|
||||
await()
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
|
||||
isSelected(
|
||||
bundle.uid,
|
||||
patch
|
||||
) && patch.options?.any { it.required && it.default == null && it.key !in opts } ?: false
|
||||
) && patch.options?.any { it.required && it.default == null && it.name !in opts } ?: false
|
||||
}.toList()
|
||||
}.filter { (_, patches) -> patches.isNotEmpty() }
|
||||
}
|
||||
@@ -180,13 +180,13 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
|
||||
|
||||
fun getOptions(bundle: Int, patch: PatchInfo) = patchOptions[bundle]?.get(patch.name)
|
||||
|
||||
fun setOption(bundle: Int, patch: PatchInfo, key: String, value: Any?) {
|
||||
fun setOption(bundle: Int, patch: PatchInfo, name: String, value: Any?) {
|
||||
// All patches
|
||||
val patchesToOpts = patchOptions.getOrElse(bundle, ::persistentMapOf)
|
||||
// The key-value options of an individual patch
|
||||
val patchToOpts = patchesToOpts
|
||||
.getOrElse(patch.name, ::persistentMapOf)
|
||||
.put(key, value)
|
||||
.put(name, value)
|
||||
|
||||
patchOptions[bundle] = patchesToOpts.put(patch.name, patchToOpts)
|
||||
}
|
||||
|
||||
@@ -22,19 +22,19 @@ import app.revanced.manager.R
|
||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||
import app.revanced.manager.domain.installer.RootInstaller
|
||||
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.InstalledAppRepository
|
||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||
import app.revanced.manager.domain.repository.PatchOptionsRepository
|
||||
import app.revanced.manager.domain.repository.PatchSelectionRepository
|
||||
import app.revanced.manager.patcher.patch.PatchBundleInfo
|
||||
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
|
||||
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
|
||||
import app.revanced.manager.network.downloader.LoadedDownloader
|
||||
import app.revanced.manager.network.downloader.ParceledDownloaderData
|
||||
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
|
||||
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.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.navigation.Patcher
|
||||
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
|
||||
@@ -59,7 +59,7 @@ import kotlinx.parcelize.Parcelize
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
|
||||
@OptIn(SavedStateHandleSaveableApi::class, DownloaderHostApi::class)
|
||||
class SelectedAppInfoViewModel(
|
||||
input: SelectedApplicationInfo.ViewModelParams
|
||||
) : ViewModel(), KoinComponent {
|
||||
@@ -67,13 +67,13 @@ class SelectedAppInfoViewModel(
|
||||
private val bundleRepository: PatchBundleRepository = get()
|
||||
private val selectionRepository: PatchSelectionRepository = get()
|
||||
private val optionsRepository: PatchOptionsRepository = get()
|
||||
private val pluginsRepository: DownloaderPluginRepository = get()
|
||||
private val downloaderRepository: DownloaderRepository = get()
|
||||
private val installedAppRepository: InstalledAppRepository = get()
|
||||
private val rootInstaller: RootInstaller = get()
|
||||
private val pm: PM = get()
|
||||
private val savedStateHandle: SavedStateHandle = get()
|
||||
val prefs: PreferencesManager = get()
|
||||
val plugins = pluginsRepository.loadedPluginsFlow
|
||||
val downloaders = downloaderRepository.loadedDownloadersFlow
|
||||
val desiredVersion = input.app.version
|
||||
val packageName = input.app.packageName
|
||||
|
||||
@@ -160,15 +160,15 @@ class SelectedAppInfoViewModel(
|
||||
|
||||
var showSourceSelector by mutableStateOf(false)
|
||||
private set
|
||||
private var pluginAction: Pair<LoadedDownloaderPlugin, Job>? by mutableStateOf(null)
|
||||
val activePluginAction get() = pluginAction?.first?.packageName
|
||||
private var downloaderAction: Pair<LoadedDownloader, Job>? by mutableStateOf(null)
|
||||
val activeDownloader get() = downloaderAction?.first
|
||||
private var launchedActivity by mutableStateOf<CompletableDeferred<ActivityResult>?>(null)
|
||||
private val launchActivityChannel = Channel<Intent>()
|
||||
val launchActivityFlow = launchActivityChannel.receiveAsFlow()
|
||||
|
||||
val errorFlow = combine(plugins, snapshotFlow { selectedApp }) { pluginsList, app ->
|
||||
val errorFlow = combine(downloaders, snapshotFlow { selectedApp }) { downloaderList, app ->
|
||||
when {
|
||||
app is SelectedApp.Search && pluginsList.isEmpty() -> Error.NoPlugins
|
||||
app is SelectedApp.Search && downloaderList.isEmpty() -> Error.NoDownloaders
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -178,23 +178,23 @@ class SelectedAppInfoViewModel(
|
||||
showSourceSelector = true
|
||||
}
|
||||
|
||||
private fun cancelPluginAction() {
|
||||
pluginAction?.second?.cancel()
|
||||
pluginAction = null
|
||||
private fun cancelDownloaderAction() {
|
||||
downloaderAction?.second?.cancel()
|
||||
downloaderAction = null
|
||||
}
|
||||
|
||||
fun dismissSourceSelector() {
|
||||
cancelPluginAction()
|
||||
cancelDownloaderAction()
|
||||
showSourceSelector = false
|
||||
}
|
||||
|
||||
fun searchUsingPlugin(plugin: LoadedDownloaderPlugin) {
|
||||
cancelPluginAction()
|
||||
pluginAction = plugin to viewModelScope.launch {
|
||||
fun searchUsingDownloader(downloader: LoadedDownloader) {
|
||||
cancelDownloaderAction()
|
||||
downloaderAction = downloader to viewModelScope.launch {
|
||||
try {
|
||||
val scope = object : GetScope {
|
||||
override val hostPackageName = app.packageName
|
||||
override val pluginPackageName = plugin.packageName
|
||||
override val downloaderPackageName = downloader.packageName
|
||||
override suspend fun requestStartActivity(intent: Intent) =
|
||||
withContext(Dispatchers.Main) {
|
||||
if (launchedActivity != null) error("Previous activity has not finished")
|
||||
@@ -219,7 +219,7 @@ class SelectedAppInfoViewModel(
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
plugin.get(scope, packageName, desiredVersion)
|
||||
downloader.get(scope, packageName, desiredVersion)
|
||||
}?.let { (data, version) ->
|
||||
if (desiredVersion != null && version != desiredVersion) {
|
||||
app.toast(app.getString(R.string.downloader_invalid_version))
|
||||
@@ -228,7 +228,7 @@ class SelectedAppInfoViewModel(
|
||||
selectedApp = SelectedApp.Download(
|
||||
packageName,
|
||||
version,
|
||||
ParceledDownloaderData(plugin, data)
|
||||
ParceledDownloaderData(downloader, data)
|
||||
)
|
||||
} ?: app.toast(app.getString(R.string.downloader_app_not_found))
|
||||
} catch (e: UserInteractionException.Activity) {
|
||||
@@ -239,13 +239,13 @@ class SelectedAppInfoViewModel(
|
||||
app.toast(app.getString(R.string.downloader_error, e.simpleMessage()))
|
||||
Log.e(tag, "Downloader.get threw an exception", e)
|
||||
} finally {
|
||||
pluginAction = null
|
||||
downloaderAction = null
|
||||
dismissSourceSelector()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePluginActivityResult(result: ActivityResult) {
|
||||
fun handleDownloaderActivityResult(result: ActivityResult) {
|
||||
launchedActivity?.complete(result)
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ class SelectedAppInfoViewModel(
|
||||
}
|
||||
|
||||
enum class Error(@param:StringRes val resourceId: Int) {
|
||||
NoPlugins(R.string.downloader_no_plugins_available)
|
||||
NoDownloaders(R.string.no_downloader_available)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
@@ -325,7 +325,7 @@ class SelectedAppInfoViewModel(
|
||||
bundleOptions.forEach patch@{ (patchName, values) ->
|
||||
// Get all valid option keys for the patch.
|
||||
val validOptionKeys =
|
||||
patches[patchName]?.options?.map { it.key }?.toSet() ?: return@patch
|
||||
patches[patchName]?.options?.map { it.name }?.toSet() ?: return@patch
|
||||
|
||||
this@bundleOptions[patchName] = values.filterKeys { key ->
|
||||
key in validOptionKeys
|
||||
|
||||
@@ -90,8 +90,10 @@ class UpdateViewModel(
|
||||
http.download(location) {
|
||||
url(release.downloadUrl)
|
||||
onDownload { bytesSentTotal, contentLength ->
|
||||
downloadedSize = bytesSentTotal
|
||||
totalSize = contentLength
|
||||
withContext(Dispatchers.Main) {
|
||||
downloadedSize = bytesSentTotal
|
||||
contentLength?.let { totalSize = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
installUpdate()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package app.revanced.manager.util
|
||||
|
||||
import android.app.LocaleConfig
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.LocaleList
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import app.revanced.manager.BuildConfig
|
||||
import java.util.Locale
|
||||
|
||||
object SupportedLocales {
|
||||
fun getSupportedLocales(context: Context): List<Locale> {
|
||||
var result: List<Locale>? = null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) result = runCatching {
|
||||
LocaleConfig(context).supportedLocales?.toList()
|
||||
}.getOrNull()
|
||||
|
||||
return result ?: generated
|
||||
}
|
||||
|
||||
fun getCurrentLocale(): Locale? =
|
||||
AppCompatDelegate.getApplicationLocales().takeIf { !it.isEmpty }?.get(0)
|
||||
|
||||
fun setLocale(locale: Locale?) = AppCompatDelegate.setApplicationLocales(
|
||||
locale?.let { LocaleListCompat.create(it) } ?: LocaleListCompat.getEmptyLocaleList()
|
||||
)
|
||||
|
||||
fun getDisplayName(locale: Locale) =
|
||||
locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) }
|
||||
|
||||
private fun LocaleList.toList() = (0 until size()).map { get(it) }
|
||||
|
||||
private val generated by lazy {
|
||||
listOf(
|
||||
Locale.ENGLISH,
|
||||
*BuildConfig.SUPPORTED_LOCALES.map(Locale::forLanguageTag).toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,10 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -43,7 +41,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.format.MonthNames
|
||||
@@ -51,9 +48,11 @@ import kotlinx.datetime.format.char
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.properties.PropertyDelegateProvider
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.time.Clock
|
||||
|
||||
typealias PatchSelection = Map<Int, Set<String>>
|
||||
typealias Options = Map<Int, Map<String, Map<String, Any?>>>
|
||||
@@ -169,7 +168,7 @@ fun LocalDateTime.relativeTime(context: Context): String {
|
||||
else -> LocalDateTime.Format {
|
||||
monthName(MonthNames.ENGLISH_ABBREVIATED)
|
||||
char(' ')
|
||||
dayOfMonth()
|
||||
day()
|
||||
if (now.toLocalDateTime(TimeZone.UTC).year != this@relativeTime.year) {
|
||||
chars(", ")
|
||||
year()
|
||||
@@ -196,7 +195,12 @@ val transparentListItemColors
|
||||
.also { transparentListItemColorsCached = it }
|
||||
|
||||
@Composable
|
||||
fun <T> EventEffect(flow: Flow<T>, vararg keys: Any?, state: Lifecycle.State = Lifecycle.State.STARTED, block: suspend (T) -> Unit) {
|
||||
fun <T> EventEffect(
|
||||
flow: Flow<T>,
|
||||
vararg keys: Any?,
|
||||
state: Lifecycle.State = Lifecycle.State.STARTED,
|
||||
block: suspend (T) -> Unit
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentBlock by rememberUpdatedState(block)
|
||||
|
||||
@@ -212,40 +216,36 @@ fun <T> EventEffect(flow: Flow<T>, vararg keys: Any?, state: Lifecycle.State = L
|
||||
const val isScrollingUpSensitivity = 10
|
||||
|
||||
@Composable
|
||||
fun LazyListState.isScrollingUp(): State<Boolean> {
|
||||
return remember(this) {
|
||||
var previousIndex by mutableIntStateOf(firstVisibleItemIndex)
|
||||
var previousScrollOffset by mutableIntStateOf(firstVisibleItemScrollOffset)
|
||||
fun LazyListState.isScrollingUp() = produceState(true, this) {
|
||||
var previousIndex = firstVisibleItemIndex
|
||||
var previousScrollOffset = firstVisibleItemScrollOffset
|
||||
|
||||
derivedStateOf {
|
||||
val indexChanged = previousIndex != firstVisibleItemIndex
|
||||
val offsetChanged =
|
||||
kotlin.math.abs(previousScrollOffset - firstVisibleItemScrollOffset) > isScrollingUpSensitivity
|
||||
snapshotFlow {
|
||||
firstVisibleItemIndex to firstVisibleItemScrollOffset
|
||||
}.collect { (index, scrollOffset) ->
|
||||
val indexChanged = previousIndex != index
|
||||
val offsetChanged = abs(previousScrollOffset - scrollOffset) > isScrollingUpSensitivity
|
||||
|
||||
if (indexChanged) {
|
||||
previousIndex > firstVisibleItemIndex
|
||||
} else if (offsetChanged) {
|
||||
previousScrollOffset > firstVisibleItemScrollOffset
|
||||
} else {
|
||||
true
|
||||
}.also {
|
||||
previousIndex = firstVisibleItemIndex
|
||||
previousScrollOffset = firstVisibleItemScrollOffset
|
||||
}
|
||||
value = when {
|
||||
indexChanged -> previousIndex > index
|
||||
offsetChanged -> previousScrollOffset > scrollOffset
|
||||
else -> value
|
||||
}
|
||||
previousIndex = index
|
||||
previousScrollOffset = scrollOffset
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: support sensitivity
|
||||
@Composable
|
||||
fun ScrollState.isScrollingUp(): State<Boolean> {
|
||||
return remember(this) {
|
||||
var previousScrollOffset by mutableIntStateOf(value)
|
||||
derivedStateOf {
|
||||
(previousScrollOffset >= value).also {
|
||||
previousScrollOffset = value
|
||||
}
|
||||
fun ScrollState.isScrollingUp() = produceState(true, this) {
|
||||
var previousScrollOffset = this@isScrollingUp.value
|
||||
|
||||
snapshotFlow { this@isScrollingUp.value }.collect { scrollOffset ->
|
||||
if (abs(previousScrollOffset - scrollOffset) > isScrollingUpSensitivity) {
|
||||
value = previousScrollOffset >= scrollOffset
|
||||
}
|
||||
|
||||
previousScrollOffset = scrollOffset
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ Second \"item\" text"</string>
|
||||
<string name="cli">CLI</string>
|
||||
<string name="manager">Manager</string>
|
||||
|
||||
<string name="plugin_host_permission_label">ReVanced Manager plugin host</string>
|
||||
<string name="plugin_host_permission_description">Used to control access to ReVanced Manager plugins. Only ReVanced Manager has this.</string>
|
||||
<string name="downloader_host_permission_label">ReVanced Manager downloader host</string>
|
||||
<string name="downloader_host_permission_description">Used to control access to ReVanced Manager downloaders. Only ReVanced Manager has this.</string>
|
||||
|
||||
<string name="toast_copied_to_clipboard">Copied!</string>
|
||||
<string name="copy_to_clipboard">Copy to clipboard</string>
|
||||
@@ -30,7 +30,7 @@ Second \"item\" text"</string>
|
||||
<string name="select_app">Select an app</string>
|
||||
<string name="patches_count_selected">%1$d/%2$d selected</string>
|
||||
|
||||
<string name="new_downloader_plugins_notification">New downloader plugins available. Click here to configure them.</string>
|
||||
<string name="new_downloader_notification">New downloader(s) available. Click here to configure them.</string>
|
||||
<string name="unsupported_architecture_warning">Patching on this device architecture is unsupported and will most likely fail.</string>
|
||||
|
||||
<string name="import_">Import</string>
|
||||
@@ -56,7 +56,7 @@ Second \"item\" text"</string>
|
||||
<string name="app_source_dialog_title">Select source</string>
|
||||
<string name="app_source_dialog_option_auto">Auto</string>
|
||||
<string name="app_source_dialog_option_auto_description">Use all available downloader to download the app</string>
|
||||
<string name="app_source_dialog_option_auto_unavailable">No plugins available</string>
|
||||
<string name="app_source_dialog_option_auto_unavailable">No downloader available</string>
|
||||
<string name="app_source_dialog_option_installed_no_root">Mounted apps cannot be patched again without root access</string>
|
||||
<string name="app_source_dialog_option_installed_version_not_suggested">Version %s does not match the suggested version</string>
|
||||
|
||||
@@ -82,12 +82,15 @@ Second \"item\" text"</string>
|
||||
<string name="auto_updates_dialog_patches">ReVanced Patches</string>
|
||||
<string name="auto_updates_dialog_note">These settings can be changed later.</string>
|
||||
|
||||
<string name="announcements_filter_tag">Filter by tag</string>
|
||||
<string name="announcements_show_archived">Show archived</string>
|
||||
|
||||
<string name="general">General</string>
|
||||
<string name="general_description">Theme, dynamic color</string>
|
||||
<string name="general_description">Language, theme, dynamic color</string>
|
||||
<string name="updates">Updates</string>
|
||||
<string name="updates_description">Check for updates and view changelogs</string>
|
||||
<string name="downloads">Downloads</string>
|
||||
<string name="downloads_description">Downloader plugins and downloaded apps</string>
|
||||
<string name="downloads_description">Downloaders and downloaded apps</string>
|
||||
<string name="import_export">Import & export</string>
|
||||
<string name="import_export_description">Keystore, patch options and selection</string>
|
||||
<string name="advanced">Advanced</string>
|
||||
@@ -104,27 +107,30 @@ Second \"item\" text"</string>
|
||||
<string name="pure_black_theme_description">Use pure black backgrounds for dark theme</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="theme_description">Choose between light or dark theme</string>
|
||||
<string name="language">Language</string>
|
||||
<string name="language_description">Choose the app display language</string>
|
||||
<string name="language_system_default">System default</string>
|
||||
<string name="safeguards">Safeguards</string>
|
||||
<string name="patch_compat_check">Disable version compatibility check</string>
|
||||
<string name="patch_compat_check_description">Do not restrict patches to compatible app versions</string>
|
||||
<string name="patch_compat_check_confirmation">"Selecting incompatible patches can result in a broken app.
|
||||
<string name="patch_compat_check_confirmation">Selecting incompatible patches can result in a broken app.
|
||||
|
||||
Do you want to proceed anyways?"</string>
|
||||
Do you want to proceed anyways?</string>
|
||||
<string name="suggested_version_safeguard">Require suggested app version</string>
|
||||
<string name="suggested_version_safeguard_description">Enforce selection of the suggested app version</string>
|
||||
<string name="suggested_version_safeguard_confirmation">"Selecting an app that is not the suggested version may cause unexpected issues.
|
||||
<string name="suggested_version_safeguard_confirmation">Selecting an app that is not the suggested version may cause unexpected issues.
|
||||
|
||||
Do you want to proceed anyways?"</string>
|
||||
Do you want to proceed anyways?</string>
|
||||
<string name="patch_selection_safeguard">Allow changing patch selection and options</string>
|
||||
<string name="patch_selection_safeguard_description">Do not prevent selecting or deselecting patches and customization of options</string>
|
||||
<string name="patch_selection_safeguard_confirmation">"Changing the selection of patches may cause unexpected issues.
|
||||
<string name="patch_selection_safeguard_confirmation">Changing the selection of patches may cause unexpected issues.
|
||||
|
||||
Enable anyways?"</string>
|
||||
Enable anyways?</string>
|
||||
<string name="universal_patches_safeguard">Allow using universal patches</string>
|
||||
<string name="universal_patches_safeguard_description">Do not prevent using universal patches</string>
|
||||
<string name="universal_patches_safeguard_confirmation">"Universal patches are not as well tested as those that target specific apps.
|
||||
<string name="universal_patches_safeguard_confirmation">Universal patches are not as well tested as those that target specific apps.
|
||||
|
||||
Enable anyways?"</string>
|
||||
Enable anyways?</string>
|
||||
<string name="import_keystore">Import keystore</string>
|
||||
<string name="import_keystore_description">Import a custom keystore</string>
|
||||
<string name="import_keystore_dialog_title">Enter keystore credentials</string>
|
||||
@@ -140,9 +146,9 @@ Enable anyways?"</string>
|
||||
<string name="export_keystore_success">Exported keystore</string>
|
||||
<string name="regenerate_keystore">Regenerate keystore</string>
|
||||
<string name="regenerate_keystore_description">Generate a new keystore</string>
|
||||
<string name="regenerate_keystore_dialog_description">"You are about to regenerate your keystore the manager will use during the patching process.
|
||||
<string name="regenerate_keystore_dialog_description">You are about to regenerate your keystore the manager will use during the patching process.
|
||||
|
||||
You will not be able to update the previously installed apps from this source."</string>
|
||||
You will not be able to update the previously installed apps from this source.</string>
|
||||
<string name="regenerate_keystore_success">The keystore has been successfully replaced</string>
|
||||
<string name="import_patch_selection">Import patch selection</string>
|
||||
<string name="import_patch_selection_description">Import patch selection from a JSON file</string>
|
||||
@@ -175,17 +181,17 @@ You will not be able to update the previously installed apps from this source."<
|
||||
<string name="patch_options_reset_all">Reset patch options globally</string>
|
||||
<string name="patch_options_reset_all_dialog_description">You are about to reset all patch options. You will have to reapply each option again.</string>
|
||||
<string name="patch_options_reset_all_description">Resets all patch options</string>
|
||||
<string name="downloader_plugins">Plugins</string>
|
||||
<string name="downloader_plugin_state_trusted">Trusted</string>
|
||||
<string name="downloader_plugin_state_failed">Failed to load. Click for more details</string>
|
||||
<string name="downloader_plugin_state_untrusted">Untrusted</string>
|
||||
<string name="downloader_plugin_trust_dialog_title">Trust plugin?</string>
|
||||
<string name="downloader_plugin_revoke_trust_dialog_title">Revoke trust?</string>
|
||||
<string name="downloader_plugin_trust_dialog_body">Continuing will allow this plugin to run on your system.\n\nOnly enable this plugin if you trust it. Plugins can execute arbitrary code and may compromise your device.</string>
|
||||
<string name="downloader_plugin_trust_dialog_signature">Signature:\n\n%s</string>
|
||||
<string name="downloader_plugin_trust_dialog_plugin">Plugin:\n%s</string>
|
||||
<string name="downloader_plugin_delete_apps_title">Delete selected apps</string>
|
||||
<string name="downloader_plugin_delete_apps_description">Are you sure you want to delete the selected apps?</string>
|
||||
<string name="downloaders">Downloaders</string>
|
||||
<string name="downloader_state_trusted">Trusted</string>
|
||||
<string name="downloader_state_failed">Failed to load. Click for more details</string>
|
||||
<string name="downloader_state_untrusted">Untrusted</string>
|
||||
<string name="downloader_trust_dialog_title">Trust downloader?</string>
|
||||
<string name="downloader_revoke_trust_dialog_title">Revoke trust?</string>
|
||||
<string name="downloader_trust_dialog_body">Continuing will allow this downloader to run.\n\nOnly enable this downloader if you trust it. Downloaders can make Manager execute arbitrary code.</string>
|
||||
<string name="downloader_trust_dialog_signature">Signature:\n\n%s</string>
|
||||
<string name="downloader_trust_dialog_name">Downloader:\n%s</string>
|
||||
<string name="downloader_delete_apps_title">Delete selected apps</string>
|
||||
<string name="downloader_delete_apps_description">Are you sure you want to delete the selected apps?</string>
|
||||
<string name="downloader_settings_no_apps">No downloaded apps found.</string>
|
||||
|
||||
<string name="search_apps">Search apps…</string>
|
||||
@@ -267,23 +273,23 @@ You will not be able to update the previously installed apps from this source."<
|
||||
<string name="patch_selection_reset_toast">Patch selection and options has been reset to recommended defaults</string>
|
||||
<string name="patch_options_reset_toast">Patch options have been reset</string>
|
||||
<string name="non_suggested_version_warning_title">Non suggested version</string>
|
||||
<string name="non_suggested_version_warning_description">"The version of the app you have selected does not match the suggested version.
|
||||
<string name="non_suggested_version_warning_description">The version of the app you have selected does not match the suggested version.
|
||||
Please use the suggested version: %s
|
||||
|
||||
To continue anyway, disable \"Require suggested app version\" in the advanced settings."</string>
|
||||
To continue anyway, disable \"Require suggested app version\" in the advanced settings.</string>
|
||||
<string name="selection_warning_title">Stop using defaults?</string>
|
||||
<string name="selection_warning_description">"It is recommended to use the default patch selection and options. Changing them may result in unexpected issues.
|
||||
<string name="selection_warning_description">It is recommended to use the default patch selection and options. Changing them may result in unexpected issues.
|
||||
|
||||
You need to turn on \"Allow changing patch selection and options\" in the advanced settings before toggling patches."</string>
|
||||
<string name="universal_patch_warning_description">"Universal patches have a more generalized use and do not work as reliably as patches that target specific apps. You may encounter issues while using them.
|
||||
You need to turn on \"Allow changing patch selection and options\" in the advanced settings before toggling patches.</string>
|
||||
<string name="universal_patch_warning_description">Universal patches have a more generalized use and do not work as reliably as patches that target specific apps. You may encounter issues while using them.
|
||||
|
||||
You need to turn on \"Allow using universal patches\" in the advanced settings before using universal patches."</string>
|
||||
You need to turn on \"Allow using universal patches\" in the advanced settings before using universal patches.</string>
|
||||
<string name="this_version">This version</string>
|
||||
<string name="universal">Any app</string>
|
||||
<string name="search_patches">Search patches</string>
|
||||
<string name="app_version_not_compatible">"This patch is not compatible with the selected app version (%1$s)
|
||||
<string name="app_version_not_compatible">This patch is not compatible with the selected app version (%1$s)
|
||||
|
||||
It is only compatible with the following version(s): %2$s"</string>
|
||||
It is only compatible with the following version(s): %2$s</string>
|
||||
<string name="continue_with_version">Continue with this version?</string>
|
||||
<string name="version_not_compatible">Not all patches are compatible with this version (%s). Do you want to continue anyway?</string>
|
||||
<string name="download_application">Download application?</string>
|
||||
@@ -316,8 +322,8 @@ It is only compatible with the following version(s): %2$s"</string>
|
||||
<string name="downloader_invalid_version">Downloader did not fetch the correct version</string>
|
||||
<string name="downloader_app_not_found">Downloader did not find the app</string>
|
||||
<string name="downloader_error">Downloader error: %s</string>
|
||||
<string name="downloader_no_plugins_installed">No downloader installed.</string>
|
||||
<string name="downloader_no_plugins_available">There are downloaders installed but none are trusted. Check your settings.</string>
|
||||
<string name="no_downloader_installed">No downloaders installed.</string>
|
||||
<string name="no_downloader_available">There are downloaders installed but none are trusted. Check your settings.</string>
|
||||
<string name="already_patched">Already patched</string>
|
||||
|
||||
<string name="patch_selector_sheet_filter_title">Filter</string>
|
||||
@@ -345,7 +351,7 @@ It is only compatible with the following version(s): %2$s"</string>
|
||||
<string name="save_apk_success">APK Saved</string>
|
||||
<string name="sign_fail">Failed to sign APK: %s</string>
|
||||
<string name="save_logs">Save logs</string>
|
||||
<string name="plugin_activity_dialog_body">User interaction is required in order to proceed with this plugin.</string>
|
||||
<string name="downloader_activity_dialog_body">User interaction is required in order to proceed with this downloader.</string>
|
||||
<string name="select_install_type">Select installation type</string>
|
||||
|
||||
<string name="patcher_step_group_preparing">Preparing</string>
|
||||
@@ -377,6 +383,7 @@ It is only compatible with the following version(s): %2$s"</string>
|
||||
<string name="less">Less</string>
|
||||
<string name="continue_">Continue</string>
|
||||
<string name="dismiss">Dismiss</string>
|
||||
<string name="view_announcement">View announcement</string>
|
||||
<string name="permanent_dismiss">Do not show this again</string>
|
||||
<string name="donate">Donate</string>
|
||||
<string name="website">Website</string>
|
||||
@@ -395,6 +402,8 @@ It is only compatible with the following version(s): %2$s"</string>
|
||||
<string name="patches_delete_single_dialog_description">Are you sure you want to delete \"%s\"?</string>
|
||||
<string name="patches_delete_multiple_dialog_description">Are you sure you want to delete the selected patches?</string>
|
||||
|
||||
<string name="announcements">Announcements</string>
|
||||
<string name="archive">Archive</string>
|
||||
<string name="about_revanced_manager">About ReVanced Manager</string>
|
||||
<string name="revanced_manager_description">ReVanced Manager is an Android application that uses ReVanced Patcher to patch Android apps. It allows you to download and patch apps with custom patches, and manage the patching process.</string>
|
||||
<string name="developer_options_taps">%d taps remaining</string>
|
||||
@@ -425,8 +434,8 @@ It is only compatible with the following version(s): %2$s"</string>
|
||||
<string name="save_with_count">Save (%1$s)</string>
|
||||
<string name="update">Update</string>
|
||||
<string name="empty">Empty</string>
|
||||
<string name="installing_message">"Tap on <b>Update</b> when prompted.
|
||||
ReVanced Manager will close when updating."</string>
|
||||
<string name="installing_message">Tap on <b>Update</b> when prompted.
|
||||
ReVanced Manager will close when updating.</string>
|
||||
<string name="no_changelogs_found">No changelogs found</string>
|
||||
<string name="just_now">Just now</string>
|
||||
<string name="minutes_ago">%sm ago</string>
|
||||
@@ -440,14 +449,15 @@ ReVanced Manager will close when updating."</string>
|
||||
|
||||
<string name="failed_to_check_updates">Failed to check for updates: %s</string>
|
||||
<string name="no_update_available">No update available</string>
|
||||
<string name="no_announcements_found">No announcements found</string>
|
||||
<string name="update_check">Checking for updates…</string>
|
||||
<string name="dismiss_temporary">Not now</string>
|
||||
<string name="update_available_dialog_description">A new version of ReVanced Manager (%s) is available.</string>
|
||||
<string name="failed_to_download_update">Failed to download update: %s</string>
|
||||
<string name="download">Download</string>
|
||||
<string name="download_confirmation_metered">"You are currently on a metered connection, and data charges from your service provider may apply.
|
||||
<string name="download_confirmation_metered">You are currently on a metered connection, and data charges from your service provider may apply.
|
||||
|
||||
Do you still want to continue?"</string>
|
||||
Do you still want to continue?</string>
|
||||
<string name="download_update_confirmation">Download update?</string>
|
||||
<string name="no_contributors_found">No contributors found</string>
|
||||
<string name="select">Select</string>
|
||||
@@ -485,9 +495,9 @@ Do you still want to continue?"</string>
|
||||
<string name="patches_prereleases">Use pre-releases</string>
|
||||
<string name="patches_prereleases_description">Use pre-release versions of %s</string>
|
||||
<string name="patches_url">Patches URL</string>
|
||||
<string name="incompatible_patches_dialog">"These patches are not compatible with the selected app version (%1$s).
|
||||
<string name="incompatible_patches_dialog">These patches are not compatible with the selected app version (%1$s).
|
||||
|
||||
Click on the patches to see more details."</string>
|
||||
Click on the patches to see more details.</string>
|
||||
<string name="incompatible_patch">Incompatible patch</string>
|
||||
<string name="any_version">Any</string>
|
||||
<string name="never_show_again">Never show again</string>
|
||||
@@ -496,4 +506,5 @@ Click on the patches to see more details."</string>
|
||||
<string name="failed_to_import_keystore">Failed to import keystore</string>
|
||||
<string name="export">Export</string>
|
||||
<string name="confirm">Confirm</string>
|
||||
<string name="new_announcement">New announcement:\n%s</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,5 +6,6 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.kotlin.parcelize) apply false
|
||||
alias(libs.plugins.about.libraries) apply false
|
||||
alias(libs.plugins.about.libraries.android) apply false
|
||||
alias(libs.plugins.compose.compiler) apply false
|
||||
}
|
||||
|
||||
9
crowdin.yml
Normal file
9
crowdin.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
project_id_env: "CROWDIN_PROJECT_ID"
|
||||
api_token_env: "CROWDIN_PERSONAL_TOKEN"
|
||||
|
||||
preserve_hierarchy: true
|
||||
files:
|
||||
- source: app/src/main/res/values/strings.xml
|
||||
dest: manager.xml
|
||||
translation: app/src/main/res/values-%android_code%/strings.xml
|
||||
skip_untranslated_strings: true
|
||||
@@ -3,4 +3,4 @@ android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=false
|
||||
org.gradle.caching=true
|
||||
org.gradle.caching=true
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user