mirror of
https://github.com/revanced/revanced-manager.git
synced 2025-05-02 23:04:25 +02:00
🔮 Merge repository updated to latest snapshot!
Script Execution UTC Time: null Signed-off-by: validcube <pun.butrach@gmail.com>
This commit is contained in:
commit
ace6701aaf
6
.github/workflows/pr-build.yml
vendored
6
.github/workflows/pr-build.yml
vendored
@ -23,8 +23,8 @@ jobs:
|
|||||||
java-version: '17'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/gradle-build-action@v2
|
uses: gradle/actions/setup-gradle@v4
|
||||||
|
|
||||||
- name: Build with Gradle
|
- name: Build with Gradle
|
||||||
env:
|
env:
|
||||||
@ -38,7 +38,7 @@ jobs:
|
|||||||
run: mv app/build/outputs/apk/release/app-release.apk revanced-manager-${{ env.COMMIT_HASH }}.apk
|
run: mv app/build/outputs/apk/release/app-release.apk revanced-manager-${{ env.COMMIT_HASH }}.apk
|
||||||
|
|
||||||
- name: Upload build
|
- name: Upload build
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: revanced-manager
|
name: revanced-manager
|
||||||
path: revanced-manager-${{ env.COMMIT_HASH }}.apk
|
path: revanced-manager-${{ env.COMMIT_HASH }}.apk
|
||||||
|
6
.github/workflows/release-build.yml
vendored
6
.github/workflows/release-build.yml
vendored
@ -20,10 +20,8 @@ jobs:
|
|||||||
java-version: '17'
|
java-version: '17'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Set up Gradle
|
||||||
uses: gradle/gradle-build-action@v2
|
uses: gradle/actions/setup-gradle@v4
|
||||||
with:
|
|
||||||
cache-disabled: true
|
|
||||||
|
|
||||||
- name: Build with Gradle
|
- name: Build with Gradle
|
||||||
env:
|
env:
|
||||||
|
2
.github/workflows/update-documentation.yml
vendored
2
.github/workflows/update-documentation.yml
vendored
@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Dispatch event to documentation repository
|
name: Dispatch event to documentation repository
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
steps:
|
steps:
|
||||||
- uses: peter-evans/repository-dispatch@v2
|
- uses: peter-evans/repository-dispatch@v3
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.DOCUMENTATION_REPO_ACCESS_TOKEN }}
|
token: ${{ secrets.DOCUMENTATION_REPO_ACCESS_TOKEN }}
|
||||||
repository: revanced/revanced-documentation
|
repository: revanced/revanced-documentation
|
||||||
|
78
SECURITY.md
Normal file
78
SECURITY.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<p align="center">
|
||||||
|
<picture>
|
||||||
|
<source
|
||||||
|
width="256px"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
srcset="assets/revanced-headline/revanced-headline-vertical-dark.svg"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
width="256px"
|
||||||
|
src="assets/revanced-headline/revanced-headline-vertical-light.svg"
|
||||||
|
>
|
||||||
|
</picture>
|
||||||
|
<br>
|
||||||
|
<a href="https://revanced.app/">
|
||||||
|
<picture>
|
||||||
|
<source height="24px" media="(prefers-color-scheme: dark)" srcset="assets/revanced-logo/revanced-logo-round.svg" />
|
||||||
|
<img height="24px" src="assets/revanced-logo/revanced-logo-round.svg" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/ReVanced">
|
||||||
|
<picture>
|
||||||
|
<source height="24px" media="(prefers-color-scheme: dark)" srcset="https://i.ibb.co/dMMmCrW/Git-Hub-Mark.png" />
|
||||||
|
<img height="24px" src="https://i.ibb.co/9wV3HGF/Git-Hub-Mark-Light.png" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="http://revanced.app/discord">
|
||||||
|
<picture>
|
||||||
|
<source height="24px" media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/13122796/178032563-d4e084b7-244e-4358-af50-26bde6dd4996.png" />
|
||||||
|
<img height="24px" src="https://user-images.githubusercontent.com/13122796/178032563-d4e084b7-244e-4358-af50-26bde6dd4996.png" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://reddit.com/r/revancedapp">
|
||||||
|
<picture>
|
||||||
|
<source height="24px" media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/13122796/178032351-9d9d5619-8ef7-470a-9eec-2744ece54553.png" />
|
||||||
|
<img height="24px" src="https://user-images.githubusercontent.com/13122796/178032351-9d9d5619-8ef7-470a-9eec-2744ece54553.png" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://t.me/app_revanced">
|
||||||
|
<picture>
|
||||||
|
<source height="24px" media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/13122796/178032213-faf25ab8-0bc3-4a94-a730-b524c96df124.png" />
|
||||||
|
<img height="24px" src="https://user-images.githubusercontent.com/13122796/178032213-faf25ab8-0bc3-4a94-a730-b524c96df124.png" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://x.com/revancedapp">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/93124920/270180600-7c1b38bf-889b-4d68-bd5e-b9d86f91421a.png">
|
||||||
|
<img height="24px" src="https://user-images.githubusercontent.com/93124920/270108715-d80743fa-b330-4809-b1e6-79fbdc60d09c.png" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<a href="https://www.youtube.com/@ReVanced">
|
||||||
|
<picture>
|
||||||
|
<source height="24px" media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/13122796/178032714-c51c7492-0666-44ac-99c2-f003a695ab50.png" />
|
||||||
|
<img height="24px" src="https://user-images.githubusercontent.com/13122796/178032714-c51c7492-0666-44ac-99c2-f003a695ab50.png" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
Continuing the legacy of Vanced
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# 🔒 Security Policy
|
||||||
|
|
||||||
|
This document describes how to report security vulnerabilities for ReVanced Manager.
|
||||||
|
|
||||||
|
## 🚨 Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please open an issue in our [advisory tracker](https://github.com/ReVanced/revanced-manager/security/advisories/new) or reach out privately to us on [Discord](https://discord.gg/revanced).
|
||||||
|
|
||||||
|
If a vulnerability is confirmed and accepted, you can join our [Discord](https://discord.gg/revanced) server to receive a special contributor role.
|
||||||
|
|
||||||
|
### ⏳ Supported Versions
|
||||||
|
|
||||||
|
| Version | Branch | Supported |
|
||||||
|
| ------- | ------------|------------------- |
|
||||||
|
| v1.18.0 | main | :white_check_mark: |
|
||||||
|
| latest | dev | :white_check_mark: |
|
||||||
|
| latest | compose-dev | :white_check_mark: |
|
||||||
|
|
@ -1,10 +1,12 @@
|
|||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.devtools)
|
alias(libs.plugins.devtools)
|
||||||
alias(libs.plugins.about.libraries)
|
alias(libs.plugins.about.libraries)
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
kotlin("plugin.serialization") version "1.9.10"
|
kotlin("plugin.serialization") version "1.9.23"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@ -18,16 +20,15 @@ android {
|
|||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.0.1"
|
versionName = "0.0.1"
|
||||||
resourceConfigurations.addAll(listOf(
|
|
||||||
"en",
|
|
||||||
))
|
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".debug"
|
applicationIdSuffix = ".debug"
|
||||||
resValue("string", "app_name", "ReVanced Manager Debug")
|
resValue("string", "app_name", "ReVanced Manager (dev)")
|
||||||
|
|
||||||
|
buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L")
|
||||||
}
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
@ -42,6 +43,8 @@ android {
|
|||||||
resValue("string", "app_name", "ReVanced Manager Debug")
|
resValue("string", "app_name", "ReVanced Manager Debug")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildConfigField("long", "BUILD_ID", "0L")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +57,7 @@ android {
|
|||||||
includeInApk = false
|
includeInApk = false
|
||||||
includeInBundle = false
|
includeInBundle = false
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources.excludes.addAll(listOf(
|
resources.excludes.addAll(listOf(
|
||||||
"/prebuilt/**",
|
"/prebuilt/**",
|
||||||
@ -80,8 +83,21 @@ android {
|
|||||||
|
|
||||||
buildFeatures.compose = true
|
buildFeatures.compose = true
|
||||||
buildFeatures.aidl = true
|
buildFeatures.aidl = true
|
||||||
|
buildFeatures.buildConfig=true
|
||||||
|
|
||||||
composeOptions.kotlinCompilerExtensionVersion = "1.5.3"
|
android {
|
||||||
|
androidResources {
|
||||||
|
generateLocaleConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions.kotlinCompilerExtensionVersion = "1.5.10"
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
version = "3.22.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@ -104,14 +120,16 @@ dependencies {
|
|||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.ui.preview)
|
implementation(libs.compose.ui.preview)
|
||||||
|
implementation(libs.compose.ui.tooling)
|
||||||
implementation(libs.compose.livedata)
|
implementation(libs.compose.livedata)
|
||||||
implementation(libs.compose.material.icons.extended)
|
implementation(libs.compose.material.icons.extended)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
|
|
||||||
// Accompanist
|
// Accompanist
|
||||||
implementation(libs.accompanist.drawablepainter)
|
implementation(libs.accompanist.drawablepainter)
|
||||||
implementation(libs.accompanist.webview)
|
|
||||||
implementation(libs.accompanist.placeholder)
|
// Placeholder
|
||||||
|
implementation(libs.placeholder.material3)
|
||||||
|
|
||||||
// HTML Scraper
|
// HTML Scraper
|
||||||
implementation(libs.skrapeit.dsl)
|
implementation(libs.skrapeit.dsl)
|
||||||
@ -135,6 +153,13 @@ dependencies {
|
|||||||
implementation(libs.revanced.patcher)
|
implementation(libs.revanced.patcher)
|
||||||
implementation(libs.revanced.library)
|
implementation(libs.revanced.library)
|
||||||
|
|
||||||
|
// Native processes
|
||||||
|
implementation(libs.kotlin.process)
|
||||||
|
|
||||||
|
// HiddenAPI
|
||||||
|
compileOnly(libs.hidden.api.stub)
|
||||||
|
|
||||||
|
// LibSU
|
||||||
implementation(libs.libsu.core)
|
implementation(libs.libsu.core)
|
||||||
implementation(libs.libsu.service)
|
implementation(libs.libsu.service)
|
||||||
implementation(libs.libsu.nio)
|
implementation(libs.libsu.nio)
|
||||||
@ -162,4 +187,13 @@ dependencies {
|
|||||||
|
|
||||||
// Fading Edges
|
// Fading Edges
|
||||||
implementation(libs.fading.edges)
|
implementation(libs.fading.edges)
|
||||||
|
|
||||||
|
// Scrollbars
|
||||||
|
implementation(libs.scrollbars)
|
||||||
|
|
||||||
|
// Reorderable lists
|
||||||
|
implementation(libs.reorderable)
|
||||||
|
|
||||||
|
// Compose Icons
|
||||||
|
implementation(libs.compose.icons.fontawesome)
|
||||||
}
|
}
|
||||||
|
5
app/proguard-rules.pro
vendored
5
app/proguard-rules.pro
vendored
@ -26,6 +26,10 @@
|
|||||||
kotlinx.serialization.KSerializer serializer(...);
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# This required for the process runtime.
|
||||||
|
-keep class app.revanced.manager.patcher.runtime.process.* {
|
||||||
|
*;
|
||||||
|
}
|
||||||
# Required for the patcher to function correctly
|
# Required for the patcher to function correctly
|
||||||
-keep class app.revanced.patcher.** {
|
-keep class app.revanced.patcher.** {
|
||||||
*;
|
*;
|
||||||
@ -45,6 +49,7 @@
|
|||||||
-keep class com.android.** {
|
-keep class com.android.** {
|
||||||
*;
|
*;
|
||||||
}
|
}
|
||||||
|
-dontwarn com.google.auto.value.**
|
||||||
-dontwarn java.awt.**
|
-dontwarn java.awt.**
|
||||||
-dontwarn javax.**
|
-dontwarn javax.**
|
||||||
-dontwarn org.slf4j.**
|
-dontwarn org.slf4j.**
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"identityHash": "802fa2fda94b930bf0ebb85d195f1022",
|
"identityHash": "1dd9d5c0201fdf3cfef3ae669fd65e46",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "patch_bundles",
|
"tableName": "patch_bundles",
|
||||||
@ -51,17 +51,7 @@
|
|||||||
"uid"
|
"uid"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"indices": [
|
"indices": [],
|
||||||
{
|
|
||||||
"name": "index_patch_bundles_name",
|
|
||||||
"unique": true,
|
|
||||||
"columnNames": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_bundles_name` ON `${TABLE_NAME}` (`name`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": []
|
"foreignKeys": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -231,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tableName": "applied_patch",
|
"tableName": "applied_patch",
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "packageName",
|
"fieldPath": "packageName",
|
||||||
@ -285,7 +275,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"table": "patch_bundles",
|
"table": "patch_bundles",
|
||||||
"onDelete": "NO ACTION",
|
"onDelete": "CASCADE",
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"columns": [
|
"columns": [
|
||||||
"bundle"
|
"bundle"
|
||||||
@ -407,7 +397,7 @@
|
|||||||
"views": [],
|
"views": [],
|
||||||
"setupQueries": [
|
"setupQueries": [
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
"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, '802fa2fda94b930bf0ebb85d195f1022')"
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1dd9d5c0201fdf3cfef3ae669fd65e46')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
// IPatcherEvents.aidl
|
||||||
|
package app.revanced.manager.patcher.runtime.process;
|
||||||
|
|
||||||
|
// Interface for sending events back to the main app process.
|
||||||
|
oneway interface IPatcherEvents {
|
||||||
|
void log(String level, String msg);
|
||||||
|
void patchSucceeded();
|
||||||
|
void progress(String name, String state, String msg);
|
||||||
|
// The patching process has ended. The exceptionStackTrace is null if it finished successfully.
|
||||||
|
void finished(String exceptionStackTrace);
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
// IPatcherProcess.aidl
|
||||||
|
package app.revanced.manager.patcher.runtime.process;
|
||||||
|
|
||||||
|
import app.revanced.manager.patcher.runtime.process.Parameters;
|
||||||
|
import app.revanced.manager.patcher.runtime.process.IPatcherEvents;
|
||||||
|
|
||||||
|
interface IPatcherProcess {
|
||||||
|
// Returns BuildConfig.BUILD_ID, which is used to ensure the main app and runner process are running the same code.
|
||||||
|
long buildId();
|
||||||
|
// Makes the patcher process exit with code 0
|
||||||
|
oneway void exit();
|
||||||
|
// Starts patching.
|
||||||
|
oneway void start(in Parameters parameters, IPatcherEvents events);
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
// Parameters.aidl
|
||||||
|
package app.revanced.manager.patcher.runtime.process;
|
||||||
|
|
||||||
|
parcelable Parameters;
|
38
app/src/main/cpp/CMakeLists.txt
Normal file
38
app/src/main/cpp/CMakeLists.txt
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
|
||||||
|
# For more information about using CMake with Android Studio, read the
|
||||||
|
# documentation: https://d.android.com/studio/projects/add-native-code.html.
|
||||||
|
# For more examples on how to use CMake, see https://github.com/android/ndk-samples.
|
||||||
|
|
||||||
|
# Sets the minimum CMake version required for this project.
|
||||||
|
cmake_minimum_required(VERSION 3.22.1)
|
||||||
|
|
||||||
|
# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
|
||||||
|
# Since this is the top level CMakeLists.txt, the project name is also accessible
|
||||||
|
# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
|
||||||
|
# build script scope).
|
||||||
|
project("prop_override")
|
||||||
|
|
||||||
|
# Creates and names a library, sets it as either STATIC
|
||||||
|
# or SHARED, and provides the relative paths to its source code.
|
||||||
|
# You can define multiple libraries, and CMake builds them for you.
|
||||||
|
# Gradle automatically packages shared libraries with your APK.
|
||||||
|
#
|
||||||
|
# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
|
||||||
|
# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
|
||||||
|
# is preferred for the same purpose.
|
||||||
|
#
|
||||||
|
# In order to load a library into your app from Java/Kotlin, you must call
|
||||||
|
# System.loadLibrary() and pass the name of the library defined here;
|
||||||
|
# for GameActivity/NativeActivity derived applications, the same library name must be
|
||||||
|
# used in the AndroidManifest.xml file.
|
||||||
|
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||||
|
# List C/C++ source files with relative paths to this CMakeLists.txt.
|
||||||
|
prop_override.cpp)
|
||||||
|
|
||||||
|
# Specifies libraries CMake should link to your target library. You
|
||||||
|
# can link libraries from various origins, such as libraries defined in this
|
||||||
|
# build script, prebuilt third-party libraries, or Android system libraries.
|
||||||
|
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||||
|
# List libraries link to the target library
|
||||||
|
android
|
||||||
|
log)
|
62
app/src/main/cpp/prop_override.cpp
Normal file
62
app/src/main/cpp/prop_override.cpp
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// Library for overriding Android system properties via environment variables.
|
||||||
|
//
|
||||||
|
// Usage: LD_PRELOAD=prop_override.so PROP_dalvik.vm.heapsize=123M getprop dalvik.vm.heapsize
|
||||||
|
// Output: 123M
|
||||||
|
#include <string>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
|
||||||
|
// Source: https://android.googlesource.com/platform/system/core/+/100b08a848d018eeb1caa5d5e7c7c2aaac65da15/libcutils/include/cutils/properties.h
|
||||||
|
#define PROP_VALUE_MAX 92
|
||||||
|
// This is the mangled name of "android::base::GetProperty".
|
||||||
|
#define GET_PROPERTY_MANGLED_NAME "_ZN7android4base11GetPropertyERKNSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEES9_"
|
||||||
|
|
||||||
|
extern "C" typedef int (*property_get_ptr)(const char *, char *, const char *);
|
||||||
|
typedef std::string (*GetProperty_ptr)(const std::string &, const std::string &);
|
||||||
|
|
||||||
|
char *GetPropOverride(const std::string &key) {
|
||||||
|
auto envKey = "PROP_" + key;
|
||||||
|
|
||||||
|
return getenv(envKey.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// See: https://android.googlesource.com/platform/system/core/+/100b08a848d018eeb1caa5d5e7c7c2aaac65da15/libcutils/properties.cpp
|
||||||
|
extern "C" int property_get(const char *key, char *value, const char *default_value) {
|
||||||
|
auto replacement = GetPropOverride(std::string(key));
|
||||||
|
if (replacement) {
|
||||||
|
int len = strnlen(replacement, PROP_VALUE_MAX);
|
||||||
|
|
||||||
|
strncpy(value, replacement, len);
|
||||||
|
return len;
|
||||||
|
}
|
||||||
|
|
||||||
|
static property_get_ptr original = NULL;
|
||||||
|
if (!original) {
|
||||||
|
// Get the address of the original function.
|
||||||
|
original = reinterpret_cast<property_get_ptr>(dlsym(RTLD_NEXT, "property_get"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return original(key, value, default_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defining android::base::GetProperty ourselves won't work because std::string has a slightly different "path" in the NDK version of the C++ standard library.
|
||||||
|
// We can get around this by forcing the function to adopt a specific name using the asm keyword.
|
||||||
|
std::string GetProperty(const std::string &, const std::string &) asm(GET_PROPERTY_MANGLED_NAME);
|
||||||
|
|
||||||
|
|
||||||
|
// See: https://android.googlesource.com/platform/system/libbase/+/1a34bb67c4f3ba0a1ea6f4f20ac9fe117ba4fe64/properties.cpp
|
||||||
|
// This isn't used for the properties we want to override, but property_get is deprecated so that could change in the future.
|
||||||
|
std::string GetProperty(const std::string &key, const std::string &default_value) {
|
||||||
|
auto replacement = GetPropOverride(key);
|
||||||
|
if (replacement) {
|
||||||
|
return std::string(replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
static GetProperty_ptr original = NULL;
|
||||||
|
if (!original) {
|
||||||
|
original = reinterpret_cast<GetProperty_ptr>(dlsym(RTLD_NEXT, GET_PROPERTY_MANGLED_NAME));
|
||||||
|
}
|
||||||
|
|
||||||
|
return original(key, default_value);
|
||||||
|
}
|
@ -5,22 +5,14 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
import androidx.compose.animation.ExperimentalAnimationApi
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Update
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import app.revanced.manager.ui.component.AutoUpdatesDialog
|
|
||||||
import app.revanced.manager.ui.destination.Destination
|
import app.revanced.manager.ui.destination.Destination
|
||||||
import app.revanced.manager.ui.destination.SettingsDestination
|
import app.revanced.manager.ui.destination.SettingsDestination
|
||||||
import app.revanced.manager.ui.screen.AppSelectorScreen
|
import app.revanced.manager.ui.screen.AppSelectorScreen
|
||||||
import app.revanced.manager.ui.screen.DashboardScreen
|
import app.revanced.manager.ui.screen.DashboardScreen
|
||||||
import app.revanced.manager.ui.screen.InstalledAppInfoScreen
|
import app.revanced.manager.ui.screen.InstalledAppInfoScreen
|
||||||
import app.revanced.manager.ui.screen.InstallerScreen
|
import app.revanced.manager.ui.screen.PatcherScreen
|
||||||
import app.revanced.manager.ui.screen.SelectedAppInfoScreen
|
import app.revanced.manager.ui.screen.SelectedAppInfoScreen
|
||||||
import app.revanced.manager.ui.screen.SettingsScreen
|
import app.revanced.manager.ui.screen.SettingsScreen
|
||||||
import app.revanced.manager.ui.screen.VersionSelectorScreen
|
import app.revanced.manager.ui.screen.VersionSelectorScreen
|
||||||
@ -35,7 +27,7 @@ import dev.olshevski.navigation.reimagined.pop
|
|||||||
import dev.olshevski.navigation.reimagined.popUpTo
|
import dev.olshevski.navigation.reimagined.popUpTo
|
||||||
import dev.olshevski.navigation.reimagined.rememberNavController
|
import dev.olshevski.navigation.reimagined.rememberNavController
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
import org.koin.androidx.compose.getViewModel as getComposeViewModel
|
import org.koin.androidx.compose.koinViewModel as getComposeViewModel
|
||||||
import org.koin.androidx.viewmodel.ext.android.getViewModel as getAndroidViewModel
|
import org.koin.androidx.viewmodel.ext.android.getViewModel as getAndroidViewModel
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@ -46,7 +38,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
|
|
||||||
val vm: MainViewModel = getAndroidViewModel()
|
val vm: MainViewModel = getAndroidViewModel()
|
||||||
|
|
||||||
vm.importLegacySettings(this)
|
vm.importLegacySettings(this)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
@ -59,37 +50,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
) {
|
) {
|
||||||
val navController =
|
val navController =
|
||||||
rememberNavController<Destination>(startDestination = Destination.Dashboard)
|
rememberNavController<Destination>(startDestination = Destination.Dashboard)
|
||||||
|
|
||||||
NavBackHandler(navController)
|
NavBackHandler(navController)
|
||||||
|
|
||||||
val firstLaunch by vm.prefs.firstLaunch.getAsState()
|
|
||||||
|
|
||||||
if (firstLaunch) AutoUpdatesDialog(vm::applyAutoUpdatePrefs)
|
|
||||||
|
|
||||||
vm.updatedManagerVersion?.let {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = vm::dismissUpdateDialog,
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
vm.dismissUpdateDialog()
|
|
||||||
navController.navigate(Destination.Settings(SettingsDestination.Update(false)))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.update))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = vm::dismissUpdateDialog) {
|
|
||||||
Text(stringResource(R.string.dismiss_temporary))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon = { Icon(Icons.Outlined.Update, null) },
|
|
||||||
title = { Text(stringResource(R.string.update_available_dialog_title)) },
|
|
||||||
text = { Text(stringResource(R.string.update_available_dialog_description, it)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedNavHost(
|
AnimatedNavHost(
|
||||||
controller = navController
|
controller = navController
|
||||||
) { destination ->
|
) { destination ->
|
||||||
@ -97,6 +59,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
is Destination.Dashboard -> DashboardScreen(
|
is Destination.Dashboard -> DashboardScreen(
|
||||||
onSettingsClick = { navController.navigate(Destination.Settings()) },
|
onSettingsClick = { navController.navigate(Destination.Settings()) },
|
||||||
onAppSelectorClick = { navController.navigate(Destination.AppSelector) },
|
onAppSelectorClick = { navController.navigate(Destination.AppSelector) },
|
||||||
|
onUpdateClick = { navController.navigate(
|
||||||
|
Destination.Settings(SettingsDestination.Update())
|
||||||
|
) },
|
||||||
onAppClick = { installedApp ->
|
onAppClick = { installedApp ->
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
Destination.InstalledApplicationInfo(
|
Destination.InstalledApplicationInfo(
|
||||||
@ -107,11 +72,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen(
|
is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen(
|
||||||
onPatchClick = { packageName, patchesSelection ->
|
onPatchClick = { packageName, patchSelection ->
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
Destination.VersionSelector(
|
Destination.VersionSelector(
|
||||||
packageName,
|
packageName,
|
||||||
patchesSelection
|
patchSelection
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -142,14 +107,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
navController.navigate(
|
navController.navigate(
|
||||||
Destination.SelectedApplicationInfo(
|
Destination.SelectedApplicationInfo(
|
||||||
selectedApp,
|
selectedApp,
|
||||||
destination.patchesSelection,
|
destination.patchSelection,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
viewModel = getComposeViewModel {
|
viewModel = getComposeViewModel {
|
||||||
parametersOf(
|
parametersOf(
|
||||||
destination.packageName,
|
destination.packageName,
|
||||||
destination.patchesSelection
|
destination.patchSelection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -157,7 +122,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
|
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
|
||||||
onPatchClick = { app, patches, options ->
|
onPatchClick = { app, patches, options ->
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
Destination.Installer(
|
Destination.Patcher(
|
||||||
app, patches, options
|
app, patches, options
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -167,13 +132,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
parametersOf(
|
parametersOf(
|
||||||
SelectedAppInfoViewModel.Params(
|
SelectedAppInfoViewModel.Params(
|
||||||
destination.selectedApp,
|
destination.selectedApp,
|
||||||
destination.patchesSelection
|
destination.patchSelection
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
is Destination.Installer -> InstallerScreen(
|
is Destination.Patcher -> PatcherScreen(
|
||||||
onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
|
onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
|
||||||
vm = getComposeViewModel { parametersOf(destination) }
|
vm = getComposeViewModel { parametersOf(destination) }
|
||||||
)
|
)
|
||||||
|
@ -1,23 +1,18 @@
|
|||||||
package app.revanced.manager
|
package app.revanced.manager
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Intent
|
|
||||||
import app.revanced.manager.di.*
|
import app.revanced.manager.di.*
|
||||||
import app.revanced.manager.domain.manager.PreferencesManager
|
import app.revanced.manager.domain.manager.PreferencesManager
|
||||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||||
import app.revanced.manager.service.ManagerRootService
|
|
||||||
import app.revanced.manager.service.RootConnection
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import coil.Coil
|
import coil.Coil
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import com.topjohnwu.superuser.Shell
|
import com.topjohnwu.superuser.Shell
|
||||||
import com.topjohnwu.superuser.internal.BuilderImpl
|
import com.topjohnwu.superuser.internal.BuilderImpl
|
||||||
import com.topjohnwu.superuser.ipc.RootService
|
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
|
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
|
||||||
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
|
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
|
||||||
import org.koin.android.ext.android.get
|
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.android.ext.koin.androidLogger
|
import org.koin.android.ext.koin.androidLogger
|
||||||
@ -61,9 +56,6 @@ class ManagerApplication : Application() {
|
|||||||
val shellBuilder = BuilderImpl.create().setFlags(Shell.FLAG_MOUNT_MASTER)
|
val shellBuilder = BuilderImpl.create().setFlags(Shell.FLAG_MOUNT_MASTER)
|
||||||
Shell.setDefaultBuilder(shellBuilder)
|
Shell.setDefaultBuilder(shellBuilder)
|
||||||
|
|
||||||
val intent = Intent(this, ManagerRootService::class.java)
|
|
||||||
RootService.bind(intent, get<RootConnection>())
|
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
prefs.preload()
|
prefs.preload()
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
package app.revanced.manager.data.platform
|
package app.revanced.manager.data.platform
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import app.revanced.manager.util.RequestManageStorageContract
|
import app.revanced.manager.util.RequestManageStorageContract
|
||||||
@ -16,7 +17,7 @@ class Filesystem(private val app: Application) {
|
|||||||
* A directory that gets cleared when the app restarts.
|
* A directory that gets cleared when the app restarts.
|
||||||
* Do not store paths to this directory in a parcel.
|
* Do not store paths to this directory in a parcel.
|
||||||
*/
|
*/
|
||||||
val tempDir = app.cacheDir.resolve("ephemeral").apply {
|
val tempDir = app.getDir("ephemeral", Context.MODE_PRIVATE).apply {
|
||||||
deleteRecursively()
|
deleteRecursively()
|
||||||
mkdirs()
|
mkdirs()
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ package app.revanced.manager.data.room
|
|||||||
|
|
||||||
import androidx.room.TypeConverter
|
import androidx.room.TypeConverter
|
||||||
import app.revanced.manager.data.room.bundles.Source
|
import app.revanced.manager.data.room.bundles.Source
|
||||||
import io.ktor.http.*
|
import app.revanced.manager.data.room.options.Option.SerializedValue
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class Converters {
|
class Converters {
|
||||||
@ -17,4 +17,10 @@ class Converters {
|
|||||||
|
|
||||||
@TypeConverter
|
@TypeConverter
|
||||||
fun fileToString(file: File): String = file.path
|
fun fileToString(file: File): String = file.path
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun serializedOptionFromString(value: String) = SerializedValue.fromJsonString(value)
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun serializedOptionToString(value: SerializedValue) = value.toJsonString()
|
||||||
}
|
}
|
@ -22,7 +22,8 @@ import kotlinx.parcelize.Parcelize
|
|||||||
ForeignKey(
|
ForeignKey(
|
||||||
PatchBundleEntity::class,
|
PatchBundleEntity::class,
|
||||||
parentColumns = ["uid"],
|
parentColumns = ["uid"],
|
||||||
childColumns = ["bundle"]
|
childColumns = ["bundle"],
|
||||||
|
onDelete = ForeignKey.CASCADE
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
indices = [Index(value = ["bundle"], unique = false)]
|
indices = [Index(value = ["bundle"], unique = false)]
|
||||||
|
@ -3,7 +3,7 @@ package app.revanced.manager.data.room.apps.installed
|
|||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Delete
|
import androidx.room.Delete
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.MapInfo
|
import androidx.room.MapColumn
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Upsert
|
import androidx.room.Upsert
|
||||||
@ -17,12 +17,13 @@ interface InstalledAppDao {
|
|||||||
@Query("SELECT * FROM installed_app WHERE current_package_name = :packageName")
|
@Query("SELECT * FROM installed_app WHERE current_package_name = :packageName")
|
||||||
suspend fun get(packageName: String): InstalledApp?
|
suspend fun get(packageName: String): InstalledApp?
|
||||||
|
|
||||||
@MapInfo(keyColumn = "bundle", valueColumn = "patch_name")
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT bundle, patch_name FROM applied_patch" +
|
"SELECT bundle, patch_name FROM applied_patch" +
|
||||||
" WHERE package_name = :packageName"
|
" WHERE package_name = :packageName"
|
||||||
)
|
)
|
||||||
suspend fun getPatchesSelection(packageName: String): Map<Int, List<String>>
|
suspend fun getPatchesSelection(packageName: String): Map<@MapColumn("bundle") Int, List<@MapColumn(
|
||||||
|
"patch_name"
|
||||||
|
) String>>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
suspend fun upsertApp(installedApp: InstalledApp, appliedPatches: List<AppliedPatch>) {
|
suspend fun upsertApp(installedApp: InstalledApp, appliedPatches: List<AppliedPatch>) {
|
||||||
|
@ -9,7 +9,7 @@ interface PatchBundleDao {
|
|||||||
suspend fun all(): List<PatchBundleEntity>
|
suspend fun all(): List<PatchBundleEntity>
|
||||||
|
|
||||||
@Query("SELECT version, integrations_version, auto_update FROM patch_bundles WHERE uid = :uid")
|
@Query("SELECT version, integrations_version, auto_update FROM patch_bundles WHERE uid = :uid")
|
||||||
fun getPropsById(uid: Int): Flow<BundleProperties>
|
fun getPropsById(uid: Int): Flow<BundleProperties?>
|
||||||
|
|
||||||
@Query("UPDATE patch_bundles SET version = :patches, integrations_version = :integrations WHERE uid = :uid")
|
@Query("UPDATE patch_bundles SET version = :patches, integrations_version = :integrations WHERE uid = :uid")
|
||||||
suspend fun updateVersion(uid: Int, patches: String?, integrations: String?)
|
suspend fun updateVersion(uid: Int, patches: String?, integrations: String?)
|
||||||
@ -17,6 +17,9 @@ interface PatchBundleDao {
|
|||||||
@Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid")
|
@Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid")
|
||||||
suspend fun setAutoUpdate(uid: Int, value: Boolean)
|
suspend fun setAutoUpdate(uid: Int, value: Boolean)
|
||||||
|
|
||||||
|
@Query("UPDATE patch_bundles SET name = :value WHERE uid = :uid")
|
||||||
|
suspend fun setName(uid: Int, value: String)
|
||||||
|
|
||||||
@Query("DELETE FROM patch_bundles WHERE uid != 0")
|
@Query("DELETE FROM patch_bundles WHERE uid != 0")
|
||||||
suspend fun purgeCustomBundles()
|
suspend fun purgeCustomBundles()
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ sealed class Source {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(value: String) = when(value) {
|
fun from(value: String) = when (value) {
|
||||||
Local.SENTINEL -> Local
|
Local.SENTINEL -> Local
|
||||||
API.SENTINEL -> API
|
API.SENTINEL -> API
|
||||||
else -> Remote(Url(value))
|
else -> Remote(Url(value))
|
||||||
@ -34,7 +34,7 @@ data class VersionInfo(
|
|||||||
@ColumnInfo(name = "integrations_version") val integrations: String? = null,
|
@ColumnInfo(name = "integrations_version") val integrations: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Entity(tableName = "patch_bundles", indices = [Index(value = ["name"], unique = true)])
|
@Entity(tableName = "patch_bundles")
|
||||||
data class PatchBundleEntity(
|
data class PatchBundleEntity(
|
||||||
@PrimaryKey val uid: Int,
|
@PrimaryKey val uid: Int,
|
||||||
@ColumnInfo(name = "name") val name: String,
|
@ColumnInfo(name = "name") val name: String,
|
||||||
|
@ -3,6 +3,23 @@ package app.revanced.manager.data.room.options
|
|||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
import androidx.room.ForeignKey
|
||||||
|
import app.revanced.manager.patcher.patch.Option
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.add
|
||||||
|
import kotlinx.serialization.json.boolean
|
||||||
|
import kotlinx.serialization.json.buildJsonArray
|
||||||
|
import kotlinx.serialization.json.float
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.long
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "options",
|
tableName = "options",
|
||||||
@ -19,5 +36,74 @@ data class Option(
|
|||||||
@ColumnInfo(name = "patch_name") val patchName: String,
|
@ColumnInfo(name = "patch_name") val patchName: String,
|
||||||
@ColumnInfo(name = "key") val key: String,
|
@ColumnInfo(name = "key") val key: String,
|
||||||
// Encoded as Json.
|
// Encoded as Json.
|
||||||
@ColumnInfo(name = "value") val value: String,
|
@ColumnInfo(name = "value") val value: SerializedValue,
|
||||||
)
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class SerializedValue(val raw: JsonElement) {
|
||||||
|
fun toJsonString() = json.encodeToString(raw)
|
||||||
|
fun deserializeFor(option: Option<*>): Any? {
|
||||||
|
if (raw is JsonNull) return null
|
||||||
|
|
||||||
|
val errorMessage = "Cannot deserialize value as ${option.type}"
|
||||||
|
try {
|
||||||
|
if (option.type.endsWith("Array")) {
|
||||||
|
val elementType = option.type.removeSuffix("Array")
|
||||||
|
return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return deserializeBasicType(option.type, raw.jsonPrimitive)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
throw SerializationException(errorMessage, e)
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
throw SerializationException(errorMessage, e)
|
||||||
|
} catch (e: kotlinx.serialization.SerializationException) {
|
||||||
|
throw SerializationException(errorMessage, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val json = Json {
|
||||||
|
// Patcher does not forbid the use of these values, so we should support them.
|
||||||
|
allowSpecialFloatingPointValues = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deserializeBasicType(type: String, value: JsonPrimitive) = when (type) {
|
||||||
|
"Boolean" -> value.boolean
|
||||||
|
"Int" -> value.int
|
||||||
|
"Long" -> value.long
|
||||||
|
"Float" -> value.float
|
||||||
|
"String" -> value.content.also { if (!value.isString) throw SerializationException("Expected value to be a string: $value") }
|
||||||
|
else -> throw SerializationException("Unknown type: $type")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromJsonString(value: String) = SerializedValue(json.decodeFromString(value))
|
||||||
|
fun fromValue(value: Any?) = SerializedValue(when (value) {
|
||||||
|
null -> JsonNull
|
||||||
|
is Number -> JsonPrimitive(value)
|
||||||
|
is Boolean -> JsonPrimitive(value)
|
||||||
|
is String -> JsonPrimitive(value)
|
||||||
|
is List<*> -> buildJsonArray {
|
||||||
|
var elementClass: KClass<out Any>? = null
|
||||||
|
|
||||||
|
value.forEach {
|
||||||
|
when (it) {
|
||||||
|
null -> throw SerializationException("List elements must not be null")
|
||||||
|
is Number -> add(it)
|
||||||
|
is Boolean -> add(it)
|
||||||
|
is String -> add(it)
|
||||||
|
else -> throw SerializationException("Unknown element type: ${it::class.simpleName}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementClass == null) elementClass = it::class
|
||||||
|
else if (elementClass != it::class) throw SerializationException("List elements must have the same type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw SerializationException("Unknown type: ${value::class.simpleName}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SerializationException(message: String, cause: Throwable? = null) :
|
||||||
|
Exception(message, cause)
|
||||||
|
}
|
||||||
|
@ -2,7 +2,7 @@ package app.revanced.manager.data.room.options
|
|||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.MapInfo
|
import androidx.room.MapColumn
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@ -10,13 +10,12 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
@Dao
|
@Dao
|
||||||
abstract class OptionDao {
|
abstract class OptionDao {
|
||||||
@Transaction
|
@Transaction
|
||||||
@MapInfo(keyColumn = "patch_bundle")
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT patch_bundle, `group`, patch_name, `key`, value FROM option_groups" +
|
"SELECT patch_bundle, `group`, patch_name, `key`, value FROM option_groups" +
|
||||||
" LEFT JOIN options ON uid = options.`group`" +
|
" LEFT JOIN options ON uid = options.`group`" +
|
||||||
" WHERE package_name = :packageName"
|
" WHERE package_name = :packageName"
|
||||||
)
|
)
|
||||||
abstract suspend fun getOptions(packageName: String): Map<Int, List<Option>>
|
abstract suspend fun getOptions(packageName: String): Map<@MapColumn("patch_bundle") Int, List<Option>>
|
||||||
|
|
||||||
@Query("SELECT uid FROM option_groups WHERE patch_bundle = :bundleUid AND package_name = :packageName")
|
@Query("SELECT uid FROM option_groups WHERE patch_bundle = :bundleUid AND package_name = :packageName")
|
||||||
abstract suspend fun getGroupId(bundleUid: Int, packageName: String): Int?
|
abstract suspend fun getGroupId(bundleUid: Int, packageName: String): Int?
|
||||||
|
@ -2,29 +2,31 @@ package app.revanced.manager.data.room.selection
|
|||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.MapInfo
|
import androidx.room.MapColumn
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
abstract class SelectionDao {
|
abstract class SelectionDao {
|
||||||
@Transaction
|
@Transaction
|
||||||
@MapInfo(keyColumn = "patch_bundle", valueColumn = "patch_name")
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT patch_bundle, patch_name FROM patch_selections" +
|
"SELECT patch_bundle, patch_name FROM patch_selections" +
|
||||||
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
|
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
|
||||||
" WHERE package_name = :packageName"
|
" WHERE package_name = :packageName"
|
||||||
)
|
)
|
||||||
abstract suspend fun getSelectedPatches(packageName: String): Map<Int, List<String>>
|
abstract suspend fun getSelectedPatches(packageName: String): Map<@MapColumn("patch_bundle") Int, List<@MapColumn(
|
||||||
|
"patch_name"
|
||||||
|
) String>>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@MapInfo(keyColumn = "package_name", valueColumn = "patch_name")
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT package_name, patch_name FROM patch_selections" +
|
"SELECT package_name, patch_name FROM patch_selections" +
|
||||||
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
|
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
|
||||||
" WHERE patch_bundle = :bundleUid"
|
" WHERE patch_bundle = :bundleUid"
|
||||||
)
|
)
|
||||||
abstract suspend fun exportSelection(bundleUid: Int): Map<String, List<String>>
|
abstract suspend fun exportSelection(bundleUid: Int): Map<@MapColumn("package_name") String, List<@MapColumn(
|
||||||
|
"patch_name"
|
||||||
|
) String>>
|
||||||
|
|
||||||
@Query("SELECT uid FROM patch_selections WHERE patch_bundle = :bundleUid AND package_name = :packageName")
|
@Query("SELECT uid FROM patch_selections WHERE patch_bundle = :bundleUid AND package_name = :packageName")
|
||||||
abstract suspend fun getSelectionId(bundleUid: Int, packageName: String): Int?
|
abstract suspend fun getSelectionId(bundleUid: Int, packageName: String): Int?
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
package app.revanced.manager.di
|
package app.revanced.manager.di
|
||||||
|
|
||||||
import app.revanced.manager.domain.installer.RootInstaller
|
import app.revanced.manager.domain.installer.RootInstaller
|
||||||
import app.revanced.manager.service.RootConnection
|
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val rootModule = module {
|
val rootModule = module {
|
||||||
singleOf(::RootConnection)
|
|
||||||
singleOf(::RootInstaller)
|
singleOf(::RootInstaller)
|
||||||
}
|
}
|
@ -13,12 +13,15 @@ val viewModelModule = module {
|
|||||||
viewModelOf(::AdvancedSettingsViewModel)
|
viewModelOf(::AdvancedSettingsViewModel)
|
||||||
viewModelOf(::AppSelectorViewModel)
|
viewModelOf(::AppSelectorViewModel)
|
||||||
viewModelOf(::VersionSelectorViewModel)
|
viewModelOf(::VersionSelectorViewModel)
|
||||||
viewModelOf(::InstallerViewModel)
|
viewModelOf(::PatcherViewModel)
|
||||||
viewModelOf(::UpdateViewModel)
|
viewModelOf(::UpdateViewModel)
|
||||||
viewModelOf(::ChangelogsViewModel)
|
viewModelOf(::ChangelogsViewModel)
|
||||||
viewModelOf(::ImportExportViewModel)
|
viewModelOf(::ImportExportViewModel)
|
||||||
|
viewModelOf(::AboutViewModel)
|
||||||
|
viewModelOf(::DeveloperOptionsViewModel)
|
||||||
viewModelOf(::ContributorViewModel)
|
viewModelOf(::ContributorViewModel)
|
||||||
viewModelOf(::DownloadsViewModel)
|
viewModelOf(::DownloadsViewModel)
|
||||||
viewModelOf(::InstalledAppsViewModel)
|
viewModelOf(::InstalledAppsViewModel)
|
||||||
viewModelOf(::InstalledAppInfoViewModel)
|
viewModelOf(::InstalledAppInfoViewModel)
|
||||||
|
viewModelOf(::UpdatesSettingsViewModel)
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,8 @@ import java.io.InputStream
|
|||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.StandardCopyOption
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
class LocalPatchBundle(name: String, id: Int, directory: File) : PatchBundleSource(name, id, directory) {
|
class LocalPatchBundle(name: String, id: Int, directory: File) :
|
||||||
|
PatchBundleSource(name, id, directory) {
|
||||||
suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) {
|
suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
patches?.let { inputStream ->
|
patches?.let { inputStream ->
|
||||||
@ -16,10 +17,16 @@ class LocalPatchBundle(name: String, id: Int, directory: File) : PatchBundleSour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
integrations?.let {
|
integrations?.let {
|
||||||
Files.copy(it, this@LocalPatchBundle.integrationsFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
Files.copy(
|
||||||
|
it,
|
||||||
|
this@LocalPatchBundle.integrationsFile.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reload()
|
reload()?.also {
|
||||||
|
saveVersion(it.readManifestAttribute("Version"), null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,22 @@
|
|||||||
package app.revanced.manager.domain.bundles
|
package app.revanced.manager.domain.bundles
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import app.revanced.manager.R
|
||||||
|
import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository
|
||||||
import app.revanced.manager.patcher.patch.PatchBundle
|
import app.revanced.manager.patcher.patch.PatchBundle
|
||||||
import app.revanced.manager.util.tag
|
import app.revanced.manager.util.tag
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
@ -14,13 +24,21 @@ import java.io.OutputStream
|
|||||||
* A [PatchBundle] source.
|
* A [PatchBundle] source.
|
||||||
*/
|
*/
|
||||||
@Stable
|
@Stable
|
||||||
sealed class PatchBundleSource(val name: String, val uid: Int, directory: File) {
|
sealed class PatchBundleSource(initialName: String, val uid: Int, directory: File) : KoinComponent {
|
||||||
|
protected val configRepository: PatchBundlePersistenceRepository by inject()
|
||||||
|
private val app: Application by inject()
|
||||||
protected val patchesFile = directory.resolve("patches.jar")
|
protected val patchesFile = directory.resolve("patches.jar")
|
||||||
protected val integrationsFile = directory.resolve("integrations.apk")
|
protected val integrationsFile = directory.resolve("integrations.apk")
|
||||||
|
|
||||||
private val _state = MutableStateFlow(load())
|
private val _state = MutableStateFlow(load())
|
||||||
val state = _state.asStateFlow()
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _nameFlow = MutableStateFlow(initialName)
|
||||||
|
val nameFlow =
|
||||||
|
_nameFlow.map { it.ifEmpty { app.getString(if (isDefault) R.string.bundle_name_default else R.string.bundle_name_fallback) } }
|
||||||
|
|
||||||
|
suspend fun getName() = nameFlow.first()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if the bundle has been downloaded to local storage.
|
* Returns true if the bundle has been downloaded to local storage.
|
||||||
*/
|
*/
|
||||||
@ -42,13 +60,38 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File)
|
|||||||
return try {
|
return try {
|
||||||
State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists)))
|
State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists)))
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.e(tag, "Failed to load patch bundle $name", t)
|
Log.e(tag, "Failed to load patch bundle with UID $uid", t)
|
||||||
State.Failed(t)
|
State.Failed(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reload() {
|
suspend fun reload(): PatchBundle? {
|
||||||
_state.value = load()
|
val newState = load()
|
||||||
|
_state.value = newState
|
||||||
|
|
||||||
|
val bundle = newState.patchBundleOrNull()
|
||||||
|
// Try to read the name from the patch bundle manifest if the bundle does not have a name.
|
||||||
|
if (bundle != null && _nameFlow.value.isEmpty()) {
|
||||||
|
bundle.readManifestAttribute("Name")?.let { setName(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return bundle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a flow that emits the [app.revanced.manager.data.room.bundles.BundleProperties] of this [PatchBundleSource].
|
||||||
|
* The flow will emit null if the associated [PatchBundleSource] is deleted.
|
||||||
|
*/
|
||||||
|
fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default)
|
||||||
|
suspend fun getProps() = propsFlow().first()!!
|
||||||
|
|
||||||
|
suspend fun currentVersion() = getProps().versionInfo
|
||||||
|
protected suspend fun saveVersion(patches: String?, integrations: String?) =
|
||||||
|
configRepository.updateVersion(uid, patches, integrations)
|
||||||
|
|
||||||
|
suspend fun setName(name: String) {
|
||||||
|
configRepository.setName(uid, name)
|
||||||
|
_nameFlow.value = name
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface State {
|
sealed interface State {
|
||||||
@ -61,9 +104,12 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object Extensions {
|
||||||
val PatchBundleSource.isDefault get() = uid == 0
|
val PatchBundleSource.isDefault inline get() = uid == 0
|
||||||
val PatchBundleSource.asRemoteOrNull get() = this as? RemotePatchBundle
|
val PatchBundleSource.asRemoteOrNull inline get() = this as? RemotePatchBundle
|
||||||
fun PatchBundleSource.propsOrNullFlow() = asRemoteOrNull?.propsFlow() ?: flowOf(null)
|
val PatchBundleSource.nameState
|
||||||
|
@Composable inline get() = nameFlow.collectAsStateWithLifecycle(
|
||||||
|
""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,7 +2,6 @@ package app.revanced.manager.domain.bundles
|
|||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import app.revanced.manager.data.room.bundles.VersionInfo
|
import app.revanced.manager.data.room.bundles.VersionInfo
|
||||||
import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository
|
|
||||||
import app.revanced.manager.network.api.ReVancedAPI
|
import app.revanced.manager.network.api.ReVancedAPI
|
||||||
import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType
|
import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType
|
||||||
import app.revanced.manager.network.dto.BundleAsset
|
import app.revanced.manager.network.dto.BundleAsset
|
||||||
@ -15,17 +14,14 @@ import io.ktor.client.request.url
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) :
|
sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) :
|
||||||
PatchBundleSource(name, id, directory), KoinComponent {
|
PatchBundleSource(name, id, directory) {
|
||||||
private val configRepository: PatchBundlePersistenceRepository by inject()
|
|
||||||
protected val http: HttpService by inject()
|
protected val http: HttpService by inject()
|
||||||
|
|
||||||
protected abstract suspend fun getLatestInfo(): BundleInfo
|
protected abstract suspend fun getLatestInfo(): BundleInfo
|
||||||
@ -70,17 +66,11 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun currentVersion() = configRepository.getProps(uid).first().versionInfo
|
|
||||||
private suspend fun saveVersion(patches: String, integrations: String) =
|
|
||||||
configRepository.updateVersion(uid, patches, integrations)
|
|
||||||
|
|
||||||
suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) {
|
suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) {
|
||||||
arrayOf(patchesFile, integrationsFile).forEach(File::delete)
|
arrayOf(patchesFile, integrationsFile).forEach(File::delete)
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun propsFlow() = configRepository.getProps(uid)
|
|
||||||
|
|
||||||
suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value)
|
suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -107,7 +97,7 @@ class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
|
|||||||
.getLatestRelease(repo)
|
.getLatestRelease(repo)
|
||||||
.getOrThrow()
|
.getOrThrow()
|
||||||
.let {
|
.let {
|
||||||
BundleAsset(it.metadata.tag, it.findAssetByType(mime).downloadUrl)
|
BundleAsset(it.version, it.findAssetByType(mime).downloadUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,49 +1,93 @@
|
|||||||
package app.revanced.manager.domain.installer
|
package app.revanced.manager.domain.installer
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import app.revanced.manager.service.RootConnection
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
|
import app.revanced.manager.IRootSystemService
|
||||||
|
import app.revanced.manager.service.ManagerRootService
|
||||||
import app.revanced.manager.util.PM
|
import app.revanced.manager.util.PM
|
||||||
import com.topjohnwu.superuser.Shell
|
import com.topjohnwu.superuser.Shell
|
||||||
|
import com.topjohnwu.superuser.ipc.RootService
|
||||||
|
import com.topjohnwu.superuser.nio.FileSystemManager
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.time.withTimeoutOrNull
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
class RootInstaller(
|
class RootInstaller(
|
||||||
private val app: Application,
|
private val app: Application,
|
||||||
private val rootConnection: RootConnection,
|
|
||||||
private val pm: PM
|
private val pm: PM
|
||||||
) {
|
) : ServiceConnection {
|
||||||
|
private var remoteFS = CompletableDeferred<FileSystemManager>()
|
||||||
|
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
val ipc = IRootSystemService.Stub.asInterface(service)
|
||||||
|
val binder = ipc.fileSystemService
|
||||||
|
|
||||||
|
remoteFS.complete(FileSystemManager.getRemote(binder))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
remoteFS = CompletableDeferred()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitRemoteFS(): FileSystemManager {
|
||||||
|
if (remoteFS.isActive) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val intent = Intent(app, ManagerRootService::class.java)
|
||||||
|
RootService.bind(intent, this@RootInstaller)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return withTimeoutOrNull(Duration.ofSeconds(120L)) {
|
||||||
|
remoteFS.await()
|
||||||
|
} ?: throw RootServiceException()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getShell() = with(CompletableDeferred<Shell>()) {
|
||||||
|
Shell.getShell(::complete)
|
||||||
|
|
||||||
|
await()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun execute(vararg commands: String) = getShell().newJob().add(*commands).exec()
|
||||||
|
|
||||||
fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false
|
fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false
|
||||||
|
|
||||||
fun isAppInstalled(packageName: String) =
|
suspend fun isAppInstalled(packageName: String) =
|
||||||
rootConnection.remoteFS?.getFile("$modulesPath/$packageName-revanced")
|
awaitRemoteFS().getFile("$modulesPath/$packageName-revanced").exists()
|
||||||
?.exists() ?: throw RootServiceException()
|
|
||||||
|
|
||||||
fun isAppMounted(packageName: String): Boolean {
|
suspend fun isAppMounted(packageName: String) = withContext(Dispatchers.IO) {
|
||||||
return pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let {
|
pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let {
|
||||||
Shell.cmd("mount | grep \"$it\"").exec().isSuccess
|
execute("mount | grep \"$it\"").isSuccess
|
||||||
} ?: false
|
} ?: false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mount(packageName: String) {
|
suspend fun mount(packageName: String) {
|
||||||
if (isAppMounted(packageName)) return
|
if (isAppMounted(packageName)) return
|
||||||
|
|
||||||
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
|
withContext(Dispatchers.IO) {
|
||||||
?: throw Exception("Failed to load application info")
|
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
|
||||||
val patchedAPK = "$modulesPath/$packageName-revanced/$packageName.apk"
|
?: throw Exception("Failed to load application info")
|
||||||
|
val patchedAPK = "$modulesPath/$packageName-revanced/$packageName.apk"
|
||||||
|
|
||||||
Shell.cmd("mount -o bind \"$patchedAPK\" \"$stockAPK\"").exec()
|
execute("mount -o bind \"$patchedAPK\" \"$stockAPK\"").assertSuccess("Failed to mount APK")
|
||||||
.also { if (!it.isSuccess) throw Exception("Failed to mount APK") }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unmount(packageName: String) {
|
suspend fun unmount(packageName: String) {
|
||||||
if (!isAppMounted(packageName)) return
|
if (!isAppMounted(packageName)) return
|
||||||
|
|
||||||
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
|
withContext(Dispatchers.IO) {
|
||||||
?: throw Exception("Failed to load application info")
|
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
|
||||||
|
?: throw Exception("Failed to load application info")
|
||||||
|
|
||||||
Shell.cmd("umount -l \"$stockAPK\"").exec()
|
execute("umount -l \"$stockAPK\"").assertSuccess("Failed to unmount APK")
|
||||||
.also { if (!it.isSuccess) throw Exception("Failed to unmount APK") }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun install(
|
suspend fun install(
|
||||||
@ -52,80 +96,77 @@ class RootInstaller(
|
|||||||
packageName: String,
|
packageName: String,
|
||||||
version: String,
|
version: String,
|
||||||
label: String
|
label: String
|
||||||
) {
|
) = withContext(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.IO) {
|
val remoteFS = awaitRemoteFS()
|
||||||
rootConnection.remoteFS?.let { remoteFS ->
|
val assets = app.assets
|
||||||
val assets = app.assets
|
val modulePath = "$modulesPath/$packageName-revanced"
|
||||||
val modulePath = "$modulesPath/$packageName-revanced"
|
|
||||||
|
|
||||||
unmount(packageName)
|
unmount(packageName)
|
||||||
|
|
||||||
stockAPK?.let { stockApp ->
|
stockAPK?.let { stockApp ->
|
||||||
pm.getPackageInfo(packageName)?.let { packageInfo ->
|
pm.getPackageInfo(packageName)?.let { packageInfo ->
|
||||||
if (packageInfo.versionName <= version)
|
if (packageInfo.versionName <= version)
|
||||||
Shell.cmd("pm uninstall -k --user 0 $packageName").exec()
|
execute("pm uninstall -k --user 0 $packageName").assertSuccess("Failed to uninstall stock app")
|
||||||
.also { if (!it.isSuccess) throw Exception("Failed to uninstall stock app") }
|
}
|
||||||
|
|
||||||
|
execute("pm install \"${stockApp.absolutePath}\"").assertSuccess("Failed to install stock app")
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteFS.getFile(modulePath).mkdir()
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
"service.sh",
|
||||||
|
"module.prop",
|
||||||
|
).forEach { file ->
|
||||||
|
assets.open("root/$file").use { inputStream ->
|
||||||
|
remoteFS.getFile("$modulePath/$file").newOutputStream()
|
||||||
|
.use { outputStream ->
|
||||||
|
val content = String(inputStream.readBytes())
|
||||||
|
.replace("__PKG_NAME__", packageName)
|
||||||
|
.replace("__VERSION__", version)
|
||||||
|
.replace("__LABEL__", label)
|
||||||
|
.toByteArray()
|
||||||
|
|
||||||
|
outputStream.write(content)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Shell.cmd("pm install \"${stockApp.absolutePath}\"").exec()
|
"$modulePath/$packageName.apk".let { apkPath ->
|
||||||
.also { if (!it.isSuccess) throw Exception("Failed to install stock app") }
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteFS.getFile(modulePath).mkdir()
|
remoteFS.getFile(patchedAPK.absolutePath)
|
||||||
|
.also { if (!it.exists()) throw Exception("File doesn't exist") }
|
||||||
listOf(
|
.newInputStream().use { inputStream ->
|
||||||
"service.sh",
|
remoteFS.getFile(apkPath).newOutputStream().use { outputStream ->
|
||||||
"module.prop",
|
inputStream.copyTo(outputStream)
|
||||||
).forEach { file ->
|
|
||||||
assets.open("root/$file").use { inputStream ->
|
|
||||||
remoteFS.getFile("$modulePath/$file").newOutputStream()
|
|
||||||
.use { outputStream ->
|
|
||||||
val content = String(inputStream.readBytes())
|
|
||||||
.replace("__PKG_NAME__", packageName)
|
|
||||||
.replace("__VERSION__", version)
|
|
||||||
.replace("__LABEL__", label)
|
|
||||||
.toByteArray()
|
|
||||||
|
|
||||||
outputStream.write(content)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"$modulePath/$packageName.apk".let { apkPath ->
|
execute(
|
||||||
|
"chmod 644 $apkPath",
|
||||||
remoteFS.getFile(patchedAPK.absolutePath)
|
"chown system:system $apkPath",
|
||||||
.also { if (!it.exists()) throw Exception("File doesn't exist") }
|
"chcon u:object_r:apk_data_file:s0 $apkPath",
|
||||||
.newInputStream().use { inputStream ->
|
"chmod +x $modulePath/service.sh"
|
||||||
remoteFS.getFile(apkPath).newOutputStream().use { outputStream ->
|
).assertSuccess("Failed to set file permissions")
|
||||||
inputStream.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Shell.cmd(
|
|
||||||
"chmod 644 $apkPath",
|
|
||||||
"chown system:system $apkPath",
|
|
||||||
"chcon u:object_r:apk_data_file:s0 $apkPath",
|
|
||||||
"chmod +x $modulePath/service.sh"
|
|
||||||
).exec()
|
|
||||||
.let { if (!it.isSuccess) throw Exception("Failed to set file permissions") }
|
|
||||||
}
|
|
||||||
} ?: throw RootServiceException()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun uninstall(packageName: String) {
|
suspend fun uninstall(packageName: String) {
|
||||||
rootConnection.remoteFS?.let { remoteFS ->
|
val remoteFS = awaitRemoteFS()
|
||||||
if (isAppMounted(packageName))
|
if (isAppMounted(packageName))
|
||||||
unmount(packageName)
|
unmount(packageName)
|
||||||
|
|
||||||
remoteFS.getFile("$modulesPath/$packageName-revanced").deleteRecursively()
|
remoteFS.getFile("$modulesPath/$packageName-revanced").deleteRecursively()
|
||||||
.also { if (!it) throw Exception("Failed to delete files") }
|
.also { if (!it) throw Exception("Failed to delete files") }
|
||||||
} ?: throw RootServiceException()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val modulesPath = "/data/adb/modules"
|
const val modulesPath = "/data/adb/modules"
|
||||||
|
|
||||||
|
private fun Shell.Result.assertSuccess(errorMessage: String) {
|
||||||
|
if (!isSuccess) throw Exception(errorMessage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RootServiceException: Exception("Root not available")
|
class RootServiceException : Exception("Root not available")
|
@ -43,21 +43,23 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
|
|||||||
|
|
||||||
suspend fun regenerate() = withContext(Dispatchers.Default) {
|
suspend fun regenerate() = withContext(Dispatchers.Default) {
|
||||||
val ks = ApkSigner.newKeyStore(
|
val ks = ApkSigner.newKeyStore(
|
||||||
listOf(
|
setOf(
|
||||||
ApkSigner.KeyStoreEntry(
|
ApkSigner.KeyStoreEntry(
|
||||||
DEFAULT, DEFAULT
|
DEFAULT, DEFAULT
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
keystorePath.outputStream().use {
|
withContext(Dispatchers.IO) {
|
||||||
ks.store(it, null)
|
keystorePath.outputStream().use {
|
||||||
|
ks.store(it, null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePrefs(DEFAULT, DEFAULT)
|
updatePrefs(DEFAULT, DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun import(cn: String, pass: String, keystore: InputStream): Boolean {
|
suspend fun import(cn: String, pass: String, keystore: InputStream): Boolean {
|
||||||
val keystoreData = keystore.readBytes()
|
val keystoreData = withContext(Dispatchers.IO) { keystore.readBytes() }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null)
|
val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null)
|
||||||
|
@ -13,7 +13,8 @@ class PreferencesManager(
|
|||||||
val api = stringPreference("api_url", "https://api.revanced.app")
|
val api = stringPreference("api_url", "https://api.revanced.app")
|
||||||
|
|
||||||
val multithreadingDexFileWriter = booleanPreference("multithreading_dex_file_writer", true)
|
val multithreadingDexFileWriter = booleanPreference("multithreading_dex_file_writer", true)
|
||||||
val allowExperimental = booleanPreference("allow_experimental", false)
|
val useProcessRuntime = booleanPreference("use_process_runtime", false)
|
||||||
|
val patcherProcessMemoryLimit = intPreference("process_runtime_memory_limit", 700)
|
||||||
|
|
||||||
val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
|
val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
|
||||||
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
|
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
|
||||||
@ -22,7 +23,10 @@ class PreferencesManager(
|
|||||||
|
|
||||||
val firstLaunch = booleanPreference("first_launch", true)
|
val firstLaunch = booleanPreference("first_launch", true)
|
||||||
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
|
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
|
||||||
|
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
|
||||||
|
|
||||||
|
val disablePatchVersionCompatCheck = booleanPreference("disable_patch_version_compatibility_check", false)
|
||||||
val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
|
val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
|
||||||
val enableSelectionWarningCountdown = booleanPreference("enable_selection_warning_countdown", true)
|
val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false)
|
||||||
|
val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true)
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,7 @@ class EditorContext(private val prefs: MutablePreferences) {
|
|||||||
|
|
||||||
abstract class Preference<T>(
|
abstract class Preference<T>(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
protected val default: T
|
val default: T
|
||||||
) {
|
) {
|
||||||
internal abstract fun Preferences.read(): T
|
internal abstract fun Preferences.read(): T
|
||||||
internal abstract fun MutablePreferences.write(value: T)
|
internal abstract fun MutablePreferences.write(value: T)
|
||||||
|
@ -4,7 +4,7 @@ import app.revanced.manager.data.room.AppDatabase
|
|||||||
import app.revanced.manager.data.room.apps.installed.AppliedPatch
|
import app.revanced.manager.data.room.apps.installed.AppliedPatch
|
||||||
import app.revanced.manager.data.room.apps.installed.InstallType
|
import app.revanced.manager.data.room.apps.installed.InstallType
|
||||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||||
import app.revanced.manager.util.PatchesSelection
|
import app.revanced.manager.util.PatchSelection
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
class InstalledAppRepository(
|
class InstalledAppRepository(
|
||||||
@ -16,7 +16,7 @@ class InstalledAppRepository(
|
|||||||
|
|
||||||
suspend fun get(packageName: String) = dao.get(packageName)
|
suspend fun get(packageName: String) = dao.get(packageName)
|
||||||
|
|
||||||
suspend fun getAppliedPatches(packageName: String): PatchesSelection =
|
suspend fun getAppliedPatches(packageName: String): PatchSelection =
|
||||||
dao.getPatchesSelection(packageName).mapValues { (_, patches) -> patches.toSet() }
|
dao.getPatchesSelection(packageName).mapValues { (_, patches) -> patches.toSet() }
|
||||||
|
|
||||||
suspend fun addOrUpdate(
|
suspend fun addOrUpdate(
|
||||||
@ -24,7 +24,7 @@ class InstalledAppRepository(
|
|||||||
originalPackageName: String,
|
originalPackageName: String,
|
||||||
version: String,
|
version: String,
|
||||||
installType: InstallType,
|
installType: InstallType,
|
||||||
patchesSelection: PatchesSelection
|
patchSelection: PatchSelection
|
||||||
) {
|
) {
|
||||||
dao.upsertApp(
|
dao.upsertApp(
|
||||||
InstalledApp(
|
InstalledApp(
|
||||||
@ -33,7 +33,7 @@ class InstalledAppRepository(
|
|||||||
version = version,
|
version = version,
|
||||||
installType = installType
|
installType = installType
|
||||||
),
|
),
|
||||||
patchesSelection.flatMap { (uid, patches) ->
|
patchSelection.flatMap { (uid, patches) ->
|
||||||
patches.map { patch ->
|
patches.map { patch ->
|
||||||
AppliedPatch(
|
AppliedPatch(
|
||||||
packageName = currentPackageName,
|
packageName = currentPackageName,
|
||||||
|
@ -5,7 +5,6 @@ import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
|
|||||||
import app.revanced.manager.data.room.bundles.PatchBundleEntity
|
import app.revanced.manager.data.room.bundles.PatchBundleEntity
|
||||||
import app.revanced.manager.data.room.bundles.Source
|
import app.revanced.manager.data.room.bundles.Source
|
||||||
import app.revanced.manager.data.room.bundles.VersionInfo
|
import app.revanced.manager.data.room.bundles.VersionInfo
|
||||||
import io.ktor.http.*
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
class PatchBundlePersistenceRepository(db: AppDatabase) {
|
class PatchBundlePersistenceRepository(db: AppDatabase) {
|
||||||
@ -23,7 +22,6 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
|
|||||||
|
|
||||||
suspend fun reset() = dao.reset()
|
suspend fun reset() = dao.reset()
|
||||||
|
|
||||||
|
|
||||||
suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) =
|
suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) =
|
||||||
PatchBundleEntity(
|
PatchBundleEntity(
|
||||||
uid = generateUid(),
|
uid = generateUid(),
|
||||||
@ -37,17 +35,19 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
|
|||||||
|
|
||||||
suspend fun delete(uid: Int) = dao.remove(uid)
|
suspend fun delete(uid: Int) = dao.remove(uid)
|
||||||
|
|
||||||
suspend fun updateVersion(uid: Int, patches: String, integrations: String) =
|
suspend fun updateVersion(uid: Int, patches: String?, integrations: String?) =
|
||||||
dao.updateVersion(uid, patches, integrations)
|
dao.updateVersion(uid, patches, integrations)
|
||||||
|
|
||||||
suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value)
|
suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value)
|
||||||
|
|
||||||
|
suspend fun setName(uid: Int, name: String) = dao.setName(uid, name)
|
||||||
|
|
||||||
fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged()
|
fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged()
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val defaultSource = PatchBundleEntity(
|
val defaultSource = PatchBundleEntity(
|
||||||
uid = 0,
|
uid = 0,
|
||||||
name = "Main",
|
name = "",
|
||||||
versionInfo = VersionInfo(),
|
versionInfo = VersionInfo(),
|
||||||
source = Source.API,
|
source = Source.API,
|
||||||
autoUpdate = false
|
autoUpdate = false
|
||||||
|
@ -3,6 +3,7 @@ package app.revanced.manager.domain.repository
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import app.revanced.library.PatchUtils
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.data.platform.NetworkInfo
|
import app.revanced.manager.data.platform.NetworkInfo
|
||||||
import app.revanced.manager.data.room.bundles.PatchBundleEntity
|
import app.revanced.manager.data.room.bundles.PatchBundleEntity
|
||||||
@ -12,6 +13,8 @@ import app.revanced.manager.data.room.bundles.Source as SourceInfo
|
|||||||
import app.revanced.manager.domain.bundles.LocalPatchBundle
|
import app.revanced.manager.domain.bundles.LocalPatchBundle
|
||||||
import app.revanced.manager.domain.bundles.RemotePatchBundle
|
import app.revanced.manager.domain.bundles.RemotePatchBundle
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource
|
import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||||
|
import app.revanced.manager.domain.manager.PreferencesManager
|
||||||
|
import app.revanced.manager.patcher.patch.PatchInfo
|
||||||
import app.revanced.manager.util.flatMapLatestAndCombine
|
import app.revanced.manager.util.flatMapLatestAndCombine
|
||||||
import app.revanced.manager.util.tag
|
import app.revanced.manager.util.tag
|
||||||
import app.revanced.manager.util.uiSafe
|
import app.revanced.manager.util.uiSafe
|
||||||
@ -29,6 +32,7 @@ class PatchBundleRepository(
|
|||||||
private val app: Application,
|
private val app: Application,
|
||||||
private val persistenceRepo: PatchBundlePersistenceRepository,
|
private val persistenceRepo: PatchBundlePersistenceRepository,
|
||||||
private val networkInfo: NetworkInfo,
|
private val networkInfo: NetworkInfo,
|
||||||
|
private val prefs: PreferencesManager,
|
||||||
) {
|
) {
|
||||||
private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
|
private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
@ -47,6 +51,37 @@ class PatchBundleRepository(
|
|||||||
it.state.map { state -> it.uid to state }
|
it.state.map { state -> it.uid to state }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val suggestedVersions = bundles.map {
|
||||||
|
val allPatches =
|
||||||
|
it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()
|
||||||
|
|
||||||
|
PatchUtils.getMostCommonCompatibleVersions(allPatches, countUnusedPatches = true)
|
||||||
|
.mapValues { (_, versions) ->
|
||||||
|
if (versions.keys.size < 2)
|
||||||
|
return@mapValues versions.keys.firstOrNull()
|
||||||
|
|
||||||
|
// The entries are ordered from most compatible to least compatible.
|
||||||
|
// If there are entries with the same number of compatible patches, older versions will be first, which is undesirable.
|
||||||
|
// This means we have to pick the last entry we find that has the highest patch count.
|
||||||
|
// The order may change in future versions of ReVanced Library.
|
||||||
|
var currentHighestPatchCount = -1
|
||||||
|
versions.entries.last { (_, patchCount) ->
|
||||||
|
if (patchCount >= currentHighestPatchCount) {
|
||||||
|
currentHighestPatchCount = patchCount
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isVersionAllowed(packageName: String, version: String) =
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
if (!prefs.suggestedVersionSafeguard.get()) return@withContext true
|
||||||
|
|
||||||
|
val suggestedVersion = suggestedVersions.first()[packageName] ?: return@withContext true
|
||||||
|
suggestedVersion == version
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the directory of the [PatchBundleSource] with the specified [uid], creating it if needed.
|
* Get the directory of the [PatchBundleSource] with the specified [uid], creating it if needed.
|
||||||
*/
|
*/
|
||||||
@ -102,16 +137,16 @@ class PatchBundleRepository(
|
|||||||
private fun addBundle(patchBundle: PatchBundleSource) =
|
private fun addBundle(patchBundle: PatchBundleSource) =
|
||||||
_sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
|
_sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
|
||||||
|
|
||||||
suspend fun createLocal(name: String, patches: InputStream, integrations: InputStream?) {
|
suspend fun createLocal(patches: InputStream, integrations: InputStream?) = withContext(Dispatchers.Default) {
|
||||||
val id = persistenceRepo.create(name, SourceInfo.Local).uid
|
val uid = persistenceRepo.create("", SourceInfo.Local).uid
|
||||||
val bundle = LocalPatchBundle(name, id, directoryOf(id))
|
val bundle = LocalPatchBundle("", uid, directoryOf(uid))
|
||||||
|
|
||||||
bundle.replace(patches, integrations)
|
bundle.replace(patches, integrations)
|
||||||
addBundle(bundle)
|
addBundle(bundle)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createRemote(name: String, url: String, autoUpdate: Boolean) {
|
suspend fun createRemote(url: String, autoUpdate: Boolean) = withContext(Dispatchers.Default) {
|
||||||
val entity = persistenceRepo.create(name, SourceInfo.from(url), autoUpdate)
|
val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate)
|
||||||
addBundle(entity.load())
|
addBundle(entity.load())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,8 +174,8 @@ class PatchBundleRepository(
|
|||||||
|
|
||||||
getBundlesByType<RemotePatchBundle>().forEach {
|
getBundlesByType<RemotePatchBundle>().forEach {
|
||||||
launch {
|
launch {
|
||||||
if (!it.propsFlow().first().autoUpdate) return@launch
|
if (!it.getProps().autoUpdate) return@launch
|
||||||
Log.d(tag, "Updating patch bundle: ${it.name}")
|
Log.d(tag, "Updating patch bundle: ${it.getName()}")
|
||||||
it.update()
|
it.update()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,14 @@
|
|||||||
package app.revanced.manager.domain.repository
|
package app.revanced.manager.domain.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import app.revanced.manager.data.room.AppDatabase
|
import app.revanced.manager.data.room.AppDatabase
|
||||||
import app.revanced.manager.data.room.options.Option
|
import app.revanced.manager.data.room.options.Option
|
||||||
import app.revanced.manager.data.room.options.OptionGroup
|
import app.revanced.manager.data.room.options.OptionGroup
|
||||||
|
import app.revanced.manager.patcher.patch.PatchInfo
|
||||||
import app.revanced.manager.util.Options
|
import app.revanced.manager.util.Options
|
||||||
|
import app.revanced.manager.util.tag
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonNull
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
|
||||||
import kotlinx.serialization.json.floatOrNull
|
|
||||||
import kotlinx.serialization.json.intOrNull
|
|
||||||
|
|
||||||
class PatchOptionsRepository(db: AppDatabase) {
|
class PatchOptionsRepository(db: AppDatabase) {
|
||||||
private val dao = db.optionDao()
|
private val dao = db.optionDao()
|
||||||
@ -24,19 +20,37 @@ class PatchOptionsRepository(db: AppDatabase) {
|
|||||||
packageName = packageName
|
packageName = packageName
|
||||||
).also { dao.createOptionGroup(it) }.uid
|
).also { dao.createOptionGroup(it) }.uid
|
||||||
|
|
||||||
suspend fun getOptions(packageName: String): Options {
|
suspend fun getOptions(
|
||||||
|
packageName: String,
|
||||||
|
bundlePatches: Map<Int, Map<String, PatchInfo>>
|
||||||
|
): Options {
|
||||||
val options = dao.getOptions(packageName)
|
val options = dao.getOptions(packageName)
|
||||||
// Bundle -> Patches
|
// Bundle -> Patches
|
||||||
return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) {
|
return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) {
|
||||||
options.forEach { (sourceUid, bundlePatchOptionsList) ->
|
options.forEach { (sourceUid, bundlePatchOptionsList) ->
|
||||||
// Patches -> Patch options
|
// Patches -> Patch options
|
||||||
this[sourceUid] = bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, option ->
|
this[sourceUid] =
|
||||||
val patchOptions = bundlePatchOptions.getOrPut(option.patchName, ::mutableMapOf)
|
bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, dbOption ->
|
||||||
|
val deserializedPatchOptions =
|
||||||
|
bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf)
|
||||||
|
|
||||||
patchOptions[option.key] = deserialize(option.value)
|
val option =
|
||||||
|
bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.key == dbOption.key }
|
||||||
|
if (option != null) {
|
||||||
|
try {
|
||||||
|
deserializedPatchOptions[option.key] =
|
||||||
|
dbOption.value.deserializeFor(option)
|
||||||
|
} catch (e: Option.SerializationException) {
|
||||||
|
Log.w(
|
||||||
|
tag,
|
||||||
|
"Option ${dbOption.patchName}:${option.key} could not be deserialized",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bundlePatchOptions
|
bundlePatchOptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -47,8 +61,12 @@ class PatchOptionsRepository(db: AppDatabase) {
|
|||||||
|
|
||||||
groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) ->
|
groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) ->
|
||||||
patchOptions.mapNotNull { (key, value) ->
|
patchOptions.mapNotNull { (key, value) ->
|
||||||
val serialized = serialize(value)
|
val serialized = try {
|
||||||
?: return@mapNotNull null // Don't save options that we can't serialize.
|
Option.SerializedValue.fromValue(value)
|
||||||
|
} catch (e: Option.SerializationException) {
|
||||||
|
Log.e(tag, "Option $patchName:$key could not be serialized", e)
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
Option(groupId, patchName, key, serialized)
|
Option(groupId, patchName, key, serialized)
|
||||||
}
|
}
|
||||||
@ -61,29 +79,4 @@ class PatchOptionsRepository(db: AppDatabase) {
|
|||||||
suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
|
suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
|
||||||
suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
|
suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
|
||||||
suspend fun reset() = dao.reset()
|
suspend fun reset() = dao.reset()
|
||||||
|
|
||||||
private companion object {
|
|
||||||
fun deserialize(value: String): Any? {
|
|
||||||
val primitive = Json.decodeFromString<JsonPrimitive>(value)
|
|
||||||
|
|
||||||
return when {
|
|
||||||
primitive.isString -> primitive.content
|
|
||||||
primitive is JsonNull -> null
|
|
||||||
else -> primitive.booleanOrNull ?: primitive.intOrNull ?: primitive.floatOrNull
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun serialize(value: Any?): String? {
|
|
||||||
val primitive = when (value) {
|
|
||||||
null -> JsonNull
|
|
||||||
is String -> JsonPrimitive(value)
|
|
||||||
is Int -> JsonPrimitive(value)
|
|
||||||
is Float -> JsonPrimitive(value)
|
|
||||||
is Boolean -> JsonPrimitive(value)
|
|
||||||
else -> return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return Json.encodeToString(primitive)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -1,8 +1,10 @@
|
|||||||
package app.revanced.manager.network.api
|
package app.revanced.manager.network.api
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import app.revanced.manager.domain.manager.PreferencesManager
|
import app.revanced.manager.domain.manager.PreferencesManager
|
||||||
import app.revanced.manager.network.dto.ReVancedRelease
|
import app.revanced.manager.network.dto.ReVancedRelease
|
||||||
import app.revanced.manager.network.service.ReVancedService
|
import app.revanced.manager.network.service.ReVancedService
|
||||||
|
import app.revanced.manager.network.utils.getOrThrow
|
||||||
import app.revanced.manager.network.utils.transform
|
import app.revanced.manager.network.utils.transform
|
||||||
|
|
||||||
class ReVancedAPI(
|
class ReVancedAPI(
|
||||||
@ -13,12 +15,23 @@ class ReVancedAPI(
|
|||||||
|
|
||||||
suspend fun getContributors() = service.getContributors(apiUrl()).transform { it.repositories }
|
suspend fun getContributors() = service.getContributors(apiUrl()).transform { it.repositories }
|
||||||
|
|
||||||
suspend fun getLatestRelease(name: String) = service.getLatestRelease(apiUrl(), name).transform { it.release }
|
suspend fun getLatestRelease(name: String) =
|
||||||
|
service.getLatestRelease(apiUrl(), name).transform { it.release }
|
||||||
|
|
||||||
|
suspend fun getReleases(name: String) =
|
||||||
|
service.getReleases(apiUrl(), name).transform { it.releases }
|
||||||
|
|
||||||
|
suspend fun getAppUpdate() =
|
||||||
|
getLatestRelease("revanced-manager")
|
||||||
|
.getOrThrow()
|
||||||
|
.takeIf { it.version != Build.VERSION.RELEASE }
|
||||||
|
|
||||||
|
suspend fun getInfo(api: String? = null) = service.getInfo(api ?: apiUrl()).transform { it.info }
|
||||||
|
|
||||||
suspend fun getReleases(name: String) = service.getReleases(apiUrl(), name).transform { it.releases }
|
|
||||||
|
|
||||||
companion object Extensions {
|
companion object Extensions {
|
||||||
fun ReVancedRelease.findAssetByType(mime: String) = assets.singleOrNull { it.contentType == mime } ?: throw MissingAssetException(mime)
|
fun ReVancedRelease.findAssetByType(mime: String) =
|
||||||
|
assets.singleOrNull { it.contentType == mime } ?: throw MissingAssetException(mime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
|||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun getAppLink(packageName: String): String {
|
private suspend fun getAppLink(packageName: String): String {
|
||||||
val searchResults = httpClient.getHtml { url("$apkMirror/?post_type=app_release&searchtype=app&s=$packageName") }
|
val searchResults = httpClient.getHtml { url("$APK_MIRROR/?post_type=app_release&searchtype=app&s=$packageName") }
|
||||||
.div {
|
.div {
|
||||||
withId = "content"
|
withId = "content"
|
||||||
findFirst {
|
findFirst {
|
||||||
@ -66,7 +66,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return searchResults.find { url ->
|
return searchResults.find { url ->
|
||||||
httpClient.getHtml { url(apkMirror + url) }
|
httpClient.getHtml { url(APK_MIRROR + url) }
|
||||||
.div {
|
.div {
|
||||||
withId = "primary"
|
withId = "primary"
|
||||||
findFirst {
|
findFirst {
|
||||||
@ -90,11 +90,12 @@ class APKMirror : AppDownloader, KoinComponent {
|
|||||||
|
|
||||||
override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow<AppDownloader.App> {
|
override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow<AppDownloader.App> {
|
||||||
|
|
||||||
// Vanced music uses the same package name so we have to hardcode...
|
// We have to hardcode some apps since there are multiple apps with that package name
|
||||||
val appCategory = if (packageName == "com.google.android.apps.youtube.music")
|
val appCategory = when (packageName) {
|
||||||
"youtube-music"
|
"com.google.android.apps.youtube.music" -> "youtube-music"
|
||||||
else
|
"com.google.android.youtube" -> "youtube"
|
||||||
getAppLink(packageName).split("/")[3]
|
else -> getAppLink(packageName).split("/")[3]
|
||||||
|
}
|
||||||
|
|
||||||
var page = 1
|
var page = 1
|
||||||
|
|
||||||
@ -107,7 +108,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
|||||||
page <= 1
|
page <= 1
|
||||||
) {
|
) {
|
||||||
httpClient.getHtml {
|
httpClient.getHtml {
|
||||||
url("$apkMirror/uploads/page/$page/")
|
url("$APK_MIRROR/uploads/page/$page/")
|
||||||
parameter("appcategory", appCategory)
|
parameter("appcategory", appCategory)
|
||||||
}.div {
|
}.div {
|
||||||
withClass = "widget_appmanager_recentpostswidget"
|
withClass = "widget_appmanager_recentpostswidget"
|
||||||
@ -172,7 +173,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
|||||||
preferSplit: Boolean,
|
preferSplit: Boolean,
|
||||||
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit
|
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit
|
||||||
) {
|
) {
|
||||||
val variants = httpClient.getHtml { url(apkMirror + downloadLink) }
|
val variants = httpClient.getHtml { url(APK_MIRROR + downloadLink) }
|
||||||
.div {
|
.div {
|
||||||
withClass = "variants-table"
|
withClass = "variants-table"
|
||||||
findFirst { // list of variants
|
findFirst { // list of variants
|
||||||
@ -217,7 +218,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
|||||||
|
|
||||||
if (variant.apkType == APKType.BUNDLE) throw Exception("Split apks are not supported yet") // TODO
|
if (variant.apkType == APKType.BUNDLE) throw Exception("Split apks are not supported yet") // TODO
|
||||||
|
|
||||||
val downloadPage = httpClient.getHtml { url(apkMirror + variant.link) }
|
val downloadPage = httpClient.getHtml { url(APK_MIRROR + variant.link) }
|
||||||
.a {
|
.a {
|
||||||
withClass = "downloadButton"
|
withClass = "downloadButton"
|
||||||
findFirst {
|
findFirst {
|
||||||
@ -225,7 +226,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val downloadLink = httpClient.getHtml { url(apkMirror + downloadPage) }
|
val downloadLink = httpClient.getHtml { url(APK_MIRROR + downloadPage) }
|
||||||
.form {
|
.form {
|
||||||
withId = "filedownload"
|
withId = "filedownload"
|
||||||
findFirst {
|
findFirst {
|
||||||
@ -250,7 +251,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
httpClient.download(targetFile) {
|
httpClient.download(targetFile) {
|
||||||
url(apkMirror + downloadLink)
|
url(APK_MIRROR + downloadLink)
|
||||||
onDownload { bytesSentTotal, contentLength ->
|
onDownload { bytesSentTotal, contentLength ->
|
||||||
onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
|
onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
|
||||||
}
|
}
|
||||||
@ -268,7 +269,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val apkMirror = "https://www.apkmirror.com"
|
const val APK_MIRROR = "https://www.apkmirror.com"
|
||||||
|
|
||||||
val supportedArches = listOf("universal", "noarch") + SUPPORTED_ABIS
|
val supportedArches = listOf("universal", "noarch") + SUPPORTED_ABIS
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,56 @@
|
|||||||
|
package app.revanced.manager.network.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReVancedInfoParent(
|
||||||
|
val info: ReVancedInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReVancedInfo(
|
||||||
|
val name: String,
|
||||||
|
val about: String,
|
||||||
|
val branding: ReVancedBranding,
|
||||||
|
val contact: ReVancedContact,
|
||||||
|
val socials: List<ReVancedSocial>,
|
||||||
|
val donations: ReVancedDonation,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReVancedBranding(
|
||||||
|
val logo: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReVancedContact(
|
||||||
|
val email: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReVancedSocial(
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
val preferred: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReVancedDonation(
|
||||||
|
val wallets: List<ReVancedWallet>,
|
||||||
|
val links: List<ReVancedDonationLink>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReVancedWallet(
|
||||||
|
val network: String,
|
||||||
|
val currency_code: String,
|
||||||
|
val address: String,
|
||||||
|
val preferred: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReVancedDonationLink(
|
||||||
|
val name: String,
|
||||||
|
val url: String,
|
||||||
|
val preferred: Boolean,
|
||||||
|
)
|
@ -17,7 +17,9 @@ data class ReVancedReleases(
|
|||||||
data class ReVancedRelease(
|
data class ReVancedRelease(
|
||||||
val metadata: ReVancedReleaseMeta,
|
val metadata: ReVancedReleaseMeta,
|
||||||
val assets: List<Asset>
|
val assets: List<Asset>
|
||||||
)
|
) {
|
||||||
|
val version get() = metadata.tag
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ReVancedReleaseMeta(
|
data class ReVancedReleaseMeta(
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
package app.revanced.manager.network.service
|
package app.revanced.manager.network.service
|
||||||
|
|
||||||
import app.revanced.manager.network.dto.ReVancedLatestRelease
|
|
||||||
import app.revanced.manager.network.dto.ReVancedGitRepositories
|
import app.revanced.manager.network.dto.ReVancedGitRepositories
|
||||||
|
import app.revanced.manager.network.dto.ReVancedInfo
|
||||||
|
import app.revanced.manager.network.dto.ReVancedInfoParent
|
||||||
|
import app.revanced.manager.network.dto.ReVancedLatestRelease
|
||||||
import app.revanced.manager.network.dto.ReVancedReleases
|
import app.revanced.manager.network.dto.ReVancedReleases
|
||||||
import app.revanced.manager.network.utils.APIResponse
|
import app.revanced.manager.network.utils.APIResponse
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.url
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@ -31,4 +33,11 @@ class ReVancedService(
|
|||||||
url("$api/contributors")
|
url("$api/contributors")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getInfo(api: String): APIResponse<ReVancedInfoParent> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
client.request {
|
||||||
|
url("$api/v2/info")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
package app.revanced.manager.patcher
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
abstract class LibraryResolver {
|
||||||
|
protected fun findLibrary(context: Context, searchTerm: String): File? = File(context.applicationInfo.nativeLibraryDir).run {
|
||||||
|
list { _, f -> !File(f).isDirectory && f.contains(searchTerm) }?.firstOrNull()?.let { resolve(it) }
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,12 @@
|
|||||||
package app.revanced.manager.patcher
|
package app.revanced.manager.patcher
|
||||||
|
|
||||||
import app.revanced.library.ApkUtils
|
import android.content.Context
|
||||||
import app.revanced.manager.ui.viewmodel.ManagerLogger
|
import app.revanced.library.ApkUtils.applyTo
|
||||||
|
import app.revanced.manager.R
|
||||||
|
import app.revanced.manager.patcher.logger.Logger
|
||||||
|
import app.revanced.manager.ui.model.State
|
||||||
import app.revanced.patcher.Patcher
|
import app.revanced.patcher.Patcher
|
||||||
import app.revanced.patcher.PatcherOptions
|
import app.revanced.patcher.PatcherConfig
|
||||||
import app.revanced.patcher.patch.Patch
|
import app.revanced.patcher.patch.Patch
|
||||||
import app.revanced.patcher.patch.PatchResult
|
import app.revanced.patcher.patch.PatchResult
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -12,7 +15,6 @@ import java.io.Closeable
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.StandardCopyOption
|
import java.nio.file.StandardCopyOption
|
||||||
import java.util.logging.Logger
|
|
||||||
|
|
||||||
internal typealias PatchList = List<Patch<*>>
|
internal typealias PatchList = List<Patch<*>>
|
||||||
|
|
||||||
@ -21,67 +23,108 @@ class Session(
|
|||||||
frameworkDir: String,
|
frameworkDir: String,
|
||||||
aaptPath: String,
|
aaptPath: String,
|
||||||
multithreadingDexFileWriter: Boolean,
|
multithreadingDexFileWriter: Boolean,
|
||||||
private val logger: ManagerLogger,
|
private val androidContext: Context,
|
||||||
|
private val logger: Logger,
|
||||||
private val input: File,
|
private val input: File,
|
||||||
private val onStepSucceeded: suspend () -> Unit
|
private val onPatchCompleted: () -> Unit,
|
||||||
|
private val onProgress: (name: String?, state: State?, message: String?) -> Unit
|
||||||
) : Closeable {
|
) : Closeable {
|
||||||
|
private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
|
||||||
|
onProgress(name, state, message)
|
||||||
|
|
||||||
private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
|
private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
|
||||||
private val patcher = Patcher(
|
private val patcher = Patcher(
|
||||||
PatcherOptions(
|
PatcherConfig(
|
||||||
inputFile = input,
|
apkFile = input,
|
||||||
resourceCachePath = tempDir.resolve("aapt-resources"),
|
temporaryFilesPath = tempDir,
|
||||||
frameworkFileDirectory = frameworkDir,
|
frameworkFileDirectory = frameworkDir,
|
||||||
aaptBinaryPath = aaptPath,
|
aaptBinaryPath = aaptPath,
|
||||||
multithreadingDexFileWriter = multithreadingDexFileWriter,
|
multithreadingDexFileWriter = multithreadingDexFileWriter,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private suspend fun Patcher.applyPatchesVerbose(selectedPatches: PatchList) {
|
||||||
|
var nextPatchIndex = 0
|
||||||
|
|
||||||
|
updateProgress(
|
||||||
|
name = androidContext.getString(R.string.executing_patch, selectedPatches[nextPatchIndex]),
|
||||||
|
state = State.RUNNING
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun Patcher.applyPatchesVerbose() {
|
|
||||||
this.apply(true).collect { (patch, exception) ->
|
this.apply(true).collect { (patch, exception) ->
|
||||||
if (exception == null) {
|
if (patch !in selectedPatches) return@collect
|
||||||
logger.info("$patch succeeded")
|
|
||||||
onStepSucceeded()
|
if (exception != null) {
|
||||||
return@collect
|
updateProgress(
|
||||||
|
name = androidContext.getString(R.string.failed_to_execute_patch, patch.name),
|
||||||
|
state = State.FAILED,
|
||||||
|
message = exception.stackTraceToString()
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.error("${patch.name} failed:")
|
||||||
|
logger.error(exception.stackTraceToString())
|
||||||
|
throw exception
|
||||||
}
|
}
|
||||||
logger.error("$patch failed:")
|
|
||||||
logger.error(exception.stackTraceToString())
|
nextPatchIndex++
|
||||||
throw exception
|
|
||||||
|
onPatchCompleted()
|
||||||
|
|
||||||
|
selectedPatches.getOrNull(nextPatchIndex)?.let { nextPatch ->
|
||||||
|
updateProgress(
|
||||||
|
name = androidContext.getString(R.string.executing_patch, nextPatch.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("${patch.name} succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateProgress(
|
||||||
|
state = State.COMPLETED,
|
||||||
|
name = androidContext.resources.getQuantityString(
|
||||||
|
R.plurals.patches_executed,
|
||||||
|
selectedPatches.size,
|
||||||
|
selectedPatches.size
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun run(output: File, selectedPatches: PatchList, integrations: List<File>) {
|
suspend fun run(output: File, selectedPatches: PatchList, integrations: List<File>) {
|
||||||
onStepSucceeded() // Unpacking
|
updateProgress(state = State.COMPLETED) // Unpacking
|
||||||
Logger.getLogger("").apply {
|
|
||||||
|
java.util.logging.Logger.getLogger("").apply {
|
||||||
handlers.forEach {
|
handlers.forEach {
|
||||||
it.close()
|
it.close()
|
||||||
removeHandler(it)
|
removeHandler(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
addHandler(logger)
|
addHandler(logger.handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
with(patcher) {
|
with(patcher) {
|
||||||
logger.info("Merging integrations")
|
logger.info("Merging integrations")
|
||||||
acceptIntegrations(integrations)
|
acceptIntegrations(integrations.toSet())
|
||||||
acceptPatches(selectedPatches)
|
acceptPatches(selectedPatches.toSet())
|
||||||
onStepSucceeded() // Merging
|
|
||||||
|
|
||||||
logger.info("Applying patches...")
|
logger.info("Applying patches...")
|
||||||
applyPatchesVerbose()
|
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Writing patched files...")
|
logger.info("Writing patched files...")
|
||||||
val result = patcher.get()
|
val result = patcher.get()
|
||||||
|
|
||||||
val aligned = tempDir.resolve("aligned.apk")
|
val patched = tempDir.resolve("result.apk")
|
||||||
ApkUtils.copyAligned(input, aligned, result)
|
withContext(Dispatchers.IO) {
|
||||||
|
Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
|
}
|
||||||
|
result.applyTo(patched)
|
||||||
|
|
||||||
logger.info("Patched apk saved to $aligned")
|
logger.info("Patched apk saved to $patched")
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
Files.move(aligned.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
Files.move(patched.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||||
}
|
}
|
||||||
onStepSucceeded() // Saving
|
updateProgress(state = State.COMPLETED) // Saving
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
@ -90,7 +133,7 @@ class Session(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
operator fun PatchResult.component1() = patch.name
|
operator fun PatchResult.component1() = patch
|
||||||
operator fun PatchResult.component2() = exception
|
operator fun PatchResult.component2() = exception
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,18 +1,12 @@
|
|||||||
package app.revanced.manager.patcher.aapt
|
package app.revanced.manager.patcher.aapt
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import app.revanced.manager.patcher.LibraryResolver
|
||||||
import android.os.Build.SUPPORTED_ABIS as DEVICE_ABIS
|
import android.os.Build.SUPPORTED_ABIS as DEVICE_ABIS
|
||||||
import java.io.File
|
object Aapt : LibraryResolver() {
|
||||||
|
private val WORKING_ABIS = setOf("arm64-v8a", "x86", "x86_64", "armeabi-v7a")
|
||||||
object Aapt {
|
|
||||||
private val WORKING_ABIS = setOf("arm64-v8a", "x86", "x86_64")
|
|
||||||
|
|
||||||
fun supportsDevice() = (DEVICE_ABIS intersect WORKING_ABIS).isNotEmpty()
|
fun supportsDevice() = (DEVICE_ABIS intersect WORKING_ABIS).isNotEmpty()
|
||||||
|
|
||||||
fun binary(context: Context): File? {
|
fun binary(context: Context) = findLibrary(context, "aapt")
|
||||||
return File(context.applicationInfo.nativeLibraryDir).resolveAapt()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun File.resolveAapt() =
|
|
||||||
list { _, f -> !File(f).isDirectory && f.contains("aapt") }?.firstOrNull()?.let { resolve(it) }
|
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
package app.revanced.manager.patcher.logger
|
||||||
|
|
||||||
|
import java.util.logging.Handler
|
||||||
|
import java.util.logging.Level
|
||||||
|
import java.util.logging.LogRecord
|
||||||
|
|
||||||
|
abstract class Logger {
|
||||||
|
abstract fun log(level: LogLevel, message: String)
|
||||||
|
|
||||||
|
fun trace(msg: String) = log(LogLevel.TRACE, msg)
|
||||||
|
fun info(msg: String) = log(LogLevel.INFO, msg)
|
||||||
|
fun warn(msg: String) = log(LogLevel.WARN, msg)
|
||||||
|
fun error(msg: String) = log(LogLevel.ERROR, msg)
|
||||||
|
|
||||||
|
val handler = object : Handler() {
|
||||||
|
override fun publish(record: LogRecord) {
|
||||||
|
val msg = record.message
|
||||||
|
|
||||||
|
when (record.level) {
|
||||||
|
Level.INFO -> info(msg)
|
||||||
|
Level.SEVERE -> error(msg)
|
||||||
|
Level.WARNING -> warn(msg)
|
||||||
|
else -> trace(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun flush() = Unit
|
||||||
|
override fun close() = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LogLevel {
|
||||||
|
TRACE,
|
||||||
|
INFO,
|
||||||
|
WARN,
|
||||||
|
ERROR,
|
||||||
|
}
|
@ -5,20 +5,21 @@ import app.revanced.manager.util.tag
|
|||||||
import app.revanced.patcher.PatchBundleLoader
|
import app.revanced.patcher.PatchBundleLoader
|
||||||
import app.revanced.patcher.patch.Patch
|
import app.revanced.patcher.patch.Patch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.jar.JarFile
|
||||||
|
|
||||||
class PatchBundle(private val loader: Iterable<Patch<*>>, val integrations: File?) {
|
class PatchBundle(val patchesJar: File, val integrations: File?) {
|
||||||
constructor(bundleJar: File, integrations: File?) : this(
|
private val loader = object : Iterable<Patch<*>> {
|
||||||
object : Iterable<Patch<*>> {
|
private fun load(): Iterable<Patch<*>> {
|
||||||
private fun load(): Iterable<Patch<*>> {
|
patchesJar.setReadOnly()
|
||||||
bundleJar.setReadOnly()
|
return PatchBundleLoader.Dex(patchesJar, optimizedDexDirectory = null)
|
||||||
return PatchBundleLoader.Dex(bundleJar, optimizedDexDirectory = null)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun iterator(): Iterator<Patch<*>> = load().iterator()
|
override fun iterator(): Iterator<Patch<*>> = load().iterator()
|
||||||
},
|
}
|
||||||
integrations
|
|
||||||
) {
|
init {
|
||||||
Log.d(tag, "Loaded patch bundle: $bundleJar")
|
Log.d(tag, "Loaded patch bundle: $patchesJar")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,6 +27,17 @@ class PatchBundle(private val loader: Iterable<Patch<*>>, val integrations: File
|
|||||||
*/
|
*/
|
||||||
val patches = loader.map(::PatchInfo)
|
val patches = loader.map(::PatchInfo)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The [java.util.jar.Manifest] of [patchesJar].
|
||||||
|
*/
|
||||||
|
private val manifest = try {
|
||||||
|
JarFile(patchesJar).use { it.manifest }
|
||||||
|
} catch (_: IOException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all patches compatible with the specified package.
|
* Load all patches compatible with the specified package.
|
||||||
*/
|
*/
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
package app.revanced.manager.patcher.patch
|
package app.revanced.manager.patcher.patch
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import app.revanced.patcher.data.ResourceContext
|
||||||
import app.revanced.patcher.patch.Patch
|
import app.revanced.patcher.patch.Patch
|
||||||
|
import app.revanced.patcher.patch.ResourcePatch
|
||||||
import app.revanced.patcher.patch.options.PatchOption
|
import app.revanced.patcher.patch.options.PatchOption
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
@ -13,7 +15,7 @@ data class PatchInfo(
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val include: Boolean,
|
val include: Boolean,
|
||||||
val compatiblePackages: ImmutableList<CompatiblePackage>?,
|
val compatiblePackages: ImmutableList<CompatiblePackage>?,
|
||||||
val options: ImmutableList<Option>?
|
val options: ImmutableList<Option<*>>?
|
||||||
) {
|
) {
|
||||||
constructor(patch: Patch<*>) : this(
|
constructor(patch: Patch<*>) : this(
|
||||||
patch.name.orEmpty(),
|
patch.name.orEmpty(),
|
||||||
@ -37,6 +39,23 @@ data class PatchInfo(
|
|||||||
pkg.versions == null || pkg.versions.contains(versionName)
|
pkg.versions == null || pkg.versions.contains(versionName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fake [Patch] with the same metadata as the [PatchInfo] instance.
|
||||||
|
* The resulting patch cannot be executed.
|
||||||
|
* This is necessary because some functions in ReVanced Library only accept full [Patch] objects.
|
||||||
|
*/
|
||||||
|
fun toPatcherPatch(): Patch<*> = object : ResourcePatch(
|
||||||
|
name = name,
|
||||||
|
description = description,
|
||||||
|
compatiblePackages = compatiblePackages
|
||||||
|
?.map(app.revanced.manager.patcher.patch.CompatiblePackage::toPatcherCompatiblePackage)
|
||||||
|
?.toSet(),
|
||||||
|
use = include,
|
||||||
|
) {
|
||||||
|
override fun execute(context: ResourceContext) =
|
||||||
|
throw Exception("Metadata patches cannot be executed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
@ -48,23 +67,35 @@ data class CompatiblePackage(
|
|||||||
pkg.name,
|
pkg.name,
|
||||||
pkg.versions?.toImmutableSet()
|
pkg.versions?.toImmutableSet()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts this [CompatiblePackage] into a [Patch.CompatiblePackage] from patcher.
|
||||||
|
*/
|
||||||
|
fun toPatcherCompatiblePackage() = Patch.CompatiblePackage(
|
||||||
|
name = packageName,
|
||||||
|
versions = versions,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class Option(
|
data class Option<T>(
|
||||||
val title: String,
|
val title: String,
|
||||||
val key: String,
|
val key: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val required: Boolean,
|
val required: Boolean,
|
||||||
val type: String,
|
val type: String,
|
||||||
val default: Any?
|
val default: T?,
|
||||||
|
val presets: Map<String, T?>?,
|
||||||
|
val validator: (T?) -> Boolean,
|
||||||
) {
|
) {
|
||||||
constructor(option: PatchOption<*>) : this(
|
constructor(option: PatchOption<T>) : this(
|
||||||
option.title ?: option.key,
|
option.title ?: option.key,
|
||||||
option.key,
|
option.key,
|
||||||
option.description.orEmpty(),
|
option.description.orEmpty(),
|
||||||
option.required,
|
option.required,
|
||||||
option.valueType,
|
option.valueType,
|
||||||
option.default,
|
option.default,
|
||||||
|
option.values,
|
||||||
|
{ option.validator(option, it) },
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
package app.revanced.manager.patcher.runtime
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.revanced.manager.patcher.Session
|
||||||
|
import app.revanced.manager.patcher.logger.Logger
|
||||||
|
import app.revanced.manager.patcher.worker.ProgressEventHandler
|
||||||
|
import app.revanced.manager.ui.model.State
|
||||||
|
import app.revanced.manager.util.Options
|
||||||
|
import app.revanced.manager.util.PatchSelection
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple [Runtime] implementation that runs the patcher using coroutines.
|
||||||
|
*/
|
||||||
|
class CoroutineRuntime(private val context: Context) : Runtime(context) {
|
||||||
|
override suspend fun execute(
|
||||||
|
inputFile: String,
|
||||||
|
outputFile: String,
|
||||||
|
packageName: String,
|
||||||
|
selectedPatches: PatchSelection,
|
||||||
|
options: Options,
|
||||||
|
logger: Logger,
|
||||||
|
onPatchCompleted: () -> Unit,
|
||||||
|
onProgress: ProgressEventHandler,
|
||||||
|
) {
|
||||||
|
val bundles = bundles()
|
||||||
|
|
||||||
|
val selectedBundles = selectedPatches.keys
|
||||||
|
val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
|
||||||
|
.mapValues { (_, bundle) -> bundle.patchClasses(packageName) }
|
||||||
|
|
||||||
|
val patchList = selectedPatches.flatMap { (bundle, selected) ->
|
||||||
|
allPatches[bundle]?.filter { selected.contains(it.name) }
|
||||||
|
?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
|
||||||
|
|
||||||
|
// Set all patch options.
|
||||||
|
options.forEach { (bundle, bundlePatchOptions) ->
|
||||||
|
val patches = allPatches[bundle] ?: return@forEach
|
||||||
|
bundlePatchOptions.forEach { (patchName, configuredPatchOptions) ->
|
||||||
|
val patchOptions = patches.single { it.name == patchName }.options
|
||||||
|
configuredPatchOptions.forEach { (key, value) ->
|
||||||
|
patchOptions[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(null, State.COMPLETED, null) // Loading patches
|
||||||
|
|
||||||
|
Session(
|
||||||
|
cacheDir,
|
||||||
|
frameworkPath,
|
||||||
|
aaptPath,
|
||||||
|
enableMultithreadedDexWriter(),
|
||||||
|
context,
|
||||||
|
logger,
|
||||||
|
File(inputFile),
|
||||||
|
onPatchCompleted = onPatchCompleted,
|
||||||
|
onProgress
|
||||||
|
).use { session ->
|
||||||
|
session.run(
|
||||||
|
File(outputFile),
|
||||||
|
patchList,
|
||||||
|
integrations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,188 @@
|
|||||||
|
package app.revanced.manager.patcher.runtime
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import app.revanced.manager.BuildConfig
|
||||||
|
import app.revanced.manager.patcher.runtime.process.IPatcherEvents
|
||||||
|
import app.revanced.manager.patcher.runtime.process.IPatcherProcess
|
||||||
|
import app.revanced.manager.patcher.LibraryResolver
|
||||||
|
import app.revanced.manager.patcher.logger.Logger
|
||||||
|
import app.revanced.manager.patcher.runtime.process.Parameters
|
||||||
|
import app.revanced.manager.patcher.runtime.process.PatchConfiguration
|
||||||
|
import app.revanced.manager.patcher.runtime.process.PatcherProcess
|
||||||
|
import app.revanced.manager.patcher.worker.ProgressEventHandler
|
||||||
|
import app.revanced.manager.ui.model.State
|
||||||
|
import app.revanced.manager.util.Options
|
||||||
|
import app.revanced.manager.util.PM
|
||||||
|
import app.revanced.manager.util.PatchSelection
|
||||||
|
import app.revanced.manager.util.tag
|
||||||
|
import com.github.pgreze.process.Redirect
|
||||||
|
import com.github.pgreze.process.process
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the patcher in another process by using the app_process binary and IPC.
|
||||||
|
*/
|
||||||
|
class ProcessRuntime(private val context: Context) : Runtime(context) {
|
||||||
|
private val pm: PM by inject()
|
||||||
|
|
||||||
|
private suspend fun awaitBinderConnection(): IPatcherProcess {
|
||||||
|
val binderFuture = CompletableDeferred<IPatcherProcess>()
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
val binder =
|
||||||
|
intent.getBundleExtra(INTENT_BUNDLE_KEY)?.getBinder(BUNDLE_BINDER_KEY)!!
|
||||||
|
|
||||||
|
binderFuture.complete(IPatcherProcess.Stub.asInterface(binder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContextCompat.registerReceiver(context, receiver, IntentFilter().apply {
|
||||||
|
addAction(CONNECT_TO_APP_ACTION)
|
||||||
|
}, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
withTimeout(10000L) {
|
||||||
|
binderFuture.await()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
context.unregisterReceiver(receiver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun execute(
|
||||||
|
inputFile: String,
|
||||||
|
outputFile: String,
|
||||||
|
packageName: String,
|
||||||
|
selectedPatches: PatchSelection,
|
||||||
|
options: Options,
|
||||||
|
logger: Logger,
|
||||||
|
onPatchCompleted: () -> Unit,
|
||||||
|
onProgress: ProgressEventHandler,
|
||||||
|
) = coroutineScope {
|
||||||
|
// Get the location of our own Apk.
|
||||||
|
val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo.sourceDir
|
||||||
|
|
||||||
|
val limit = "${prefs.patcherProcessMemoryLimit.get()}M"
|
||||||
|
val propOverride = resolvePropOverride(context)?.absolutePath
|
||||||
|
?: throw Exception("Couldn't find prop override library")
|
||||||
|
|
||||||
|
val env =
|
||||||
|
System.getenv().toMutableMap().apply {
|
||||||
|
putAll(
|
||||||
|
mapOf(
|
||||||
|
"CLASSPATH" to managerBaseApk,
|
||||||
|
// Override the props used by ART to set the memory limit.
|
||||||
|
"LD_PRELOAD" to propOverride,
|
||||||
|
"PROP_dalvik.vm.heapgrowthlimit" to limit,
|
||||||
|
"PROP_dalvik.vm.heapsize" to limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
val result = process(
|
||||||
|
APP_PROCESS_BIN_PATH,
|
||||||
|
"-Djava.io.tmpdir=$cacheDir", // The process will use /tmp if this isn't set, which is a problem because that folder is not accessible on Android.
|
||||||
|
"/", // The unused cmd-dir parameter
|
||||||
|
"--nice-name=${context.packageName}:Patcher",
|
||||||
|
PatcherProcess::class.java.name, // The class with the main function.
|
||||||
|
context.packageName,
|
||||||
|
env = env,
|
||||||
|
stdout = Redirect.CAPTURE,
|
||||||
|
stderr = Redirect.CAPTURE,
|
||||||
|
) { line ->
|
||||||
|
// The process shouldn't generally be writing to stdio. Log any lines we get as warnings.
|
||||||
|
logger.warn("[STDIO]: $line")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "Process finished with exit code ${result.resultCode}")
|
||||||
|
|
||||||
|
if (result.resultCode != 0) throw Exception("Process exited with nonzero exit code ${result.resultCode}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val patching = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
val binder = awaitBinderConnection()
|
||||||
|
|
||||||
|
// Android Studio's fast deployment feature causes an issue where the other process will be running older code compared to the main process.
|
||||||
|
// The patcher process is running outdated code if the randomly generated BUILD_ID numbers don't match.
|
||||||
|
// To fix it, clear the cache in the Android settings or disable fast deployment (Run configurations -> Edit Configurations -> app -> Enable "always deploy with package manager").
|
||||||
|
if (binder.buildId() != BuildConfig.BUILD_ID) throw Exception("app_process is running outdated code. Clear the app cache or disable disable Android 11 deployment optimizations in your IDE")
|
||||||
|
|
||||||
|
val eventHandler = object : IPatcherEvents.Stub() {
|
||||||
|
override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg)
|
||||||
|
|
||||||
|
override fun patchSucceeded() = onPatchCompleted()
|
||||||
|
|
||||||
|
override fun progress(name: String?, state: String?, msg: String?) =
|
||||||
|
onProgress(name, state?.let { enumValueOf<State>(it) }, msg)
|
||||||
|
|
||||||
|
override fun finished(exceptionStackTrace: String?) {
|
||||||
|
binder.exit()
|
||||||
|
|
||||||
|
exceptionStackTrace?.let {
|
||||||
|
patching.completeExceptionally(RemoteFailureException(it))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patching.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bundles = bundles()
|
||||||
|
|
||||||
|
val parameters = Parameters(
|
||||||
|
aaptPath = aaptPath,
|
||||||
|
frameworkDir = frameworkPath,
|
||||||
|
cacheDir = cacheDir,
|
||||||
|
packageName = packageName,
|
||||||
|
inputFile = inputFile,
|
||||||
|
outputFile = outputFile,
|
||||||
|
enableMultithrededDexWriter = enableMultithreadedDexWriter(),
|
||||||
|
configurations = selectedPatches.map { (id, patches) ->
|
||||||
|
val bundle = bundles[id]!!
|
||||||
|
|
||||||
|
PatchConfiguration(
|
||||||
|
bundle.patchesJar.absolutePath,
|
||||||
|
bundle.integrations?.absolutePath,
|
||||||
|
patches,
|
||||||
|
options[id].orEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
binder.start(parameters, eventHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until patching finishes.
|
||||||
|
patching.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object : LibraryResolver() {
|
||||||
|
private const val APP_PROCESS_BIN_PATH = "/system/bin/app_process"
|
||||||
|
|
||||||
|
const val CONNECT_TO_APP_ACTION = "CONNECT_TO_APP_ACTION"
|
||||||
|
const val INTENT_BUNDLE_KEY = "BUNDLE"
|
||||||
|
const val BUNDLE_BINDER_KEY = "BINDER"
|
||||||
|
|
||||||
|
private fun resolvePropOverride(context: Context) = findLibrary(context, "prop_override")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An [Exception] occured in the remote process while patching.
|
||||||
|
*
|
||||||
|
* @param originalStackTrace The stack trace of the original [Exception].
|
||||||
|
*/
|
||||||
|
class RemoteFailureException(val originalStackTrace: String) : Exception()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
|||||||
|
package app.revanced.manager.patcher.runtime
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.revanced.manager.data.platform.Filesystem
|
||||||
|
import app.revanced.manager.domain.manager.PreferencesManager
|
||||||
|
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||||
|
import app.revanced.manager.patcher.aapt.Aapt
|
||||||
|
import app.revanced.manager.patcher.logger.Logger
|
||||||
|
import app.revanced.manager.patcher.worker.ProgressEventHandler
|
||||||
|
import app.revanced.manager.util.Options
|
||||||
|
import app.revanced.manager.util.PatchSelection
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
|
sealed class Runtime(context: Context) : KoinComponent {
|
||||||
|
private val fs: Filesystem by inject()
|
||||||
|
private val patchBundlesRepo: PatchBundleRepository by inject()
|
||||||
|
protected val prefs: PreferencesManager by inject()
|
||||||
|
|
||||||
|
protected val cacheDir: String = fs.tempDir.absolutePath
|
||||||
|
protected val aaptPath = Aapt.binary(context)?.absolutePath
|
||||||
|
?: throw FileNotFoundException("Could not resolve aapt.")
|
||||||
|
protected val frameworkPath: String =
|
||||||
|
context.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
|
||||||
|
|
||||||
|
protected suspend fun bundles() = patchBundlesRepo.bundles.first()
|
||||||
|
protected suspend fun enableMultithreadedDexWriter() = prefs.multithreadingDexFileWriter.get()
|
||||||
|
|
||||||
|
abstract suspend fun execute(
|
||||||
|
inputFile: String,
|
||||||
|
outputFile: String,
|
||||||
|
packageName: String,
|
||||||
|
selectedPatches: PatchSelection,
|
||||||
|
options: Options,
|
||||||
|
logger: Logger,
|
||||||
|
onPatchCompleted: () -> Unit,
|
||||||
|
onProgress: ProgressEventHandler,
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
package app.revanced.manager.patcher.runtime.process
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.parcelize.RawValue
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class Parameters(
|
||||||
|
val cacheDir: String,
|
||||||
|
val aaptPath: String,
|
||||||
|
val frameworkDir: String,
|
||||||
|
val packageName: String,
|
||||||
|
val inputFile: String,
|
||||||
|
val outputFile: String,
|
||||||
|
val enableMultithrededDexWriter: Boolean,
|
||||||
|
val configurations: List<PatchConfiguration>,
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class PatchConfiguration(
|
||||||
|
val bundlePath: String,
|
||||||
|
val integrationsPath: String?,
|
||||||
|
val patches: Set<String>,
|
||||||
|
val options: @RawValue Map<String, Map<String, Any?>>
|
||||||
|
) : Parcelable
|
@ -0,0 +1,126 @@
|
|||||||
|
package app.revanced.manager.patcher.runtime.process
|
||||||
|
|
||||||
|
import android.app.ActivityThread
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Looper
|
||||||
|
import app.revanced.manager.BuildConfig
|
||||||
|
import app.revanced.manager.patcher.Session
|
||||||
|
import app.revanced.manager.patcher.logger.LogLevel
|
||||||
|
import app.revanced.manager.patcher.logger.Logger
|
||||||
|
import app.revanced.manager.patcher.patch.PatchBundle
|
||||||
|
import app.revanced.manager.patcher.runtime.ProcessRuntime
|
||||||
|
import app.revanced.manager.ui.model.State
|
||||||
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main class that runs inside the runner process launched by [ProcessRuntime].
|
||||||
|
*/
|
||||||
|
class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
|
||||||
|
private var eventBinder: IPatcherEvents? = null
|
||||||
|
|
||||||
|
private val scope =
|
||||||
|
CoroutineScope(Dispatchers.Default + CoroutineExceptionHandler { _, throwable ->
|
||||||
|
// Try to send the exception information to the main app.
|
||||||
|
eventBinder?.let {
|
||||||
|
try {
|
||||||
|
it.finished(throwable.stackTraceToString())
|
||||||
|
return@CoroutineExceptionHandler
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throwable.printStackTrace()
|
||||||
|
exitProcess(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
override fun buildId() = BuildConfig.BUILD_ID
|
||||||
|
override fun exit() = exitProcess(0)
|
||||||
|
|
||||||
|
override fun start(parameters: Parameters, events: IPatcherEvents) {
|
||||||
|
eventBinder = events
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
val logger = object : Logger() {
|
||||||
|
override fun log(level: LogLevel, message: String) =
|
||||||
|
events.log(level.name, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB")
|
||||||
|
|
||||||
|
val integrations =
|
||||||
|
parameters.configurations.mapNotNull { it.integrationsPath?.let(::File) }
|
||||||
|
val patchList = parameters.configurations.flatMap { config ->
|
||||||
|
val bundle = PatchBundle(File(config.bundlePath), null)
|
||||||
|
|
||||||
|
val patches =
|
||||||
|
bundle.patchClasses(parameters.packageName).filter { it.name in config.patches }
|
||||||
|
.associateBy { it.name }
|
||||||
|
|
||||||
|
config.options.forEach { (patchName, opts) ->
|
||||||
|
val patchOptions = patches[patchName]?.options
|
||||||
|
?: throw Exception("Patch with name $patchName does not exist.")
|
||||||
|
|
||||||
|
opts.forEach { (key, value) ->
|
||||||
|
patchOptions[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patches.values
|
||||||
|
}
|
||||||
|
|
||||||
|
events.progress(null, State.COMPLETED.name, null) // Loading patches
|
||||||
|
|
||||||
|
Session(
|
||||||
|
cacheDir = parameters.cacheDir,
|
||||||
|
aaptPath = parameters.aaptPath,
|
||||||
|
frameworkDir = parameters.frameworkDir,
|
||||||
|
multithreadingDexFileWriter = parameters.enableMultithrededDexWriter,
|
||||||
|
androidContext = context,
|
||||||
|
logger = logger,
|
||||||
|
input = File(parameters.inputFile),
|
||||||
|
onPatchCompleted = { events.patchSucceeded() },
|
||||||
|
onProgress = { name, state, message ->
|
||||||
|
events.progress(name, state?.name, message)
|
||||||
|
}
|
||||||
|
).use {
|
||||||
|
it.run(File(parameters.outputFile), patchList, integrations)
|
||||||
|
}
|
||||||
|
|
||||||
|
events.finished(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
Looper.prepare()
|
||||||
|
|
||||||
|
val managerPackageName = args[0]
|
||||||
|
|
||||||
|
// Abuse hidden APIs to get a context.
|
||||||
|
val systemContext = ActivityThread.systemMain().systemContext as Context
|
||||||
|
val appContext = systemContext.createPackageContext(managerPackageName, 0)
|
||||||
|
|
||||||
|
val ipcInterface = PatcherProcess(appContext)
|
||||||
|
|
||||||
|
appContext.sendBroadcast(Intent().apply {
|
||||||
|
action = ProcessRuntime.CONNECT_TO_APP_ACTION
|
||||||
|
`package` = managerPackageName
|
||||||
|
|
||||||
|
putExtra(ProcessRuntime.INTENT_BUNDLE_KEY, Bundle().apply {
|
||||||
|
putBinder(ProcessRuntime.BUNDLE_BINDER_KEY, ipcInterface.asBinder())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Looper.loop()
|
||||||
|
exitProcess(1) // Shouldn't happen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,125 +0,0 @@
|
|||||||
package app.revanced.manager.patcher.worker
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import app.revanced.manager.R
|
|
||||||
import app.revanced.manager.ui.model.SelectedApp
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
enum class State {
|
|
||||||
WAITING, COMPLETED, FAILED
|
|
||||||
}
|
|
||||||
|
|
||||||
class SubStep(
|
|
||||||
val name: String,
|
|
||||||
val state: State = State.WAITING,
|
|
||||||
val message: String? = null,
|
|
||||||
val progress: StateFlow<Pair<Float, Float>?>? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
class Step(
|
|
||||||
@StringRes val name: Int,
|
|
||||||
val subSteps: ImmutableList<SubStep>,
|
|
||||||
val state: State = State.WAITING
|
|
||||||
)
|
|
||||||
|
|
||||||
class PatcherProgressManager(
|
|
||||||
context: Context,
|
|
||||||
selectedPatches: List<String>,
|
|
||||||
selectedApp: SelectedApp,
|
|
||||||
downloadProgress: StateFlow<Pair<Float, Float>?>
|
|
||||||
) {
|
|
||||||
val steps = generateSteps(context, selectedPatches, selectedApp, downloadProgress)
|
|
||||||
private var currentStep: StepKey? = StepKey(0, 0)
|
|
||||||
|
|
||||||
private fun update(key: StepKey, state: State, message: String? = null) {
|
|
||||||
val isLastSubStep: Boolean
|
|
||||||
steps[key.step] = steps[key.step].let { step ->
|
|
||||||
isLastSubStep = key.substep == step.subSteps.lastIndex
|
|
||||||
|
|
||||||
val newStepState = when {
|
|
||||||
// This step failed because one of its sub-steps failed.
|
|
||||||
state == State.FAILED -> State.FAILED
|
|
||||||
// All sub-steps succeeded.
|
|
||||||
state == State.COMPLETED && isLastSubStep -> State.COMPLETED
|
|
||||||
// Keep the old status.
|
|
||||||
else -> step.state
|
|
||||||
}
|
|
||||||
|
|
||||||
Step(step.name, step.subSteps.mapIndexed { index, subStep ->
|
|
||||||
if (index != key.substep) subStep else SubStep(subStep.name, state, message)
|
|
||||||
}.toImmutableList(), newStepState)
|
|
||||||
}
|
|
||||||
|
|
||||||
val isFinal = isLastSubStep && key.step == steps.lastIndex
|
|
||||||
|
|
||||||
if (state == State.COMPLETED) {
|
|
||||||
// Move the cursor to the next step.
|
|
||||||
currentStep = when {
|
|
||||||
isFinal -> null // Final step has been completed.
|
|
||||||
isLastSubStep -> StepKey(key.step + 1, 0) // Move to the next step.
|
|
||||||
else -> StepKey(
|
|
||||||
key.step,
|
|
||||||
key.substep + 1
|
|
||||||
) // Move to the next sub-step.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun replacePatchesList(newList: List<String>) {
|
|
||||||
steps[1] = generatePatchesStep(newList)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateCurrent(newState: State, message: String? = null) {
|
|
||||||
currentStep?.let { update(it, newState, message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun failure(error: Throwable) = updateCurrent(
|
|
||||||
State.FAILED,
|
|
||||||
error.stackTraceToString()
|
|
||||||
)
|
|
||||||
|
|
||||||
fun success() = updateCurrent(State.COMPLETED)
|
|
||||||
|
|
||||||
fun getProgress(): List<Step> = steps
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private fun generatePatchesStep(selectedPatches: List<String>) = Step(
|
|
||||||
R.string.patcher_step_group_patching,
|
|
||||||
selectedPatches.map { SubStep(it) }.toImmutableList()
|
|
||||||
)
|
|
||||||
|
|
||||||
fun generateSteps(
|
|
||||||
context: Context,
|
|
||||||
selectedPatches: List<String>,
|
|
||||||
selectedApp: SelectedApp,
|
|
||||||
downloadProgress: StateFlow<Pair<Float, Float>?>? = null
|
|
||||||
) = mutableListOf(
|
|
||||||
Step(
|
|
||||||
R.string.patcher_step_group_prepare,
|
|
||||||
listOfNotNull(
|
|
||||||
SubStep(context.getString(R.string.patcher_step_load_patches)),
|
|
||||||
SubStep(
|
|
||||||
"Download apk",
|
|
||||||
progress = downloadProgress
|
|
||||||
).takeIf { selectedApp is SelectedApp.Download },
|
|
||||||
SubStep(context.getString(R.string.patcher_step_unpack)),
|
|
||||||
SubStep(context.getString(R.string.patcher_step_integrations))
|
|
||||||
).toImmutableList()
|
|
||||||
),
|
|
||||||
generatePatchesStep(selectedPatches),
|
|
||||||
Step(
|
|
||||||
R.string.patcher_step_group_saving,
|
|
||||||
persistentListOf(
|
|
||||||
SubStep(context.getString(R.string.patcher_step_write_patched)),
|
|
||||||
SubStep(context.getString(R.string.patcher_step_sign_apk))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class StepKey(val step: Int, val substep: Int)
|
|
||||||
}
|
|
@ -23,32 +23,29 @@ import app.revanced.manager.domain.manager.KeystoreManager
|
|||||||
import app.revanced.manager.domain.manager.PreferencesManager
|
import app.revanced.manager.domain.manager.PreferencesManager
|
||||||
import app.revanced.manager.domain.repository.DownloadedAppRepository
|
import app.revanced.manager.domain.repository.DownloadedAppRepository
|
||||||
import app.revanced.manager.domain.repository.InstalledAppRepository
|
import app.revanced.manager.domain.repository.InstalledAppRepository
|
||||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
|
||||||
import app.revanced.manager.domain.worker.Worker
|
import app.revanced.manager.domain.worker.Worker
|
||||||
import app.revanced.manager.domain.worker.WorkerRepository
|
import app.revanced.manager.domain.worker.WorkerRepository
|
||||||
import app.revanced.manager.patcher.Session
|
import app.revanced.manager.patcher.logger.Logger
|
||||||
import app.revanced.manager.patcher.aapt.Aapt
|
import app.revanced.manager.patcher.runtime.CoroutineRuntime
|
||||||
|
import app.revanced.manager.patcher.runtime.ProcessRuntime
|
||||||
import app.revanced.manager.ui.model.SelectedApp
|
import app.revanced.manager.ui.model.SelectedApp
|
||||||
import app.revanced.manager.ui.viewmodel.ManagerLogger
|
import app.revanced.manager.ui.model.State
|
||||||
import app.revanced.manager.util.Options
|
import app.revanced.manager.util.Options
|
||||||
import app.revanced.manager.util.PM
|
import app.revanced.manager.util.PM
|
||||||
import app.revanced.manager.util.PatchesSelection
|
import app.revanced.manager.util.PatchSelection
|
||||||
import app.revanced.manager.util.tag
|
import app.revanced.manager.util.tag
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.update
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
|
||||||
|
typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit
|
||||||
|
|
||||||
class PatcherWorker(
|
class PatcherWorker(
|
||||||
context: Context,
|
context: Context,
|
||||||
parameters: WorkerParameters
|
parameters: WorkerParameters
|
||||||
) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent {
|
) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent {
|
||||||
|
|
||||||
private val patchBundleRepository: PatchBundleRepository by inject()
|
|
||||||
private val workerRepository: WorkerRepository by inject()
|
private val workerRepository: WorkerRepository by inject()
|
||||||
private val prefs: PreferencesManager by inject()
|
private val prefs: PreferencesManager by inject()
|
||||||
private val keystoreManager: KeystoreManager by inject()
|
private val keystoreManager: KeystoreManager by inject()
|
||||||
@ -61,20 +58,17 @@ class PatcherWorker(
|
|||||||
data class Args(
|
data class Args(
|
||||||
val input: SelectedApp,
|
val input: SelectedApp,
|
||||||
val output: String,
|
val output: String,
|
||||||
val selectedPatches: PatchesSelection,
|
val selectedPatches: PatchSelection,
|
||||||
val options: Options,
|
val options: Options,
|
||||||
val progress: MutableStateFlow<ImmutableList<Step>>,
|
val logger: Logger,
|
||||||
val logger: ManagerLogger,
|
val downloadProgress: MutableStateFlow<Pair<Float, Float>?>,
|
||||||
val setInputFile: (File) -> Unit
|
val patchesProgress: MutableStateFlow<Pair<Int, Int>>,
|
||||||
|
val setInputFile: (File) -> Unit,
|
||||||
|
val onProgress: ProgressEventHandler
|
||||||
) {
|
) {
|
||||||
val packageName get() = input.packageName
|
val packageName get() = input.packageName
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val logPrefix = "[Worker]:"
|
|
||||||
private fun String.logFmt() = "$logPrefix $this"
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getForegroundInfo() =
|
override suspend fun getForegroundInfo() =
|
||||||
ForegroundInfo(
|
ForegroundInfo(
|
||||||
1,
|
1,
|
||||||
@ -107,8 +101,6 @@ class PatcherWorker(
|
|||||||
return Result.failure()
|
return Result.failure()
|
||||||
}
|
}
|
||||||
|
|
||||||
val args = workerRepository.claimInput(this)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// This does not always show up for some reason.
|
// This does not always show up for some reason.
|
||||||
setForeground(getForegroundInfo())
|
setForeground(getForegroundInfo())
|
||||||
@ -117,12 +109,14 @@ class PatcherWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val wakeLock: PowerManager.WakeLock =
|
val wakeLock: PowerManager.WakeLock =
|
||||||
(applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
(applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher").apply {
|
.newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher")
|
||||||
|
.apply {
|
||||||
acquire(10 * 60 * 1000L)
|
acquire(10 * 60 * 1000L)
|
||||||
Log.d(tag, "Acquired wakelock.")
|
Log.d(tag, "Acquired wakelock.")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
val args = workerRepository.claimInput(this)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
runPatcher(args)
|
runPatcher(args)
|
||||||
@ -132,39 +126,13 @@ class PatcherWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runPatcher(args: Args): Result {
|
private suspend fun runPatcher(args: Args): Result {
|
||||||
val aaptPath =
|
|
||||||
Aapt.binary(applicationContext)?.absolutePath
|
|
||||||
?: throw FileNotFoundException("Could not resolve aapt.")
|
|
||||||
|
|
||||||
val frameworkPath =
|
fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
|
||||||
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
|
args.onProgress(name, state, message)
|
||||||
|
|
||||||
val bundles = patchBundleRepository.bundles.first()
|
|
||||||
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
|
|
||||||
|
|
||||||
val downloadProgress = MutableStateFlow<Pair<Float, Float>?>(null)
|
|
||||||
|
|
||||||
val progressManager =
|
|
||||||
PatcherProgressManager(
|
|
||||||
applicationContext,
|
|
||||||
args.selectedPatches.flatMap { it.value },
|
|
||||||
args.input,
|
|
||||||
downloadProgress
|
|
||||||
)
|
|
||||||
|
|
||||||
val progressFlow = args.progress
|
|
||||||
|
|
||||||
fun updateProgress(advanceCounter: Boolean = true) {
|
|
||||||
if (advanceCounter) {
|
|
||||||
progressManager.success()
|
|
||||||
}
|
|
||||||
progressFlow.value = progressManager.getProgress().toImmutableList()
|
|
||||||
}
|
|
||||||
|
|
||||||
val patchedApk = fs.tempDir.resolve("patched.apk")
|
val patchedApk = fs.tempDir.resolve("patched.apk")
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
|
|
||||||
if (args.input is SelectedApp.Installed) {
|
if (args.input is SelectedApp.Installed) {
|
||||||
installedAppRepository.get(args.packageName)?.let {
|
installedAppRepository.get(args.packageName)?.let {
|
||||||
if (it.installType == InstallType.ROOT) {
|
if (it.installType == InstallType.ROOT) {
|
||||||
@ -173,41 +141,15 @@ class PatcherWorker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: consider passing all the classes directly now that the input no longer needs to be serializable.
|
|
||||||
val selectedBundles = args.selectedPatches.keys
|
|
||||||
val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
|
|
||||||
.mapValues { (_, bundle) -> bundle.patchClasses(args.packageName) }
|
|
||||||
|
|
||||||
// Set all patch options.
|
|
||||||
args.options.forEach { (bundle, bundlePatchOptions) ->
|
|
||||||
val patches = allPatches[bundle] ?: return@forEach
|
|
||||||
bundlePatchOptions.forEach { (patchName, configuredPatchOptions) ->
|
|
||||||
val patchOptions = patches.single { it.name == patchName }.options
|
|
||||||
configuredPatchOptions.forEach { (key, value) ->
|
|
||||||
patchOptions[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val patches = args.selectedPatches.flatMap { (bundle, selected) ->
|
|
||||||
allPatches[bundle]?.filter { selected.contains(it.name) }
|
|
||||||
?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Ensure they are in the correct order so we can track progress properly.
|
|
||||||
progressManager.replacePatchesList(patches.map { it.name.orEmpty() })
|
|
||||||
updateProgress() // Loading patches
|
|
||||||
|
|
||||||
val inputFile = when (val selectedApp = args.input) {
|
val inputFile = when (val selectedApp = args.input) {
|
||||||
is SelectedApp.Download -> {
|
is SelectedApp.Download -> {
|
||||||
downloadedAppRepository.download(
|
downloadedAppRepository.download(
|
||||||
selectedApp.app,
|
selectedApp.app,
|
||||||
prefs.preferSplits.get(),
|
prefs.preferSplits.get(),
|
||||||
onDownload = { downloadProgress.emit(it) }
|
onDownload = { args.downloadProgress.emit(it) }
|
||||||
).also {
|
).also {
|
||||||
args.setInputFile(it)
|
args.setInputFile(it)
|
||||||
updateProgress() // Downloading
|
updateProgress(state = State.COMPLETED) // Download APK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,31 +157,50 @@ class PatcherWorker(
|
|||||||
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo.sourceDir)
|
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo.sourceDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
Session(
|
val runtime = if (prefs.useProcessRuntime.get()) {
|
||||||
fs.tempDir.absolutePath,
|
ProcessRuntime(applicationContext)
|
||||||
frameworkPath,
|
} else {
|
||||||
aaptPath,
|
CoroutineRuntime(applicationContext)
|
||||||
prefs.multithreadingDexFileWriter.get(),
|
|
||||||
args.logger,
|
|
||||||
inputFile,
|
|
||||||
onStepSucceeded = ::updateProgress
|
|
||||||
).use { session ->
|
|
||||||
session.run(patchedApk, patches, integrations)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime.execute(
|
||||||
|
inputFile.absolutePath,
|
||||||
|
patchedApk.absolutePath,
|
||||||
|
args.packageName,
|
||||||
|
args.selectedPatches,
|
||||||
|
args.options,
|
||||||
|
args.logger,
|
||||||
|
onPatchCompleted = {
|
||||||
|
args.patchesProgress.update { (completed, total) ->
|
||||||
|
completed + 1 to total
|
||||||
|
}
|
||||||
|
},
|
||||||
|
args.onProgress
|
||||||
|
)
|
||||||
|
|
||||||
keystoreManager.sign(patchedApk, File(args.output))
|
keystoreManager.sign(patchedApk, File(args.output))
|
||||||
updateProgress() // Signing
|
updateProgress(state = State.COMPLETED) // Signing
|
||||||
|
|
||||||
Log.i(tag, "Patching succeeded".logFmt())
|
Log.i(tag, "Patching succeeded".logFmt())
|
||||||
progressManager.success()
|
|
||||||
Result.success()
|
Result.success()
|
||||||
|
} catch (e: ProcessRuntime.RemoteFailureException) {
|
||||||
|
Log.e(tag, "An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt())
|
||||||
|
updateProgress(state = State.FAILED, message = e.originalStackTrace)
|
||||||
|
Result.failure()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(tag, "Exception while patching".logFmt(), e)
|
Log.e(tag, "An exception occurred while patching".logFmt(), e)
|
||||||
progressManager.failure(e)
|
updateProgress(state = State.FAILED, message = e.stackTraceToString())
|
||||||
Result.failure()
|
Result.failure()
|
||||||
} finally {
|
} finally {
|
||||||
updateProgress(false)
|
|
||||||
patchedApk.delete()
|
patchedApk.delete()
|
||||||
|
if (args.input is SelectedApp.Local && args.input.temporary) {
|
||||||
|
args.input.file.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val LOG_PREFIX = "[Worker]"
|
||||||
|
private fun String.logFmt() = "$LOG_PREFIX $this"
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,8 +1,6 @@
|
|||||||
package app.revanced.manager.service
|
package app.revanced.manager.service
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import app.revanced.manager.IRootSystemService
|
import app.revanced.manager.IRootSystemService
|
||||||
import com.topjohnwu.superuser.ipc.RootService
|
import com.topjohnwu.superuser.ipc.RootService
|
||||||
@ -14,23 +12,5 @@ class ManagerRootService : RootService() {
|
|||||||
FileSystemManager.getService()
|
FileSystemManager.getService()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder {
|
override fun onBind(intent: Intent): IBinder = RootSystemService()
|
||||||
return RootSystemService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RootConnection : ServiceConnection {
|
|
||||||
var remoteFS: FileSystemManager? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
|
||||||
val ipc = IRootSystemService.Stub.asInterface(service)
|
|
||||||
val binder = ipc.fileSystemService
|
|
||||||
|
|
||||||
remoteFS = FileSystemManager.getRemote(binder)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
|
||||||
remoteFS = null
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -0,0 +1,152 @@
|
|||||||
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.AlertDialogDefaults
|
||||||
|
import androidx.compose.material3.BasicAlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ProvideTextStyle
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AlertDialogExtended(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
confirmButton: @Composable () -> Unit,
|
||||||
|
dismissButton: @Composable (() -> Unit)? = null,
|
||||||
|
tertiaryButton: @Composable (() -> Unit)? = null,
|
||||||
|
icon: @Composable (() -> Unit)? = null,
|
||||||
|
title: @Composable (() -> Unit)? = null,
|
||||||
|
text: @Composable (() -> Unit)? = null,
|
||||||
|
shape: Shape = AlertDialogDefaults.shape,
|
||||||
|
containerColor: Color = AlertDialogDefaults.containerColor,
|
||||||
|
iconContentColor: Color = AlertDialogDefaults.iconContentColor,
|
||||||
|
titleContentColor: Color = AlertDialogDefaults.titleContentColor,
|
||||||
|
textContentColor: Color = AlertDialogDefaults.textContentColor,
|
||||||
|
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
|
||||||
|
textHorizontalPadding: PaddingValues = TextHorizontalPadding
|
||||||
|
) {
|
||||||
|
BasicAlertDialog(onDismissRequest = onDismissRequest) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = shape,
|
||||||
|
color = containerColor,
|
||||||
|
tonalElevation = tonalElevation,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(vertical = 24.dp)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth()
|
||||||
|
) {
|
||||||
|
icon?.let {
|
||||||
|
ContentStyle(color = iconContentColor) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
) {
|
||||||
|
icon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title?.let {
|
||||||
|
ContentStyle(
|
||||||
|
color = titleContentColor,
|
||||||
|
textStyle = MaterialTheme.typography.headlineSmall
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
// Align the title to the center when an icon is present.
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = 16.dp)
|
||||||
|
.align(
|
||||||
|
if (icon == null) {
|
||||||
|
Alignment.Start
|
||||||
|
} else {
|
||||||
|
Alignment.CenterHorizontally
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
title()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text?.let {
|
||||||
|
ContentStyle(
|
||||||
|
color = textContentColor,
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.weight(weight = 1f, fill = false)
|
||||||
|
.padding(bottom = 24.dp)
|
||||||
|
.padding(textHorizontalPadding)
|
||||||
|
.align(Alignment.Start)
|
||||||
|
) {
|
||||||
|
text()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
) {
|
||||||
|
ContentStyle(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textStyle = MaterialTheme.typography.labelLarge
|
||||||
|
) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
|
12.dp,
|
||||||
|
if (tertiaryButton != null) Alignment.Start else Alignment.End
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
tertiaryButton?.let {
|
||||||
|
it()
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
dismissButton?.invoke()
|
||||||
|
confirmButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContentStyle(
|
||||||
|
color: Color = LocalContentColor.current,
|
||||||
|
textStyle: TextStyle = LocalTextStyle.current,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(LocalContentColor provides color) {
|
||||||
|
ProvideTextStyle(textStyle) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val TextHorizontalPadding = PaddingValues(horizontal = 24.dp)
|
@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import com.google.accompanist.placeholder.placeholder
|
import io.github.fornewid.placeholder.material3.placeholder
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppIcon(
|
fun AppIcon(
|
||||||
@ -33,11 +33,9 @@ fun AppIcon(
|
|||||||
Image(
|
Image(
|
||||||
image,
|
image,
|
||||||
contentDescription,
|
contentDescription,
|
||||||
Modifier.placeholder(visible = showPlaceHolder, color = MaterialTheme.colorScheme.inverseOnSurface, shape = RoundedCornerShape(100)).then(modifier),
|
modifier,
|
||||||
colorFilter = colorFilter
|
colorFilter = colorFilter
|
||||||
)
|
)
|
||||||
|
|
||||||
showPlaceHolder = false
|
|
||||||
} else {
|
} else {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
packageInfo,
|
packageInfo,
|
||||||
|
@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import com.google.accompanist.placeholder.placeholder
|
import io.github.fornewid.placeholder.material3.placeholder
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ package app.revanced.manager.ui.component
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -38,7 +38,7 @@ fun AppTopBar(
|
|||||||
onBackClick: (() -> Unit)? = null,
|
onBackClick: (() -> Unit)? = null,
|
||||||
backIcon: @Composable (() -> Unit) = @Composable {
|
backIcon: @Composable (() -> Unit) = @Composable {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ArrowBack, contentDescription = stringResource(
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(
|
||||||
R.string.back
|
R.string.back
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -13,17 +13,34 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ArrowButton(modifier: Modifier = Modifier, expanded: Boolean,onClick: () -> Unit) {
|
fun ArrowButton(
|
||||||
IconButton(onClick = onClick) {
|
modifier: Modifier = Modifier,
|
||||||
val description = if (expanded) R.string.collapse_content else R.string.expand_content
|
expanded: Boolean,
|
||||||
val rotation by animateFloatAsState(targetValue = if (expanded) 0f else 180f, label = "rotation")
|
onClick: (() -> Unit)?,
|
||||||
|
rotationInitial: Float = 0f,
|
||||||
|
rotationFinal: Float = 180f
|
||||||
|
) {
|
||||||
|
val description = if (expanded) R.string.collapse_content else R.string.expand_content
|
||||||
|
val rotation by animateFloatAsState(
|
||||||
|
targetValue = if (expanded) rotationInitial else rotationFinal,
|
||||||
|
label = "rotation"
|
||||||
|
)
|
||||||
|
|
||||||
Icon(
|
onClick?.let {
|
||||||
imageVector = Icons.Filled.KeyboardArrowUp,
|
IconButton(onClick = it) {
|
||||||
contentDescription = stringResource(description),
|
Icon(
|
||||||
modifier = Modifier
|
imageVector = Icons.Filled.KeyboardArrowUp,
|
||||||
.rotate(rotation)
|
contentDescription = stringResource(description),
|
||||||
.then(modifier)
|
modifier = Modifier
|
||||||
)
|
.rotate(rotation)
|
||||||
}
|
.then(modifier)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} ?: Icon(
|
||||||
|
imageVector = Icons.Filled.KeyboardArrowUp,
|
||||||
|
contentDescription = stringResource(description),
|
||||||
|
modifier = Modifier
|
||||||
|
.rotate(rotation)
|
||||||
|
.then(modifier)
|
||||||
|
)
|
||||||
}
|
}
|
@ -4,16 +4,14 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Source
|
import androidx.compose.material.icons.outlined.Source
|
||||||
import androidx.compose.material.icons.outlined.Update
|
import androidx.compose.material.icons.outlined.Update
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@ -21,11 +19,9 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
|
|
||||||
@ -37,52 +33,35 @@ fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) {
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = {},
|
onDismissRequest = {},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(onClick = { onSubmit(managerEnabled, patchesEnabled) }) {
|
||||||
onClick = { onSubmit(managerEnabled, patchesEnabled) }
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.save))
|
Text(stringResource(R.string.save))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon = {
|
icon = { Icon(Icons.Outlined.Update, null) },
|
||||||
Icon(Icons.Outlined.Update, null)
|
title = { Text(text = stringResource(R.string.auto_updates_dialog_title)) },
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.auto_updates_dialog_title),
|
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
text = {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(text = stringResource(R.string.auto_updates_dialog_description))
|
||||||
text = stringResource(R.string.auto_updates_dialog_description),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
|
|
||||||
AutoUpdatesItem(
|
Column {
|
||||||
headline = R.string.auto_updates_dialog_manager,
|
AutoUpdatesItem(
|
||||||
icon = Icons.Outlined.Update,
|
headline = R.string.auto_updates_dialog_manager,
|
||||||
checked = managerEnabled,
|
icon = Icons.Outlined.Update,
|
||||||
onCheckedChange = { managerEnabled = it }
|
checked = managerEnabled,
|
||||||
)
|
onCheckedChange = { managerEnabled = it }
|
||||||
Divider()
|
)
|
||||||
AutoUpdatesItem(
|
HorizontalDivider()
|
||||||
headline = R.string.auto_updates_dialog_patches,
|
AutoUpdatesItem(
|
||||||
icon = Icons.Outlined.Source,
|
headline = R.string.auto_updates_dialog_patches,
|
||||||
checked = patchesEnabled,
|
icon = Icons.Outlined.Source,
|
||||||
onCheckedChange = { patchesEnabled = it }
|
checked = patchesEnabled,
|
||||||
)
|
onCheckedChange = { patchesEnabled = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(text = stringResource(R.string.auto_updates_dialog_note))
|
||||||
text = stringResource(R.string.auto_updates_dialog_note),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -94,22 +73,9 @@ private fun AutoUpdatesItem(
|
|||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
onCheckedChange: (Boolean) -> Unit
|
onCheckedChange: (Boolean) -> Unit
|
||||||
) {
|
) = ListItem(
|
||||||
ListItem(
|
leadingContent = { Icon(icon, null) },
|
||||||
leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurface) },
|
headlineContent = { Text(stringResource(headline)) },
|
||||||
headlineContent = {
|
trailingContent = { Checkbox(checked = checked, onCheckedChange = null) },
|
||||||
Text(
|
modifier = Modifier.clickable { onCheckedChange(!checked) }
|
||||||
text = stringResource(headline),
|
)
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Checkbox(
|
|
||||||
checked = checked,
|
|
||||||
onCheckedChange = onCheckedChange
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.clickable { onCheckedChange(!checked) }
|
|
||||||
)
|
|
||||||
}
|
|
@ -0,0 +1,81 @@
|
|||||||
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Update
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.revanced.manager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AvailableUpdateDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
setShowManagerUpdateDialogOnLaunch: (Boolean) -> Unit,
|
||||||
|
newVersion: String
|
||||||
|
) {
|
||||||
|
var dontShowAgain by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val dismissDialog = {
|
||||||
|
setShowManagerUpdateDialogOnLaunch(!dontShowAgain)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialogExtended(
|
||||||
|
onDismissRequest = dismissDialog,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
dismissDialog()
|
||||||
|
onConfirm()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.show))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = dismissDialog
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.dismiss))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(imageVector = Icons.Outlined.Update, contentDescription = null)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.update_available))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
text = stringResource(R.string.update_available_dialog_description, newVersion)
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { dontShowAgain = !dontShowAgain },
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.never_show_again))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
|
||||||
|
Checkbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
textHorizontalPadding = PaddingValues(0.dp)
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ColumnWithScrollbar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: ScrollState = rememberScrollState(),
|
||||||
|
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
|
||||||
|
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.then(Modifier.verticalScroll(state)),
|
||||||
|
verticalArrangement = verticalArrangement,
|
||||||
|
horizontalAlignment = horizontalAlignment,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
Scrollbar(state, Modifier.then(modifier.padding())) // Get the modifier's padding to maintain scrollbar within the screen, e.g. paddingValues
|
||||||
|
}
|
@ -1,26 +0,0 @@
|
|||||||
package app.revanced.manager.ui.component
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Countdown(start: Int, content: @Composable (Int) -> Unit) {
|
|
||||||
var timer by rememberSaveable(start) {
|
|
||||||
mutableStateOf(start)
|
|
||||||
}
|
|
||||||
LaunchedEffect(timer) {
|
|
||||||
if (timer == 0) {
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(1000L)
|
|
||||||
timer -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
content(timer)
|
|
||||||
}
|
|
@ -0,0 +1,42 @@
|
|||||||
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.FlingBehavior
|
||||||
|
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LazyColumnWithScrollbar(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
state: LazyListState = rememberLazyListState(),
|
||||||
|
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||||
|
reverseLayout: Boolean = false,
|
||||||
|
verticalArrangement: Arrangement.Vertical =
|
||||||
|
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
|
||||||
|
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
|
||||||
|
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
|
||||||
|
userScrollEnabled: Boolean = true,
|
||||||
|
content: LazyListScope.() -> Unit
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier,
|
||||||
|
state = state,
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
reverseLayout = reverseLayout,
|
||||||
|
verticalArrangement = verticalArrangement,
|
||||||
|
horizontalAlignment = horizontalAlignment,
|
||||||
|
flingBehavior = flingBehavior,
|
||||||
|
userScrollEnabled = userScrollEnabled,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
Scrollbar(state, Modifier.then(modifier.padding())) // Get the modifier's padding to maintain scrollbar within the screen, e.g. paddingValues
|
||||||
|
}
|
@ -1,37 +1,37 @@
|
|||||||
package app.revanced.manager.ui.component
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoadingIndicator(
|
fun LoadingIndicator(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
progress: Float? = null,
|
progress: () -> Float? = { null },
|
||||||
text: String? = null
|
color: Color = ProgressIndicatorDefaults.circularColor,
|
||||||
|
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
|
||||||
|
trackColor: Color = ProgressIndicatorDefaults.circularTrackColor,
|
||||||
|
strokeCap: StrokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap
|
||||||
) {
|
) {
|
||||||
Column(
|
progress()?.let {
|
||||||
modifier = Modifier.fillMaxSize(),
|
CircularProgressIndicator(
|
||||||
verticalArrangement = Arrangement.Center,
|
progress = { it },
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
modifier = modifier,
|
||||||
) {
|
color = color,
|
||||||
text?.let { Text(text) }
|
strokeWidth = strokeWidth,
|
||||||
|
trackColor = trackColor,
|
||||||
progress?.let {
|
strokeCap = strokeCap
|
||||||
CircularProgressIndicator(
|
)
|
||||||
progress = progress,
|
} ?:
|
||||||
modifier = Modifier.padding(vertical = 16.dp).then(modifier)
|
CircularProgressIndicator(
|
||||||
)
|
modifier = modifier,
|
||||||
} ?:
|
color = color,
|
||||||
CircularProgressIndicator(
|
strokeWidth = strokeWidth,
|
||||||
modifier = Modifier.padding(vertical = 16.dp).then(modifier)
|
trackColor = trackColor,
|
||||||
)
|
strokeCap = strokeCap
|
||||||
}
|
)
|
||||||
}
|
}
|
@ -3,10 +3,9 @@ package app.revanced.manager.ui.component
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.mikepenz.markdown.compose.Markdown
|
import com.mikepenz.markdown.compose.Markdown
|
||||||
import com.mikepenz.markdown.model.markdownColor
|
import com.mikepenz.markdown.m3.markdownColor
|
||||||
import com.mikepenz.markdown.model.markdownTypography
|
import com.mikepenz.markdown.m3.markdownTypography
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Markdown(
|
fun Markdown(
|
||||||
@ -19,7 +18,8 @@ fun Markdown(
|
|||||||
colors = markdownColor(
|
colors = markdownColor(
|
||||||
text = MaterialTheme.colorScheme.onSurfaceVariant,
|
text = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
codeBackground = MaterialTheme.colorScheme.secondaryContainer,
|
codeBackground = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
codeText = MaterialTheme.colorScheme.onSecondaryContainer
|
codeText = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
linkText = MaterialTheme.colorScheme.primary
|
||||||
),
|
),
|
||||||
typography = markdownTypography(
|
typography = markdownTypography(
|
||||||
h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
|
h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
package app.revanced.manager.ui.component
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -11,7 +13,6 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.outlined.Close
|
import androidx.compose.material.icons.outlined.Close
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -27,74 +28,73 @@ import app.revanced.manager.R
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NotificationCard(
|
fun NotificationCard(
|
||||||
isWarning: Boolean = false,
|
|
||||||
title: String? = null,
|
|
||||||
text: String,
|
text: String,
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
actions: (@Composable () -> Unit)?
|
modifier: Modifier = Modifier,
|
||||||
|
actions: (@Composable RowScope.() -> Unit)? = null,
|
||||||
|
title: String? = null,
|
||||||
|
isWarning: Boolean = false
|
||||||
) {
|
) {
|
||||||
val color =
|
val color =
|
||||||
if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
|
if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
||||||
NotificationCardInstance(isWarning = isWarning) {
|
NotificationCardInstance(modifier = modifier, isWarning = isWarning) {
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier.padding(if (title != null) 20.dp else 16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
if (title != null) {
|
Box(
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = color,
|
tint = color,
|
||||||
)
|
)
|
||||||
Column(
|
}
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
|
||||||
) {
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
title?.let {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = it,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = color,
|
color = color,
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = color,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
Text(
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
text = text,
|
||||||
Icon(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.size(24.dp),
|
color = color,
|
||||||
imageVector = icon,
|
)
|
||||||
contentDescription = null,
|
Row(
|
||||||
tint = color,
|
horizontalArrangement = Arrangement.End,
|
||||||
)
|
modifier = Modifier.fillMaxWidth()
|
||||||
Text(
|
) {
|
||||||
text = text,
|
actions?.invoke(this)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = color,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
actions?.invoke()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NotificationCard(
|
fun NotificationCard(
|
||||||
isWarning: Boolean = false,
|
|
||||||
title: String? = null,
|
|
||||||
text: String,
|
text: String,
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String? = null,
|
||||||
|
isWarning: Boolean = false,
|
||||||
onDismiss: (() -> Unit)? = null,
|
onDismiss: (() -> Unit)? = null,
|
||||||
primaryAction: (() -> Unit)? = null
|
onClick: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val color =
|
val color =
|
||||||
if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
|
if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
||||||
NotificationCardInstance(isWarning = isWarning, onClick = primaryAction) {
|
NotificationCardInstance(modifier = modifier, isWarning = isWarning, onClick = onClick) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -102,12 +102,17 @@ fun NotificationCard(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
modifier = Modifier.size(if (title != null) 36.dp else 24.dp),
|
modifier = Modifier.size(28.dp),
|
||||||
imageVector = icon,
|
contentAlignment = Alignment.Center
|
||||||
contentDescription = null,
|
) {
|
||||||
tint = color,
|
Icon(
|
||||||
)
|
modifier = Modifier.size(24.dp),
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = color,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (title != null) {
|
if (title != null) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@ -145,32 +150,31 @@ fun NotificationCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NotificationCardInstance(
|
private fun NotificationCardInstance(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
isWarning: Boolean = false,
|
isWarning: Boolean = false,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
CardDefaults.cardColors(containerColor = if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)
|
CardDefaults.cardColors(containerColor = if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)
|
||||||
val modifier = Modifier
|
val defaultModifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp)
|
|
||||||
.clip(RoundedCornerShape(24.dp))
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
Card(
|
Card(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
colors = colors,
|
colors = colors,
|
||||||
modifier = modifier
|
modifier = modifier.then(defaultModifier)
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Card(
|
Card(
|
||||||
colors = colors,
|
colors = colors,
|
||||||
modifier = modifier,
|
modifier = modifier.then(defaultModifier)
|
||||||
) {
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,99 @@
|
|||||||
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisallowComposableCalls
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.revanced.manager.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private inline fun <T> NumberInputDialog(
|
||||||
|
current: T?,
|
||||||
|
name: String,
|
||||||
|
crossinline onSubmit: (T?) -> Unit,
|
||||||
|
crossinline validator: @DisallowComposableCalls (T) -> Boolean,
|
||||||
|
crossinline toNumberOrNull: @DisallowComposableCalls String.() -> T?
|
||||||
|
) {
|
||||||
|
var fieldValue by rememberSaveable {
|
||||||
|
mutableStateOf(current?.toString().orEmpty())
|
||||||
|
}
|
||||||
|
val numberFieldValue by remember {
|
||||||
|
derivedStateOf { fieldValue.toNumberOrNull() }
|
||||||
|
}
|
||||||
|
val validatorFailed by remember {
|
||||||
|
derivedStateOf { numberFieldValue?.let { !validator(it) } ?: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { onSubmit(null) },
|
||||||
|
title = { Text(name) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fieldValue,
|
||||||
|
onValueChange = { fieldValue = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(stringResource(R.string.dialog_input_placeholder))
|
||||||
|
},
|
||||||
|
isError = validatorFailed,
|
||||||
|
supportingText = {
|
||||||
|
if (validatorFailed) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.input_dialog_value_invalid),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { numberFieldValue?.let(onSubmit) },
|
||||||
|
enabled = numberFieldValue != null && !validatorFailed,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { onSubmit(null) }) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IntInputDialog(
|
||||||
|
current: Int?,
|
||||||
|
name: String,
|
||||||
|
validator: (Int) -> Boolean = { true },
|
||||||
|
onSubmit: (Int?) -> Unit
|
||||||
|
) = NumberInputDialog(current, name, onSubmit, validator, String::toIntOrNull)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LongInputDialog(
|
||||||
|
current: Long?,
|
||||||
|
name: String,
|
||||||
|
validator: (Long) -> Boolean = { true },
|
||||||
|
onSubmit: (Long?) -> Unit
|
||||||
|
) = NumberInputDialog(current, name, onSubmit, validator, String::toLongOrNull)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FloatInputDialog(
|
||||||
|
current: Float?,
|
||||||
|
name: String,
|
||||||
|
validator: (Float) -> Boolean = { true },
|
||||||
|
onSubmit: (Float?) -> Unit
|
||||||
|
) = NumberInputDialog(current, name, onSubmit, validator, String::toFloatOrNull)
|
@ -0,0 +1,51 @@
|
|||||||
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.WarningAmber
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import app.revanced.manager.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SafeguardDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
@StringRes title: Int,
|
||||||
|
body: String,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Outlined.WarningAmber, null)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(body)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NonSuggestedVersionDialog(suggestedVersion: String, onDismiss: () -> Unit) {
|
||||||
|
SafeguardDialog(
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
title = R.string.non_suggested_version_warning_title,
|
||||||
|
body = stringResource(R.string.non_suggested_version_warning_description, suggestedVersion),
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ScrollState
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.gigamole.composescrollbars.Scrollbars
|
||||||
|
import com.gigamole.composescrollbars.ScrollbarsState
|
||||||
|
import com.gigamole.composescrollbars.config.ScrollbarsConfig
|
||||||
|
import com.gigamole.composescrollbars.config.ScrollbarsOrientation
|
||||||
|
import com.gigamole.composescrollbars.config.layercontenttype.ScrollbarsLayerContentType
|
||||||
|
import com.gigamole.composescrollbars.config.layersType.ScrollbarsLayersType
|
||||||
|
import com.gigamole.composescrollbars.config.layersType.thicknessType.ScrollbarsThicknessType
|
||||||
|
import com.gigamole.composescrollbars.config.visibilitytype.ScrollbarsVisibilityType
|
||||||
|
import com.gigamole.composescrollbars.scrolltype.ScrollbarsScrollType
|
||||||
|
import com.gigamole.composescrollbars.scrolltype.knobtype.ScrollbarsDynamicKnobType
|
||||||
|
import com.gigamole.composescrollbars.scrolltype.knobtype.ScrollbarsStaticKnobType
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Scrollbar(scrollState: ScrollState, modifier: Modifier = Modifier) {
|
||||||
|
Scrollbar(
|
||||||
|
ScrollbarsScrollType.Scroll(
|
||||||
|
knobType = ScrollbarsStaticKnobType.Auto(),
|
||||||
|
state = scrollState
|
||||||
|
),
|
||||||
|
modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Scrollbar(lazyListState: LazyListState, modifier: Modifier = Modifier) {
|
||||||
|
Scrollbar(
|
||||||
|
ScrollbarsScrollType.Lazy.List.Dynamic(
|
||||||
|
knobType = ScrollbarsDynamicKnobType.Auto(),
|
||||||
|
state = lazyListState
|
||||||
|
),
|
||||||
|
modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Scrollbar(scrollType: ScrollbarsScrollType, modifier: Modifier = Modifier) {
|
||||||
|
Scrollbars(
|
||||||
|
state = ScrollbarsState(
|
||||||
|
ScrollbarsConfig(
|
||||||
|
orientation = ScrollbarsOrientation.Vertical,
|
||||||
|
paddingValues = PaddingValues(0.dp),
|
||||||
|
layersType = ScrollbarsLayersType.Wrap(ScrollbarsThicknessType.Exact(4.dp)),
|
||||||
|
knobLayerContentType = ScrollbarsLayerContentType.Default.Colored.Idle(
|
||||||
|
idleColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
|
||||||
|
),
|
||||||
|
visibilityType = ScrollbarsVisibilityType.Dynamic.Fade(
|
||||||
|
isVisibleOnTouchDown = true,
|
||||||
|
isStaticWhenScrollPossible = false
|
||||||
|
)
|
||||||
|
),
|
||||||
|
scrollType
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
package app.revanced.manager.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.SearchBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.revanced.manager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchView(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
onActiveChange: (Boolean) -> Unit,
|
||||||
|
placeholder: (@Composable () -> Unit)? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
|
SearchBar(
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
onSearch = {
|
||||||
|
keyboardController?.hide()
|
||||||
|
},
|
||||||
|
active = true,
|
||||||
|
onActiveChange = onActiveChange,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
placeholder = placeholder,
|
||||||
|
leadingIcon = {
|
||||||
|
IconButton({ onActiveChange(false) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
stringResource(R.string.back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
@ -2,39 +2,33 @@ package app.revanced.manager.ui.component.bundle
|
|||||||
|
|
||||||
import android.webkit.URLUtil
|
import android.webkit.URLUtil
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
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.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.ArrowRight
|
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material.icons.outlined.Extension
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material.icons.outlined.Inventory2
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material.icons.outlined.Sell
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
|
import app.revanced.manager.ui.component.ColumnWithScrollbar
|
||||||
import app.revanced.manager.ui.component.TextInputDialog
|
import app.revanced.manager.ui.component.TextInputDialog
|
||||||
import app.revanced.manager.util.isDebuggable
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BaseBundleDialog(
|
fun BaseBundleDialog(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isDefault: Boolean,
|
isDefault: Boolean,
|
||||||
name: String,
|
name: String?,
|
||||||
onNameChange: ((String) -> Unit)? = null,
|
|
||||||
remoteUrl: String?,
|
remoteUrl: String?,
|
||||||
onRemoteUrlChange: ((String) -> Unit)? = null,
|
onRemoteUrlChange: ((String) -> Unit)? = null,
|
||||||
patchCount: Int,
|
patchCount: Int,
|
||||||
@ -42,143 +36,145 @@ fun BaseBundleDialog(
|
|||||||
autoUpdate: Boolean,
|
autoUpdate: Boolean,
|
||||||
onAutoUpdateChange: (Boolean) -> Unit,
|
onAutoUpdateChange: (Boolean) -> Unit,
|
||||||
onPatchesClick: () -> Unit,
|
onPatchesClick: () -> Unit,
|
||||||
onBundleTypeClick: () -> Unit = {},
|
|
||||||
extraFields: @Composable ColumnScope.() -> Unit = {}
|
extraFields: @Composable ColumnScope.() -> Unit = {}
|
||||||
) = Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(
|
|
||||||
start = 8.dp,
|
|
||||||
top = 8.dp,
|
|
||||||
end = 4.dp,
|
|
||||||
)
|
|
||||||
.then(modifier)
|
|
||||||
) {
|
) {
|
||||||
var showNameInputDialog by rememberSaveable {
|
ColumnWithScrollbar(
|
||||||
mutableStateOf(false)
|
modifier = Modifier
|
||||||
}
|
.fillMaxWidth()
|
||||||
if (showNameInputDialog) {
|
.then(modifier),
|
||||||
TextInputDialog(
|
) {
|
||||||
initial = name,
|
Column(
|
||||||
title = stringResource(R.string.bundle_input_name),
|
modifier = Modifier.padding(16.dp),
|
||||||
onDismissRequest = {
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
showNameInputDialog = false
|
) {
|
||||||
},
|
Row(
|
||||||
onConfirm = {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
showNameInputDialog = false
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
|
||||||
onNameChange?.invoke(it)
|
verticalAlignment = Alignment.CenterVertically
|
||||||
},
|
) {
|
||||||
validator = {
|
Icon(
|
||||||
it.length in 1..19
|
imageVector = Icons.Outlined.Inventory2,
|
||||||
}
|
contentDescription = null,
|
||||||
)
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
}
|
modifier = Modifier.size(32.dp)
|
||||||
BundleListItem(
|
|
||||||
headlineText = stringResource(R.string.bundle_input_name),
|
|
||||||
supportingText = name.ifEmpty { stringResource(R.string.field_not_set) },
|
|
||||||
modifier = Modifier.clickable(enabled = onNameChange != null) {
|
|
||||||
showNameInputDialog = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
remoteUrl?.takeUnless { isDefault }?.let { url ->
|
|
||||||
var showUrlInputDialog by rememberSaveable {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
if (showUrlInputDialog) {
|
|
||||||
TextInputDialog(
|
|
||||||
initial = url,
|
|
||||||
title = stringResource(R.string.bundle_input_source_url),
|
|
||||||
onDismissRequest = { showUrlInputDialog = false },
|
|
||||||
onConfirm = {
|
|
||||||
showUrlInputDialog = false
|
|
||||||
onRemoteUrlChange?.invoke(it)
|
|
||||||
},
|
|
||||||
validator = {
|
|
||||||
if (it.isEmpty()) return@TextInputDialog false
|
|
||||||
|
|
||||||
URLUtil.isValidUrl(it)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
BundleListItem(
|
|
||||||
modifier = Modifier.clickable(enabled = onRemoteUrlChange != null) {
|
|
||||||
showUrlInputDialog = true
|
|
||||||
},
|
|
||||||
headlineText = stringResource(R.string.bundle_input_source_url),
|
|
||||||
supportingText = url.ifEmpty { stringResource(R.string.field_not_set) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
extraFields()
|
|
||||||
|
|
||||||
if (remoteUrl != null) {
|
|
||||||
BundleListItem(
|
|
||||||
headlineText = stringResource(R.string.automatically_update),
|
|
||||||
supportingText = stringResource(R.string.automatically_update_description),
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = autoUpdate,
|
|
||||||
onCheckedChange = onAutoUpdateChange
|
|
||||||
)
|
)
|
||||||
},
|
name?.let {
|
||||||
modifier = Modifier.clickable {
|
Text(
|
||||||
onAutoUpdateChange(!autoUpdate)
|
text = it,
|
||||||
}
|
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)),
|
||||||
)
|
color = MaterialTheme.colorScheme.primary,
|
||||||
}
|
)
|
||||||
|
|
||||||
BundleListItem(
|
|
||||||
headlineText = stringResource(R.string.bundle_type),
|
|
||||||
supportingText = stringResource(R.string.bundle_type_description),
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
onBundleTypeClick()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = onBundleTypeClick,
|
|
||||||
content = {
|
|
||||||
if (remoteUrl == null) {
|
|
||||||
Text(stringResource(R.string.local))
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.remote))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
Row(
|
||||||
}
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 2.dp)
|
||||||
|
) {
|
||||||
|
version?.let {
|
||||||
|
Tag(Icons.Outlined.Sell, it)
|
||||||
|
}
|
||||||
|
Tag(Icons.Outlined.Extension, patchCount.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (version != null || patchCount > 0) {
|
HorizontalDivider(
|
||||||
Text(
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
text = stringResource(R.string.information),
|
color = MaterialTheme.colorScheme.outlineVariant
|
||||||
modifier = Modifier.padding(
|
|
||||||
horizontal = 16.dp,
|
|
||||||
vertical = 12.dp
|
|
||||||
),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
val patchesClickable = LocalContext.current.isDebuggable && patchCount > 0
|
if (remoteUrl != null) {
|
||||||
BundleListItem(
|
BundleListItem(
|
||||||
headlineText = stringResource(R.string.patches),
|
headlineText = stringResource(R.string.bundle_auto_update),
|
||||||
supportingText = if (patchCount == 0) stringResource(R.string.no_patches)
|
supportingText = stringResource(R.string.bundle_auto_update_description),
|
||||||
else stringResource(R.string.patches_available, patchCount),
|
trailingContent = {
|
||||||
modifier = Modifier.clickable(enabled = patchesClickable, onClick = onPatchesClick)
|
Switch(
|
||||||
) {
|
checked = autoUpdate,
|
||||||
if (patchesClickable)
|
onCheckedChange = onAutoUpdateChange
|
||||||
Icon(
|
)
|
||||||
Icons.Outlined.ArrowRight,
|
},
|
||||||
stringResource(R.string.patches)
|
modifier = Modifier.clickable {
|
||||||
|
onAutoUpdateChange(!autoUpdate)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
version?.let {
|
remoteUrl?.takeUnless { isDefault }?.let { url ->
|
||||||
|
var showUrlInputDialog by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
if (showUrlInputDialog) {
|
||||||
|
TextInputDialog(
|
||||||
|
initial = url,
|
||||||
|
title = stringResource(R.string.bundle_input_source_url),
|
||||||
|
onDismissRequest = { showUrlInputDialog = false },
|
||||||
|
onConfirm = {
|
||||||
|
showUrlInputDialog = false
|
||||||
|
onRemoteUrlChange?.invoke(it)
|
||||||
|
},
|
||||||
|
validator = {
|
||||||
|
if (it.isEmpty()) return@TextInputDialog false
|
||||||
|
|
||||||
|
URLUtil.isValidUrl(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
BundleListItem(
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
enabled = onRemoteUrlChange != null,
|
||||||
|
onClick = {
|
||||||
|
showUrlInputDialog = true
|
||||||
|
}
|
||||||
|
),
|
||||||
|
headlineText = stringResource(R.string.bundle_input_source_url),
|
||||||
|
supportingText = url.ifEmpty {
|
||||||
|
stringResource(R.string.field_not_set)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val patchesClickable = patchCount > 0
|
||||||
BundleListItem(
|
BundleListItem(
|
||||||
headlineText = stringResource(R.string.version),
|
headlineText = stringResource(R.string.patches),
|
||||||
supportingText = it,
|
supportingText = stringResource(R.string.bundle_view_patches),
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
enabled = patchesClickable,
|
||||||
|
onClick = onPatchesClick
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (patchesClickable) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Outlined.ArrowRight,
|
||||||
|
stringResource(R.string.patches)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extraFields()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Tag(
|
||||||
|
icon: ImageVector,
|
||||||
|
text: String
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,33 +1,32 @@
|
|||||||
package app.revanced.manager.ui.component.bundle
|
package app.revanced.manager.ui.component.bundle
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
|
||||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
import androidx.compose.material.icons.outlined.DeleteOutline
|
||||||
import androidx.compose.material.icons.outlined.Refresh
|
import androidx.compose.material.icons.outlined.Share
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material.icons.outlined.Update
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.domain.bundles.LocalPatchBundle
|
import app.revanced.manager.domain.bundles.LocalPatchBundle
|
||||||
import app.revanced.manager.domain.bundles.RemotePatchBundle
|
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource
|
import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.asRemoteOrNull
|
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.isDefault
|
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.propsOrNullFlow
|
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
|
||||||
import kotlinx.coroutines.flow.map
|
import app.revanced.manager.ui.component.ColumnWithScrollbar
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -36,17 +35,18 @@ fun BundleInformationDialog(
|
|||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onDeleteRequest: () -> Unit,
|
onDeleteRequest: () -> Unit,
|
||||||
bundle: PatchBundleSource,
|
bundle: PatchBundleSource,
|
||||||
onRefreshButton: () -> Unit,
|
onUpdate: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val composableScope = rememberCoroutineScope()
|
val composableScope = rememberCoroutineScope()
|
||||||
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
|
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
|
||||||
val isLocal = bundle is LocalPatchBundle
|
val isLocal = bundle is LocalPatchBundle
|
||||||
val patchCount by remember(bundle) {
|
val state by bundle.state.collectAsStateWithLifecycle()
|
||||||
bundle.state.map { it.patchBundleOrNull()?.patches?.size ?: 0 }
|
|
||||||
}.collectAsStateWithLifecycle(0)
|
|
||||||
val props by remember(bundle) {
|
val props by remember(bundle) {
|
||||||
bundle.propsOrNullFlow()
|
bundle.propsFlow()
|
||||||
}.collectAsStateWithLifecycle(null)
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
val patchCount = remember(state) {
|
||||||
|
state.patchBundleOrNull()?.patches?.size ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
if (viewCurrentBundlePatches) {
|
if (viewCurrentBundlePatches) {
|
||||||
BundlePatchesDialog(
|
BundlePatchesDialog(
|
||||||
@ -64,14 +64,16 @@ fun BundleInformationDialog(
|
|||||||
dismissOnBackPress = true
|
dismissOnBackPress = true
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
val bundleName by bundle.nameState
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
BundleTopBar(
|
BundleTopBar(
|
||||||
title = bundle.name,
|
title = stringResource(R.string.patch_bundle_field),
|
||||||
onBackClick = onDismissRequest,
|
onBackClick = onDismissRequest,
|
||||||
onBackIcon = {
|
backIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = stringResource(R.string.back)
|
contentDescription = stringResource(R.string.back)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -85,9 +87,9 @@ fun BundleInformationDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!isLocal) {
|
if (!isLocal) {
|
||||||
IconButton(onClick = onRefreshButton) {
|
IconButton(onClick = onUpdate) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Refresh,
|
Icons.Outlined.Update,
|
||||||
stringResource(R.string.refresh)
|
stringResource(R.string.refresh)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -99,7 +101,7 @@ fun BundleInformationDialog(
|
|||||||
BaseBundleDialog(
|
BaseBundleDialog(
|
||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
isDefault = bundle.isDefault,
|
isDefault = bundle.isDefault,
|
||||||
name = bundle.name,
|
name = bundleName,
|
||||||
remoteUrl = bundle.asRemoteOrNull?.endpoint,
|
remoteUrl = bundle.asRemoteOrNull?.endpoint,
|
||||||
patchCount = patchCount,
|
patchCount = patchCount,
|
||||||
version = props?.versionInfo?.patches,
|
version = props?.versionInfo?.patches,
|
||||||
@ -112,7 +114,95 @@ fun BundleInformationDialog(
|
|||||||
onPatchesClick = {
|
onPatchesClick = {
|
||||||
viewCurrentBundlePatches = true
|
viewCurrentBundlePatches = true
|
||||||
},
|
},
|
||||||
|
extraFields = {
|
||||||
|
(state as? PatchBundleSource.State.Failed)?.throwable?.let {
|
||||||
|
var showDialog by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
if (showDialog) BundleErrorViewerDialog(
|
||||||
|
onDismiss = { showDialog = false },
|
||||||
|
text = remember(it) { it.stackTraceToString() }
|
||||||
|
)
|
||||||
|
|
||||||
|
BundleListItem(
|
||||||
|
headlineText = stringResource(R.string.bundle_error),
|
||||||
|
supportingText = stringResource(R.string.bundle_error_description),
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Outlined.ArrowRight,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { showDialog = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state is PatchBundleSource.State.Missing && !isLocal) {
|
||||||
|
BundleListItem(
|
||||||
|
headlineText = stringResource(R.string.bundle_error),
|
||||||
|
supportingText = stringResource(R.string.bundle_not_downloaded),
|
||||||
|
modifier = Modifier.clickable(onClick = onUpdate)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun BundleErrorViewerDialog(onDismiss: () -> Unit, text: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnBackPress = true
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
BundleTopBar(
|
||||||
|
title = stringResource(R.string.bundle_error),
|
||||||
|
onBackClick = onDismiss,
|
||||||
|
backIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.back)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val sendIntent: Intent = Intent().apply {
|
||||||
|
action = Intent.ACTION_SEND
|
||||||
|
putExtra(
|
||||||
|
Intent.EXTRA_TEXT,
|
||||||
|
text
|
||||||
|
)
|
||||||
|
type = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Share,
|
||||||
|
contentDescription = stringResource(R.string.share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
ColumnWithScrollbar(
|
||||||
|
modifier = Modifier.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
Text(text, modifier = Modifier.horizontalScroll(rememberScrollState()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource
|
import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.propsOrNullFlow
|
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@ -45,8 +45,9 @@ fun BundleItem(
|
|||||||
val state by bundle.state.collectAsStateWithLifecycle()
|
val state by bundle.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val version by remember(bundle) {
|
val version by remember(bundle) {
|
||||||
bundle.propsOrNullFlow().map { props -> props?.versionInfo?.patches }
|
bundle.propsFlow().map { props -> props?.versionInfo?.patches }
|
||||||
}.collectAsStateWithLifecycle(null)
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
val name by bundle.nameState
|
||||||
|
|
||||||
if (viewBundleDialogPage) {
|
if (viewBundleDialogPage) {
|
||||||
BundleInformationDialog(
|
BundleInformationDialog(
|
||||||
@ -56,7 +57,7 @@ fun BundleItem(
|
|||||||
onDelete()
|
onDelete()
|
||||||
},
|
},
|
||||||
bundle = bundle,
|
bundle = bundle,
|
||||||
onRefreshButton = onUpdate,
|
onUpdate = onUpdate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,34 +66,22 @@ fun BundleItem(
|
|||||||
.height(64.dp)
|
.height(64.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = {
|
onClick = { viewBundleDialogPage = true },
|
||||||
viewBundleDialogPage = true
|
|
||||||
},
|
|
||||||
onLongClick = onSelect,
|
onLongClick = onSelect,
|
||||||
),
|
),
|
||||||
leadingContent = {
|
leadingContent = if (selectable) {
|
||||||
if(selectable) {
|
{
|
||||||
Checkbox(
|
Checkbox(
|
||||||
checked = isBundleSelected,
|
checked = isBundleSelected,
|
||||||
onCheckedChange = toggleSelection,
|
onCheckedChange = toggleSelection,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
} else null,
|
||||||
|
|
||||||
headlineContent = {
|
headlineContent = { Text(name) },
|
||||||
Text(
|
|
||||||
text = bundle.name,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
state.patchBundleOrNull()?.patches?.size?.let { patchCount ->
|
state.patchBundleOrNull()?.patches?.size?.let { patchCount ->
|
||||||
Text(
|
Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount))
|
||||||
text = pluralStringResource(R.plurals.patches_count, patchCount, patchCount),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
@ -107,20 +96,14 @@ fun BundleItem(
|
|||||||
|
|
||||||
icon?.let { (vector, description) ->
|
icon?.let { (vector, description) ->
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = vector,
|
vector,
|
||||||
contentDescription = stringResource(description),
|
contentDescription = stringResource(description),
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
tint = MaterialTheme.colorScheme.error
|
tint = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
version?.let { txt ->
|
version?.let { Text(text = it) }
|
||||||
Text(
|
|
||||||
text = txt,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -3,15 +3,12 @@ package app.revanced.manager.ui.component.bundle
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.material.icons.outlined.Lightbulb
|
import androidx.compose.material.icons.outlined.Lightbulb
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@ -29,6 +26,7 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource
|
import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||||
|
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||||
import app.revanced.manager.ui.component.NotificationCard
|
import app.revanced.manager.ui.component.NotificationCard
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@ -52,16 +50,16 @@ fun BundlePatchesDialog(
|
|||||||
BundleTopBar(
|
BundleTopBar(
|
||||||
title = stringResource(R.string.bundle_patches),
|
title = stringResource(R.string.bundle_patches),
|
||||||
onBackClick = onDismissRequest,
|
onBackClick = onDismissRequest,
|
||||||
onBackIcon = {
|
backIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = stringResource(R.string.back)
|
contentDescription = stringResource(R.string.back)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
LazyColumnWithScrollbar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
@ -98,7 +96,7 @@ fun BundlePatchesDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,10 +12,12 @@ import androidx.compose.material3.ModalBottomSheet
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource
|
import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||||
|
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -51,6 +53,7 @@ fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSourc
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
bundles.forEach {
|
bundles.forEach {
|
||||||
|
val name by it.nameState
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
@ -62,7 +65,7 @@ fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSourc
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = it.name,
|
name,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
@ -19,7 +19,7 @@ fun BundleTopBar(
|
|||||||
onBackClick: (() -> Unit)? = null,
|
onBackClick: (() -> Unit)? = null,
|
||||||
actions: @Composable (RowScope.() -> Unit) = {},
|
actions: @Composable (RowScope.() -> Unit) = {},
|
||||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||||
onBackIcon: @Composable () -> Unit,
|
backIcon: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ fun BundleTopBar(
|
|||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (onBackClick != null) {
|
if (onBackClick != null) {
|
||||||
IconButton(onClick = onBackClick) {
|
IconButton(onClick = onBackClick) {
|
||||||
onBackIcon()
|
backIcon()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -4,56 +4,61 @@ import android.net.Uri
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.Topic
|
import androidx.compose.material.icons.filled.Topic
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
|
import app.revanced.manager.ui.component.AlertDialogExtended
|
||||||
|
import app.revanced.manager.ui.component.TextHorizontalPadding
|
||||||
|
import app.revanced.manager.ui.model.BundleType
|
||||||
import app.revanced.manager.util.APK_MIMETYPE
|
import app.revanced.manager.util.APK_MIMETYPE
|
||||||
import app.revanced.manager.util.JAR_MIMETYPE
|
import app.revanced.manager.util.JAR_MIMETYPE
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportBundleDialog(
|
fun ImportPatchBundleDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onRemoteSubmit: (String, String, Boolean) -> Unit,
|
onRemoteSubmit: (String, Boolean) -> Unit,
|
||||||
onLocalSubmit: (String, Uri, Uri?) -> Unit
|
onLocalSubmit: (Uri, Uri?) -> Unit
|
||||||
) {
|
) {
|
||||||
var name by rememberSaveable { mutableStateOf("") }
|
var currentStep by rememberSaveable { mutableIntStateOf(0) }
|
||||||
var remoteUrl by rememberSaveable { mutableStateOf("") }
|
var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) }
|
||||||
var autoUpdate by rememberSaveable { mutableStateOf(true) }
|
|
||||||
var isLocal by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
|
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
var integrations by rememberSaveable { mutableStateOf<Uri?>(null) }
|
var integrations by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
|
var remoteUrl by rememberSaveable { mutableStateOf("") }
|
||||||
val inputsAreValid by remember {
|
var autoUpdate by rememberSaveable { mutableStateOf(false) }
|
||||||
derivedStateOf {
|
|
||||||
name.isNotEmpty() && if (isLocal) patchBundle != null else remoteUrl.isNotEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val patchActivityLauncher =
|
val patchActivityLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
uri?.let { patchBundle = it }
|
uri?.let { patchBundle = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun launchPatchActivity() {
|
fun launchPatchActivity() {
|
||||||
patchActivityLauncher.launch(JAR_MIMETYPE)
|
patchActivityLauncher.launch(JAR_MIMETYPE)
|
||||||
}
|
}
|
||||||
@ -62,101 +67,212 @@ fun ImportBundleDialog(
|
|||||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
uri?.let { integrations = it }
|
uri?.let { integrations = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun launchIntegrationsActivity() {
|
fun launchIntegrationsActivity() {
|
||||||
integrationsActivityLauncher.launch(APK_MIMETYPE)
|
integrationsActivityLauncher.launch(APK_MIMETYPE)
|
||||||
}
|
}
|
||||||
|
|
||||||
Dialog(
|
val steps = listOf<@Composable () -> Unit>(
|
||||||
onDismissRequest = onDismissRequest,
|
{
|
||||||
properties = DialogProperties(
|
SelectBundleTypeStep(bundleType) { selectedType ->
|
||||||
usePlatformDefaultWidth = false,
|
bundleType = selectedType
|
||||||
dismissOnBackPress = true
|
}
|
||||||
)
|
},
|
||||||
) {
|
{
|
||||||
Scaffold(
|
ImportBundleStep(
|
||||||
topBar = {
|
bundleType,
|
||||||
BundleTopBar(
|
patchBundle,
|
||||||
title = stringResource(R.string.import_bundle),
|
integrations,
|
||||||
onBackClick = onDismissRequest,
|
remoteUrl,
|
||||||
onBackIcon = {
|
autoUpdate,
|
||||||
Icon(
|
{ launchPatchActivity() },
|
||||||
imageVector = Icons.Default.Close,
|
{ launchIntegrationsActivity() },
|
||||||
contentDescription = stringResource(R.string.close)
|
{ remoteUrl = it },
|
||||||
)
|
{ autoUpdate = it }
|
||||||
},
|
)
|
||||||
actions = {
|
}
|
||||||
TextButton(
|
)
|
||||||
enabled = inputsAreValid,
|
|
||||||
onClick = {
|
|
||||||
if (isLocal) {
|
|
||||||
onLocalSubmit(name, patchBundle!!, integrations)
|
|
||||||
} else {
|
|
||||||
onRemoteSubmit(
|
|
||||||
name,
|
|
||||||
remoteUrl,
|
|
||||||
autoUpdate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.padding(end = 16.dp)
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.import_))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { paddingValues ->
|
|
||||||
BaseBundleDialog(
|
|
||||||
modifier = Modifier.padding(paddingValues),
|
|
||||||
isDefault = false,
|
|
||||||
name = name,
|
|
||||||
onNameChange = { name = it },
|
|
||||||
remoteUrl = remoteUrl.takeUnless { isLocal },
|
|
||||||
onRemoteUrlChange = { remoteUrl = it },
|
|
||||||
patchCount = 0,
|
|
||||||
version = null,
|
|
||||||
autoUpdate = autoUpdate,
|
|
||||||
onAutoUpdateChange = { autoUpdate = it },
|
|
||||||
onPatchesClick = {},
|
|
||||||
onBundleTypeClick = { isLocal = !isLocal },
|
|
||||||
) {
|
|
||||||
if (!isLocal) return@BaseBundleDialog
|
|
||||||
|
|
||||||
BundleListItem(
|
|
||||||
headlineText = stringResource(R.string.patch_bundle_field),
|
|
||||||
supportingText = stringResource(if (patchBundle != null) R.string.file_field_set else R.string.file_field_not_set),
|
|
||||||
trailingContent = {
|
|
||||||
IconButton(
|
|
||||||
onClick = ::launchPatchActivity
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Topic,
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
launchPatchActivity()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
BundleListItem(
|
val inputsAreValid by remember {
|
||||||
headlineText = stringResource(R.string.integrations_field),
|
derivedStateOf {
|
||||||
supportingText = stringResource(if (integrations != null) R.string.file_field_set else R.string.file_field_not_set),
|
(bundleType == BundleType.Local && patchBundle != null) ||
|
||||||
trailingContent = {
|
(bundleType == BundleType.Remote && remoteUrl.isNotEmpty())
|
||||||
IconButton(
|
}
|
||||||
onClick = ::launchIntegrationsActivity
|
}
|
||||||
) {
|
|
||||||
Icon(
|
AlertDialogExtended(
|
||||||
imageVector = Icons.Default.Topic,
|
onDismissRequest = onDismiss,
|
||||||
contentDescription = null
|
title = {
|
||||||
)
|
Text(stringResource(if (currentStep == 0) R.string.select else R.string.add_patch_bundle))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
steps[currentStep]()
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
if (currentStep == steps.lastIndex) {
|
||||||
|
TextButton(
|
||||||
|
enabled = inputsAreValid,
|
||||||
|
onClick = {
|
||||||
|
when (bundleType) {
|
||||||
|
BundleType.Local -> patchBundle?.let {
|
||||||
|
onLocalSubmit(
|
||||||
|
it,
|
||||||
|
integrations
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
BundleType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
launchIntegrationsActivity()
|
|
||||||
}
|
}
|
||||||
)
|
) {
|
||||||
|
Text(stringResource(R.string.add))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = { currentStep++ }) {
|
||||||
|
Text(stringResource(R.string.next))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
if (currentStep > 0) {
|
||||||
|
TextButton(onClick = { currentStep-- }) {
|
||||||
|
Text(stringResource(R.string.back))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
textHorizontalPadding = PaddingValues(0.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SelectBundleTypeStep(
|
||||||
|
bundleType: BundleType,
|
||||||
|
onBundleTypeSelected: (BundleType) -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp),
|
||||||
|
text = stringResource(R.string.select_bundle_type_dialog_description)
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
role = Role.RadioButton,
|
||||||
|
onClick = { onBundleTypeSelected(BundleType.Remote) }
|
||||||
|
),
|
||||||
|
headlineContent = { Text(stringResource(R.string.enter_url)) },
|
||||||
|
overlineContent = { Text(stringResource(R.string.recommended)) },
|
||||||
|
supportingContent = { Text(stringResource(R.string.remote_bundle_description)) },
|
||||||
|
leadingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = bundleType == BundleType.Remote,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
role = Role.RadioButton,
|
||||||
|
onClick = { onBundleTypeSelected(BundleType.Local) }
|
||||||
|
),
|
||||||
|
headlineContent = { Text(stringResource(R.string.select_from_storage)) },
|
||||||
|
supportingContent = { Text(stringResource(R.string.local_bundle_description)) },
|
||||||
|
overlineContent = { },
|
||||||
|
leadingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = bundleType == BundleType.Local,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ImportBundleStep(
|
||||||
|
bundleType: BundleType,
|
||||||
|
patchBundle: Uri?,
|
||||||
|
integrations: Uri?,
|
||||||
|
remoteUrl: String,
|
||||||
|
autoUpdate: Boolean,
|
||||||
|
launchPatchActivity: () -> Unit,
|
||||||
|
launchIntegrationsActivity: () -> Unit,
|
||||||
|
onRemoteUrlChange: (String) -> Unit,
|
||||||
|
onAutoUpdateChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
when (bundleType) {
|
||||||
|
BundleType.Local -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.patch_bundle_field))
|
||||||
|
},
|
||||||
|
supportingContent = { Text(stringResource(if (patchBundle != null) R.string.file_field_set else R.string.file_field_not_set)) },
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(onClick = launchPatchActivity) {
|
||||||
|
Icon(imageVector = Icons.Default.Topic, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { launchPatchActivity() }
|
||||||
|
)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(R.string.integrations_field))
|
||||||
|
},
|
||||||
|
supportingContent = { Text(stringResource(if (integrations != null) R.string.file_field_set else R.string.file_field_not_set)) },
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(onClick = launchIntegrationsActivity) {
|
||||||
|
Icon(imageVector = Icons.Default.Topic, contentDescription = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable { launchIntegrationsActivity() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BundleType.Remote -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(TextHorizontalPadding)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = remoteUrl,
|
||||||
|
onValueChange = onRemoteUrlChange,
|
||||||
|
label = { Text(stringResource(R.string.bundle_url)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
role = Role.Checkbox,
|
||||||
|
onClick = { onAutoUpdateChange(!autoUpdate) }
|
||||||
|
),
|
||||||
|
headlineContent = { Text(stringResource(R.string.auto_update)) },
|
||||||
|
leadingContent = {
|
||||||
|
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
|
||||||
|
Checkbox(
|
||||||
|
checked = autoUpdate,
|
||||||
|
onCheckedChange = {
|
||||||
|
onAutoUpdateChange(!autoUpdate)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,62 @@
|
|||||||
|
package app.revanced.manager.ui.component.patcher
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.revanced.manager.R
|
||||||
|
import app.revanced.manager.data.room.apps.installed.InstallType
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InstallPickerDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (InstallType) -> Unit
|
||||||
|
) {
|
||||||
|
var selectedInstallType by rememberSaveable { mutableStateOf(InstallType.DEFAULT) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
dismissButton = {
|
||||||
|
Button(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onConfirm(selectedInstallType)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.install_app))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.select_install_type)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
InstallType.values().forEach {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { selectedInstallType = it },
|
||||||
|
leadingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedInstallType == it,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = { Text(stringResource(it.stringResource)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,240 @@
|
|||||||
|
package app.revanced.manager.ui.component.patcher
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Cancel
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.outlined.Circle
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import app.revanced.manager.R
|
||||||
|
import app.revanced.manager.ui.component.ArrowButton
|
||||||
|
import app.revanced.manager.ui.component.LoadingIndicator
|
||||||
|
import app.revanced.manager.ui.model.State
|
||||||
|
import app.revanced.manager.ui.model.Step
|
||||||
|
import app.revanced.manager.ui.model.StepCategory
|
||||||
|
import kotlin.math.floor
|
||||||
|
|
||||||
|
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
|
||||||
|
@Composable
|
||||||
|
fun Steps(
|
||||||
|
category: StepCategory,
|
||||||
|
steps: List<Step>,
|
||||||
|
stepCount: Pair<Int, Int>? = null,
|
||||||
|
) {
|
||||||
|
var expanded by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val categoryColor by animateColorAsState(
|
||||||
|
if (expanded) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent,
|
||||||
|
label = "category"
|
||||||
|
)
|
||||||
|
|
||||||
|
val cardColor by animateColorAsState(
|
||||||
|
if (expanded) MaterialTheme.colorScheme.surfaceContainer else Color.Transparent,
|
||||||
|
label = "card"
|
||||||
|
)
|
||||||
|
|
||||||
|
val state = remember(steps) {
|
||||||
|
when {
|
||||||
|
steps.all { it.state == State.COMPLETED } -> State.COMPLETED
|
||||||
|
steps.any { it.state == State.FAILED } -> State.FAILED
|
||||||
|
steps.any { it.state == State.RUNNING } -> State.RUNNING
|
||||||
|
else -> State.WAITING
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(cardColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.clickable { expanded = !expanded }
|
||||||
|
.background(categoryColor)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
StepIcon(state = state, size = 24.dp)
|
||||||
|
|
||||||
|
Text(stringResource(category.displayName))
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
val stepProgress = remember(stepCount, steps) {
|
||||||
|
stepCount?.let { (current, total) -> "$current/$total" }
|
||||||
|
?: "${steps.count { it.state == State.COMPLETED }}/${steps.size}"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stepProgress,
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
ArrowButton(modifier = Modifier.size(24.dp), expanded = expanded, onClick = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = expanded) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
steps.forEach { step ->
|
||||||
|
val downloadProgress = step.downloadProgress?.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
SubStep(
|
||||||
|
name = step.name,
|
||||||
|
state = step.state,
|
||||||
|
message = step.message,
|
||||||
|
downloadProgress = downloadProgress?.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SubStep(
|
||||||
|
name: String,
|
||||||
|
state: State,
|
||||||
|
message: String? = null,
|
||||||
|
downloadProgress: Pair<Float, Float>? = null
|
||||||
|
) {
|
||||||
|
var messageExpanded by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.run {
|
||||||
|
if (message != null)
|
||||||
|
clickable { messageExpanded = !messageExpanded }
|
||||||
|
else this
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
StepIcon(state, downloadProgress, size = 20.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, true),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (message != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
ArrowButton(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
expanded = messageExpanded,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
downloadProgress?.let { (current, total) ->
|
||||||
|
Text(
|
||||||
|
"$current/$total MB",
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = messageExpanded && message != null) {
|
||||||
|
Text(
|
||||||
|
text = message.orEmpty(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier.padding(horizontal = 52.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StepIcon(state: State, progress: Pair<Float, Float>? = null, size: Dp) {
|
||||||
|
val strokeWidth = Dp(floor(size.value / 10) + 1)
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
State.COMPLETED -> Icon(
|
||||||
|
Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = stringResource(R.string.step_completed),
|
||||||
|
tint = MaterialTheme.colorScheme.surfaceTint,
|
||||||
|
modifier = Modifier.size(size)
|
||||||
|
)
|
||||||
|
|
||||||
|
State.FAILED -> Icon(
|
||||||
|
Icons.Filled.Cancel,
|
||||||
|
contentDescription = stringResource(R.string.step_failed),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(size)
|
||||||
|
)
|
||||||
|
|
||||||
|
State.WAITING -> Icon(
|
||||||
|
Icons.Outlined.Circle,
|
||||||
|
contentDescription = stringResource(R.string.step_waiting),
|
||||||
|
tint = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.size(size)
|
||||||
|
)
|
||||||
|
|
||||||
|
State.RUNNING ->
|
||||||
|
LoadingIndicator(
|
||||||
|
modifier = stringResource(R.string.step_running).let { description ->
|
||||||
|
Modifier
|
||||||
|
.size(size)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = description
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progress = { progress?.let { (current, total) -> current / total } },
|
||||||
|
strokeWidth = strokeWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -1,204 +1,660 @@
|
|||||||
package app.revanced.manager.ui.component.patches
|
package app.revanced.manager.ui.component.patches
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Parcelable
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.LocalIndication
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
|
import androidx.compose.material.icons.outlined.Add
|
||||||
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
import androidx.compose.material.icons.outlined.Edit
|
import androidx.compose.material.icons.outlined.Edit
|
||||||
import androidx.compose.material.icons.outlined.Folder
|
import androidx.compose.material.icons.outlined.Folder
|
||||||
import androidx.compose.material.icons.outlined.MoreVert
|
import androidx.compose.material.icons.outlined.MoreVert
|
||||||
|
import androidx.compose.material.icons.outlined.Restore
|
||||||
|
import androidx.compose.material.icons.outlined.SelectAll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisallowComposableCalls
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog as ComposeDialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.data.platform.Filesystem
|
import app.revanced.manager.data.platform.Filesystem
|
||||||
import app.revanced.manager.patcher.patch.Option
|
import app.revanced.manager.patcher.patch.Option
|
||||||
|
import app.revanced.manager.ui.component.AlertDialogExtended
|
||||||
|
import app.revanced.manager.ui.component.AppTopBar
|
||||||
|
import app.revanced.manager.ui.component.FloatInputDialog
|
||||||
|
import app.revanced.manager.ui.component.IntInputDialog
|
||||||
|
import app.revanced.manager.ui.component.LongInputDialog
|
||||||
|
import app.revanced.manager.util.isScrollingUp
|
||||||
|
import app.revanced.manager.util.mutableStateSetOf
|
||||||
|
import app.revanced.manager.util.saver.snapshotStateListSaver
|
||||||
|
import app.revanced.manager.util.saver.snapshotStateSetSaver
|
||||||
import app.revanced.manager.util.toast
|
import app.revanced.manager.util.toast
|
||||||
import org.koin.compose.rememberKoinInject
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.get
|
||||||
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
|
import sh.calvin.reorderable.rememberReorderableLazyColumnState
|
||||||
|
import java.io.Serializable
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
// Composable functions do not support function references, so we have to use composable lambdas instead.
|
private class OptionEditorScope<T : Any>(
|
||||||
private typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit
|
private val editor: OptionEditor<T>,
|
||||||
|
val option: Option<T>,
|
||||||
@Composable
|
val openDialog: () -> Unit,
|
||||||
private fun OptionListItem(
|
val dismissDialog: () -> Unit,
|
||||||
option: Option,
|
val value: T?,
|
||||||
onClick: () -> Unit,
|
val setValue: (T?) -> Unit,
|
||||||
trailingContent: @Composable () -> Unit
|
|
||||||
) {
|
) {
|
||||||
ListItem(
|
fun submitDialog(value: T?) {
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
setValue(value)
|
||||||
headlineContent = { Text(option.title) },
|
dismissDialog()
|
||||||
supportingContent = { Text(option.description) },
|
}
|
||||||
trailingContent = trailingContent
|
|
||||||
)
|
fun clickAction() = editor.clickAction(this)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Dialog() = editor.Dialog(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private interface OptionEditor<T : Any> {
|
||||||
private fun StringOptionDialog(
|
fun clickAction(scope: OptionEditorScope<T>) = scope.openDialog()
|
||||||
name: String,
|
|
||||||
value: String?,
|
|
||||||
onSubmit: (String) -> Unit,
|
|
||||||
onDismissRequest: () -> Unit
|
|
||||||
) {
|
|
||||||
var showFileDialog by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var fieldValue by rememberSaveable(value) {
|
|
||||||
mutableStateOf(value.orEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
val fs: Filesystem = rememberKoinInject()
|
@Composable
|
||||||
val (contract, permissionName) = fs.permissionContract()
|
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
|
IconButton(onClick = { clickAction(scope) }) {
|
||||||
showFileDialog = it
|
Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
|
||||||
}
|
|
||||||
|
|
||||||
if (showFileDialog) {
|
|
||||||
PathSelectorDialog(
|
|
||||||
root = fs.externalFilesDir()
|
|
||||||
) {
|
|
||||||
showFileDialog = false
|
|
||||||
it?.let { path ->
|
|
||||||
fieldValue = path.toString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AlertDialog(
|
@Composable
|
||||||
onDismissRequest = onDismissRequest,
|
fun Dialog(scope: OptionEditorScope<T>)
|
||||||
title = { Text(name) },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = fieldValue,
|
|
||||||
onValueChange = { fieldValue = it },
|
|
||||||
placeholder = {
|
|
||||||
Text(stringResource(R.string.string_option_placeholder))
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
|
|
||||||
IconButton(
|
|
||||||
onClick = { showDropdownMenu = true }
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.MoreVert,
|
|
||||||
contentDescription = stringResource(R.string.string_option_menu_description)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showDropdownMenu,
|
|
||||||
onDismissRequest = { showDropdownMenu = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(Icons.Outlined.Folder, null)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.path_selector))
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
showDropdownMenu = false
|
|
||||||
if (fs.hasStoragePermission()) {
|
|
||||||
showFileDialog = true
|
|
||||||
} else {
|
|
||||||
permissionLauncher.launch(permissionName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { onSubmit(fieldValue) }) {
|
|
||||||
Text(stringResource(R.string.save))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismissRequest) {
|
|
||||||
Text(stringResource(R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val unknownOption: OptionImpl = { option, _, _ ->
|
private val optionEditors = mapOf(
|
||||||
val context = LocalContext.current
|
"Boolean" to BooleanOptionEditor,
|
||||||
OptionListItem(
|
"String" to StringOptionEditor,
|
||||||
option = option,
|
"Int" to IntOptionEditor,
|
||||||
onClick = { context.toast("Unknown type: ${option.type}") },
|
"Long" to LongOptionEditor,
|
||||||
trailingContent = {})
|
"Float" to FloatOptionEditor,
|
||||||
}
|
"BooleanArray" to ListOptionEditor(BooleanOptionEditor),
|
||||||
|
"StringArray" to ListOptionEditor(StringOptionEditor),
|
||||||
private val optionImplementations = mapOf<String, OptionImpl>(
|
"IntArray" to ListOptionEditor(IntOptionEditor),
|
||||||
// These are the only two types that are currently used by the official patches
|
"LongArray" to ListOptionEditor(LongOptionEditor),
|
||||||
"Boolean" to { option, value, setValue ->
|
"FloatArray" to ListOptionEditor(FloatOptionEditor),
|
||||||
val current = (value as? Boolean) ?: false
|
|
||||||
|
|
||||||
OptionListItem(
|
|
||||||
option = option,
|
|
||||||
onClick = { setValue(!current) }
|
|
||||||
) {
|
|
||||||
Switch(checked = current, onCheckedChange = setValue)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"String" to { option, value, setValue ->
|
|
||||||
var showInputDialog by rememberSaveable { mutableStateOf(false) }
|
|
||||||
fun showInputDialog() {
|
|
||||||
showInputDialog = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissInputDialog() {
|
|
||||||
showInputDialog = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showInputDialog) {
|
|
||||||
StringOptionDialog(
|
|
||||||
name = option.title,
|
|
||||||
value = value as? String,
|
|
||||||
onSubmit = {
|
|
||||||
dismissInputDialog()
|
|
||||||
setValue(it)
|
|
||||||
},
|
|
||||||
onDismissRequest = ::dismissInputDialog
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
OptionListItem(
|
|
||||||
option = option,
|
|
||||||
onClick = ::showInputDialog
|
|
||||||
) {
|
|
||||||
IconButton(onClick = ::showInputDialog) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Edit,
|
|
||||||
contentDescription = stringResource(R.string.string_option_icon_description)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
|
private inline fun <T : Any> WithOptionEditor(
|
||||||
val implementation = remember(option.type) {
|
editor: OptionEditor<T>,
|
||||||
optionImplementations.getOrDefault(
|
option: Option<T>,
|
||||||
option.type,
|
value: T?,
|
||||||
unknownOption
|
noinline setValue: (T?) -> Unit,
|
||||||
|
crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {},
|
||||||
|
block: OptionEditorScope<T>.() -> Unit
|
||||||
|
) {
|
||||||
|
var showDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val scope = remember(editor, option, value, setValue) {
|
||||||
|
OptionEditorScope(
|
||||||
|
editor,
|
||||||
|
option,
|
||||||
|
openDialog = { showDialog = true },
|
||||||
|
dismissDialog = {
|
||||||
|
showDialog = false
|
||||||
|
onDismissDialog()
|
||||||
|
},
|
||||||
|
value,
|
||||||
|
setValue
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation(option, value, setValue)
|
if (showDialog) scope.Dialog()
|
||||||
|
|
||||||
|
scope.block()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) {
|
||||||
|
val editor = remember(option.type, option.presets) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val baseOptionEditor =
|
||||||
|
optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor<T>
|
||||||
|
|
||||||
|
if (option.type != "Boolean" && option.presets != null) PresetOptionEditor(baseOptionEditor)
|
||||||
|
else baseOptionEditor
|
||||||
|
}
|
||||||
|
|
||||||
|
WithOptionEditor(editor, option, value, setValue) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable(onClick = ::clickAction),
|
||||||
|
headlineContent = { Text(option.title) },
|
||||||
|
supportingContent = { Text(option.description) },
|
||||||
|
trailingContent = { ListItemTrailingContent() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object StringOptionEditor : OptionEditor<String> {
|
||||||
|
@Composable
|
||||||
|
override fun Dialog(scope: OptionEditorScope<String>) {
|
||||||
|
var showFileDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var fieldValue by rememberSaveable(scope.value) {
|
||||||
|
mutableStateOf(scope.value.orEmpty())
|
||||||
|
}
|
||||||
|
val validatorFailed by remember {
|
||||||
|
derivedStateOf { !scope.option.validator(fieldValue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val fs: Filesystem = koinInject()
|
||||||
|
val (contract, permissionName) = fs.permissionContract()
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
|
||||||
|
showFileDialog = it
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFileDialog) {
|
||||||
|
PathSelectorDialog(
|
||||||
|
root = fs.externalFilesDir()
|
||||||
|
) {
|
||||||
|
showFileDialog = false
|
||||||
|
it?.let { path ->
|
||||||
|
fieldValue = path.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = scope.dismissDialog,
|
||||||
|
title = { Text(scope.option.title) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = fieldValue,
|
||||||
|
onValueChange = { fieldValue = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(stringResource(R.string.dialog_input_placeholder))
|
||||||
|
},
|
||||||
|
isError = validatorFailed,
|
||||||
|
supportingText = {
|
||||||
|
if (validatorFailed) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.input_dialog_value_invalid),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
|
||||||
|
IconButton(
|
||||||
|
onClick = { showDropdownMenu = true }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.MoreVert,
|
||||||
|
stringResource(R.string.string_option_menu_description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showDropdownMenu,
|
||||||
|
onDismissRequest = { showDropdownMenu = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Outlined.Folder, null)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.path_selector))
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showDropdownMenu = false
|
||||||
|
if (fs.hasStoragePermission()) {
|
||||||
|
showFileDialog = true
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(permissionName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !validatorFailed,
|
||||||
|
onClick = { scope.submitDialog(fieldValue) }) {
|
||||||
|
Text(stringResource(R.string.save))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = scope.dismissDialog) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
|
||||||
|
@Composable
|
||||||
|
protected abstract fun NumberDialog(
|
||||||
|
title: String,
|
||||||
|
current: T?,
|
||||||
|
validator: (T?) -> Boolean,
|
||||||
|
onSubmit: (T?) -> Unit
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Dialog(scope: OptionEditorScope<T>) {
|
||||||
|
NumberDialog(scope.option.title, scope.value, scope.option.validator) {
|
||||||
|
if (it == null) return@NumberDialog scope.dismissDialog()
|
||||||
|
|
||||||
|
scope.submitDialog(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object IntOptionEditor : NumberOptionEditor<Int>() {
|
||||||
|
@Composable
|
||||||
|
override fun NumberDialog(
|
||||||
|
title: String,
|
||||||
|
current: Int?,
|
||||||
|
validator: (Int?) -> Boolean,
|
||||||
|
onSubmit: (Int?) -> Unit
|
||||||
|
) = IntInputDialog(current, title, validator, onSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object LongOptionEditor : NumberOptionEditor<Long>() {
|
||||||
|
@Composable
|
||||||
|
override fun NumberDialog(
|
||||||
|
title: String,
|
||||||
|
current: Long?,
|
||||||
|
validator: (Long?) -> Boolean,
|
||||||
|
onSubmit: (Long?) -> Unit
|
||||||
|
) = LongInputDialog(current, title, validator, onSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object FloatOptionEditor : NumberOptionEditor<Float>() {
|
||||||
|
@Composable
|
||||||
|
override fun NumberDialog(
|
||||||
|
title: String,
|
||||||
|
current: Float?,
|
||||||
|
validator: (Float?) -> Boolean,
|
||||||
|
onSubmit: (Float?) -> Unit
|
||||||
|
) = FloatInputDialog(current, title, validator, onSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object BooleanOptionEditor : OptionEditor<Boolean> {
|
||||||
|
override fun clickAction(scope: OptionEditorScope<Boolean>) {
|
||||||
|
scope.setValue(!scope.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) {
|
||||||
|
Switch(checked = scope.current, onCheckedChange = scope.setValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Dialog(scope: OptionEditorScope<Boolean>) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private val OptionEditorScope<Boolean>.current get() = value ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
private object UnknownTypeEditor : OptionEditor<Any>, KoinComponent {
|
||||||
|
override fun clickAction(scope: OptionEditorScope<Any>) =
|
||||||
|
get<Application>().toast("Unknown type: ${scope.option.type}")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Dialog(scope: OptionEditorScope<Any>) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper for [OptionEditor]s that shows selectable presets.
|
||||||
|
*
|
||||||
|
* @param innerEditor The [OptionEditor] for [T].
|
||||||
|
*/
|
||||||
|
private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<T>) :
|
||||||
|
OptionEditor<T> {
|
||||||
|
@Composable
|
||||||
|
override fun Dialog(scope: OptionEditorScope<T>) {
|
||||||
|
var selectedPreset by rememberSaveable(scope.value, scope.option.presets) {
|
||||||
|
val presets = scope.option.presets!!
|
||||||
|
|
||||||
|
mutableStateOf(presets.entries.find { it.value == scope.value }?.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
WithOptionEditor(
|
||||||
|
innerEditor,
|
||||||
|
scope.option,
|
||||||
|
scope.value,
|
||||||
|
scope.setValue,
|
||||||
|
onDismissDialog = scope.dismissDialog
|
||||||
|
) inner@{
|
||||||
|
var hidePresetsDialog by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
if (hidePresetsDialog) return@inner
|
||||||
|
|
||||||
|
// TODO: add a divider for scrollable content
|
||||||
|
AlertDialogExtended(
|
||||||
|
onDismissRequest = scope.dismissDialog,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
if (selectedPreset != null) scope.submitDialog(
|
||||||
|
scope.option.presets?.get(
|
||||||
|
selectedPreset
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
this@inner.openDialog()
|
||||||
|
// Hide the presets dialog so it doesn't show up in the background.
|
||||||
|
hidePresetsDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = scope.dismissDialog) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text(scope.option.title) },
|
||||||
|
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
|
||||||
|
text = {
|
||||||
|
val presets = remember(scope.option.presets) {
|
||||||
|
scope.option.presets?.entries?.toList().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn {
|
||||||
|
@Composable
|
||||||
|
fun Item(title: String, value: Any?, presetKey: String?) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { selectedPreset = presetKey },
|
||||||
|
headlineContent = { Text(title) },
|
||||||
|
supportingContent = value?.toString()?.let { { Text(it) } },
|
||||||
|
leadingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedPreset == presetKey,
|
||||||
|
onClick = { selectedPreset = presetKey }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(presets, key = { it.key }) {
|
||||||
|
Item(it.key, it.value, it.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = null) {
|
||||||
|
Item(stringResource(R.string.option_preset_custom_value), null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ListOptionEditor<T : Serializable>(private val elementEditor: OptionEditor<T>) :
|
||||||
|
OptionEditor<List<T>> {
|
||||||
|
private fun createElementOption(option: Option<List<T>>) = Option<T>(
|
||||||
|
option.title,
|
||||||
|
option.key,
|
||||||
|
option.description,
|
||||||
|
option.required,
|
||||||
|
option.type.removeSuffix("Array"),
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
) { true }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
override fun Dialog(scope: OptionEditorScope<List<T>>) {
|
||||||
|
val items =
|
||||||
|
rememberSaveable(scope.value, saver = snapshotStateListSaver()) {
|
||||||
|
// We need a key for each element in order to support dragging.
|
||||||
|
scope.value?.map(::Item)?.toMutableStateList() ?: mutableStateListOf()
|
||||||
|
}
|
||||||
|
val listIsDirty by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val current = scope.value.orEmpty()
|
||||||
|
if (current.size != items.size) return@derivedStateOf true
|
||||||
|
|
||||||
|
current.forEachIndexed { index, value ->
|
||||||
|
if (value != items[index].value) return@derivedStateOf true
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val lazyListState = rememberLazyListState()
|
||||||
|
val reorderableLazyColumnState =
|
||||||
|
rememberReorderableLazyColumnState(lazyListState) { from, to ->
|
||||||
|
// Update the list
|
||||||
|
items.add(to.index, items.removeAt(from.index))
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleteMode by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
val deletionTargets = rememberSaveable(saver = snapshotStateSetSaver()) {
|
||||||
|
mutableStateSetOf<Int>()
|
||||||
|
}
|
||||||
|
|
||||||
|
val back = back@{
|
||||||
|
if (deleteMode) {
|
||||||
|
deletionTargets.clear()
|
||||||
|
deleteMode = false
|
||||||
|
return@back
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!listIsDirty) {
|
||||||
|
scope.dismissDialog()
|
||||||
|
return@back
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.submitDialog(items.mapNotNull { it.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
ComposeDialog(
|
||||||
|
onDismissRequest = back,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnBackPress = true
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
AppTopBar(
|
||||||
|
title = if (deleteMode) pluralStringResource(
|
||||||
|
R.plurals.selected_count,
|
||||||
|
deletionTargets.size,
|
||||||
|
deletionTargets.size
|
||||||
|
) else scope.option.title,
|
||||||
|
onBackClick = back,
|
||||||
|
backIcon = {
|
||||||
|
if (deleteMode) {
|
||||||
|
return@AppTopBar Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
stringResource(R.string.cancel)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (deleteMode) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (items.size == deletionTargets.size) deletionTargets.clear()
|
||||||
|
else deletionTargets.addAll(items.map { it.key })
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.SelectAll,
|
||||||
|
stringResource(R.string.select_deselect_all)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
items.removeIf { it.key in deletionTargets }
|
||||||
|
deletionTargets.clear()
|
||||||
|
deleteMode = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Delete,
|
||||||
|
stringResource(R.string.delete)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = items::clear) {
|
||||||
|
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (deleteMode) return@Scaffold
|
||||||
|
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
text = { Text(stringResource(R.string.add)) },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Add,
|
||||||
|
stringResource(R.string.add)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
expanded = lazyListState.isScrollingUp,
|
||||||
|
onClick = { items.add(Item(null)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
val elementOption = remember(scope.option) { createElementOption(scope.option) }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = lazyListState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(paddingValues),
|
||||||
|
) {
|
||||||
|
itemsIndexed(items, key = { _, item -> item.key }) { index, item ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
ReorderableItem(reorderableLazyColumnState, key = item.key) {
|
||||||
|
WithOptionEditor(
|
||||||
|
elementEditor,
|
||||||
|
elementOption,
|
||||||
|
value = item.value,
|
||||||
|
setValue = { items[index] = item.copy(value = it) }
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.combinedClickable(
|
||||||
|
indication = LocalIndication.current,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
onLongClickLabel = stringResource(R.string.select),
|
||||||
|
onLongClick = {
|
||||||
|
deletionTargets.add(item.key)
|
||||||
|
deleteMode = true
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (!deleteMode) {
|
||||||
|
clickAction()
|
||||||
|
return@combinedClickable
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.key in deletionTargets) {
|
||||||
|
deletionTargets.remove(
|
||||||
|
item.key
|
||||||
|
)
|
||||||
|
deleteMode = deletionTargets.isNotEmpty()
|
||||||
|
} else deletionTargets.add(item.key)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
tonalElevation = if (deleteMode && item.key in deletionTargets) 8.dp else 0.dp,
|
||||||
|
leadingContent = {
|
||||||
|
IconButton(
|
||||||
|
modifier = Modifier.draggableHandle(interactionSource = interactionSource),
|
||||||
|
onClick = {},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.DragHandle,
|
||||||
|
stringResource(R.string.drag_handle)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
if (item.value == null) return@ListItem Text(
|
||||||
|
stringResource(R.string.empty),
|
||||||
|
fontStyle = FontStyle.Italic
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(item.value.toString())
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
ListItemTrailingContent()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
private data class Item<T : Serializable>(val value: T?, val key: Int = Random.nextInt()) :
|
||||||
|
Parcelable
|
||||||
}
|
}
|
@ -3,19 +3,17 @@ package app.revanced.manager.ui.component.patches
|
|||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.outlined.ArrowBack
|
|
||||||
import androidx.compose.material.icons.outlined.DocumentScanner
|
|
||||||
import androidx.compose.material.icons.outlined.Folder
|
import androidx.compose.material.icons.outlined.Folder
|
||||||
import androidx.compose.material.icons.outlined.InsertDriveFile
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -30,6 +28,7 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
import app.revanced.manager.ui.component.AppTopBar
|
||||||
import app.revanced.manager.ui.component.GroupHeader
|
import app.revanced.manager.ui.component.GroupHeader
|
||||||
|
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||||
import app.revanced.manager.util.saver.PathSaver
|
import app.revanced.manager.util.saver.PathSaver
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.io.path.absolutePathString
|
import kotlin.io.path.absolutePathString
|
||||||
@ -71,7 +70,7 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
|
|||||||
currentDirectory = currentDirectory.parent
|
currentDirectory = currentDirectory.parent
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumnWithScrollbar(
|
||||||
modifier = Modifier.padding(paddingValues)
|
modifier = Modifier.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
item(key = "current") {
|
item(key = "current") {
|
||||||
@ -86,7 +85,7 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
|
|||||||
item(key = "parent") {
|
item(key = "parent") {
|
||||||
PathItem(
|
PathItem(
|
||||||
onClick = { currentDirectory = currentDirectory.parent },
|
onClick = { currentDirectory = currentDirectory.parent },
|
||||||
icon = Icons.Outlined.ArrowBack,
|
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||||
name = stringResource(R.string.path_selector_parent_dir)
|
name = stringResource(R.string.path_selector_parent_dir)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -113,7 +112,7 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
|
|||||||
items(files, key = { it.absolutePathString() }) {
|
items(files, key = { it.absolutePathString() }) {
|
||||||
PathItem(
|
PathItem(
|
||||||
onClick = { onSelect(it) },
|
onClick = { onSelect(it) },
|
||||||
icon = Icons.Outlined.InsertDriveFile,
|
icon = Icons.AutoMirrored.Outlined.InsertDriveFile,
|
||||||
name = it.name
|
name = it.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BooleanItem(
|
fun BooleanItem(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
preference: Preference<Boolean>,
|
preference: Preference<Boolean>,
|
||||||
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||||
@StringRes headline: Int,
|
@StringRes headline: Int,
|
||||||
@ -22,6 +23,7 @@ fun BooleanItem(
|
|||||||
val value by preference.getAsState()
|
val value by preference.getAsState()
|
||||||
|
|
||||||
BooleanItem(
|
BooleanItem(
|
||||||
|
modifier = modifier,
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = { coroutineScope.launch { preference.update(it) } },
|
onValueChange = { coroutineScope.launch { preference.update(it) } },
|
||||||
headline = headline,
|
headline = headline,
|
||||||
@ -31,12 +33,15 @@ fun BooleanItem(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BooleanItem(
|
fun BooleanItem(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
onValueChange: (Boolean) -> Unit,
|
onValueChange: (Boolean) -> Unit,
|
||||||
@StringRes headline: Int,
|
@StringRes headline: Int,
|
||||||
@StringRes description: Int
|
@StringRes description: Int
|
||||||
) = SettingsListItem(
|
) = SettingsListItem(
|
||||||
modifier = Modifier.clickable { onValueChange(!value) },
|
modifier = Modifier
|
||||||
|
.clickable { onValueChange(!value) }
|
||||||
|
.then(modifier),
|
||||||
headlineContent = stringResource(headline),
|
headlineContent = stringResource(headline),
|
||||||
supportingContent = stringResource(description),
|
supportingContent = stringResource(description),
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
|
@ -55,10 +55,6 @@ fun Changelog(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Tag(
|
|
||||||
Icons.Outlined.Sell,
|
|
||||||
version
|
|
||||||
)
|
|
||||||
Tag(
|
Tag(
|
||||||
Icons.Outlined.FileDownload,
|
Icons.Outlined.FileDownload,
|
||||||
downloadCount
|
downloadCount
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
package app.revanced.manager.ui.component.settings
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Edit
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.revanced.manager.R
|
||||||
|
import app.revanced.manager.domain.manager.base.Preference
|
||||||
|
import app.revanced.manager.ui.component.IntInputDialog
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IntegerItem(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
preference: Preference<Int>,
|
||||||
|
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||||
|
@StringRes headline: Int,
|
||||||
|
@StringRes description: Int
|
||||||
|
) {
|
||||||
|
val value by preference.getAsState()
|
||||||
|
|
||||||
|
IntegerItem(
|
||||||
|
modifier = modifier,
|
||||||
|
value = value,
|
||||||
|
onValueChange = { coroutineScope.launch { preference.update(it) } },
|
||||||
|
headline = headline,
|
||||||
|
description = description
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun IntegerItem(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
value: Int,
|
||||||
|
onValueChange: (Int) -> Unit,
|
||||||
|
@StringRes headline: Int,
|
||||||
|
@StringRes description: Int
|
||||||
|
) {
|
||||||
|
var dialogOpen by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogOpen) {
|
||||||
|
IntInputDialog(current = value, name = stringResource(headline)) { new ->
|
||||||
|
dialogOpen = false
|
||||||
|
new?.let(onValueChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsListItem(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { dialogOpen = true }
|
||||||
|
.then(modifier),
|
||||||
|
headlineContent = stringResource(headline),
|
||||||
|
supportingContent = stringResource(description),
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(onClick = { dialogOpen = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Edit,
|
||||||
|
contentDescription = stringResource(R.string.edit)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@ -4,7 +4,7 @@ import android.os.Parcelable
|
|||||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||||
import app.revanced.manager.ui.model.SelectedApp
|
import app.revanced.manager.ui.model.SelectedApp
|
||||||
import app.revanced.manager.util.Options
|
import app.revanced.manager.util.Options
|
||||||
import app.revanced.manager.util.PatchesSelection
|
import app.revanced.manager.util.PatchSelection
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.parcelize.RawValue
|
import kotlinx.parcelize.RawValue
|
||||||
|
|
||||||
@ -23,12 +23,12 @@ sealed interface Destination : Parcelable {
|
|||||||
data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination
|
data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class VersionSelector(val packageName: String, val patchesSelection: PatchesSelection? = null) : Destination
|
data class VersionSelector(val packageName: String, val patchSelection: PatchSelection? = null) : Destination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
|
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Installer(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination
|
data class Patcher(val selectedApp: SelectedApp, val selectedPatches: PatchSelection, val options: @RawValue Options) : Destination
|
||||||
|
|
||||||
}
|
}
|
@ -3,7 +3,7 @@ package app.revanced.manager.ui.destination
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import app.revanced.manager.ui.model.SelectedApp
|
import app.revanced.manager.ui.model.SelectedApp
|
||||||
import app.revanced.manager.util.Options
|
import app.revanced.manager.util.Options
|
||||||
import app.revanced.manager.util.PatchesSelection
|
import app.revanced.manager.util.PatchSelection
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import kotlinx.parcelize.RawValue
|
import kotlinx.parcelize.RawValue
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ sealed interface SelectedAppInfoDestination : Parcelable {
|
|||||||
data object Main : SelectedAppInfoDestination
|
data object Main : SelectedAppInfoDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchesSelection?, val options: @RawValue Options) : SelectedAppInfoDestination
|
data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchSelection?, val options: @RawValue Options) : SelectedAppInfoDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object VersionSelector: SelectedAppInfoDestination
|
data object VersionSelector: SelectedAppInfoDestination
|
||||||
|
@ -6,35 +6,38 @@ import kotlinx.parcelize.Parcelize
|
|||||||
sealed interface SettingsDestination : Parcelable {
|
sealed interface SettingsDestination : Parcelable {
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Settings : SettingsDestination
|
data object Settings : SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object General : SettingsDestination
|
data object General : SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Advanced : SettingsDestination
|
data object Advanced : SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Updates : SettingsDestination
|
data object Updates : SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Downloads : SettingsDestination
|
data object Downloads : SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object ImportExport : SettingsDestination
|
data object ImportExport : SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object About : SettingsDestination
|
data object About : SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Update(val downloadOnScreenEntry: Boolean) : SettingsDestination
|
data class Update(val downloadOnScreenEntry: Boolean = false) : SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Changelogs : SettingsDestination
|
data object Changelogs : SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Contributors: SettingsDestination
|
data object Contributors: SettingsDestination
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object Licenses: SettingsDestination
|
data object Licenses: SettingsDestination
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data object DeveloperOptions: SettingsDestination
|
||||||
}
|
}
|
@ -2,7 +2,7 @@ package app.revanced.manager.ui.model
|
|||||||
|
|
||||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||||
import app.revanced.manager.patcher.patch.PatchInfo
|
import app.revanced.manager.patcher.patch.PatchInfo
|
||||||
import app.revanced.manager.util.PatchesSelection
|
import app.revanced.manager.util.PatchSelection
|
||||||
import app.revanced.manager.util.flatMapLatestAndCombine
|
import app.revanced.manager.util.flatMapLatestAndCombine
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ data class BundleInfo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object Extensions {
|
companion object Extensions {
|
||||||
inline fun Iterable<BundleInfo>.toPatchSelection(allowUnsupported: Boolean, condition: (Int, PatchInfo) -> Boolean): PatchesSelection = this.associate { bundle ->
|
inline fun Iterable<BundleInfo>.toPatchSelection(allowUnsupported: Boolean, condition: (Int, PatchInfo) -> Boolean): PatchSelection = this.associate { bundle ->
|
||||||
val patches =
|
val patches =
|
||||||
bundle.patchSequence(allowUnsupported)
|
bundle.patchSequence(allowUnsupported)
|
||||||
.mapNotNullTo(mutableSetOf()) { patch ->
|
.mapNotNullTo(mutableSetOf()) { patch ->
|
||||||
@ -75,8 +75,13 @@ data class BundleInfo(
|
|||||||
targetList.add(it)
|
targetList.add(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
BundleInfo(source.name, source.uid, supported, unsupported, universal)
|
BundleInfo(source.getName(), source.uid, supported, unsupported, universal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BundleType {
|
||||||
|
Local,
|
||||||
|
Remote
|
||||||
}
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package app.revanced.manager.ui.model
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import app.revanced.manager.R
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
enum class StepCategory(@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)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class State {
|
||||||
|
WAITING, RUNNING, FAILED, COMPLETED
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Step(
|
||||||
|
val name: String,
|
||||||
|
val category: StepCategory,
|
||||||
|
val state: State = State.WAITING,
|
||||||
|
val message: String? = null,
|
||||||
|
val downloadProgress: StateFlow<Pair<Float, Float>?>? = null
|
||||||
|
)
|
@ -13,7 +13,7 @@ sealed class SelectedApp : Parcelable {
|
|||||||
data class Download(override val packageName: String, override val version: String, val app: AppDownloader.App) : SelectedApp()
|
data class Download(override val packageName: String, override val version: String, val app: AppDownloader.App) : SelectedApp()
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Local(override val packageName: String, override val version: String, val file: File, val shouldDelete: Boolean) : SelectedApp()
|
data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp()
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Installed(override val packageName: String, override val version: String) : SelectedApp()
|
data class Installed(override val packageName: String, override val version: String) : SelectedApp()
|
||||||
|
@ -4,14 +4,13 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.Storage
|
import androidx.compose.material.icons.filled.Storage
|
||||||
import androidx.compose.material.icons.outlined.Search
|
import androidx.compose.material.icons.outlined.Search
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -19,7 +18,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -28,12 +26,14 @@ import app.revanced.manager.R
|
|||||||
import app.revanced.manager.ui.component.AppIcon
|
import app.revanced.manager.ui.component.AppIcon
|
||||||
import app.revanced.manager.ui.component.AppLabel
|
import app.revanced.manager.ui.component.AppLabel
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
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.component.LoadingIndicator
|
||||||
|
import app.revanced.manager.ui.component.NonSuggestedVersionDialog
|
||||||
|
import app.revanced.manager.ui.component.SearchView
|
||||||
import app.revanced.manager.ui.model.SelectedApp
|
import app.revanced.manager.ui.model.SelectedApp
|
||||||
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
|
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
|
||||||
import app.revanced.manager.util.APK_MIMETYPE
|
import app.revanced.manager.util.APK_MIMETYPE
|
||||||
import app.revanced.manager.util.toast
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import org.koin.androidx.compose.getViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -41,21 +41,19 @@ fun AppSelectorScreen(
|
|||||||
onAppClick: (packageName: String) -> Unit,
|
onAppClick: (packageName: String) -> Unit,
|
||||||
onStorageClick: (SelectedApp.Local) -> Unit,
|
onStorageClick: (SelectedApp.Local) -> Unit,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
vm: AppSelectorViewModel = getViewModel()
|
vm: AppSelectorViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
SideEffect {
|
||||||
|
vm.onStorageClick = onStorageClick
|
||||||
|
}
|
||||||
|
|
||||||
val pickApkLauncher =
|
val pickApkLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
uri?.let { apkUri ->
|
uri?.let(vm::handleStorageResult)
|
||||||
vm.loadSelectedFile(apkUri)?.let(onStorageClick) ?: context.toast(
|
|
||||||
context.getString(
|
|
||||||
R.string.failed_to_load_apk
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val suggestedVersions by vm.suggestedAppVersions.collectAsStateWithLifecycle(emptyMap())
|
||||||
|
|
||||||
var filterText by rememberSaveable { mutableStateOf("") }
|
var filterText by rememberSaveable { mutableStateOf("") }
|
||||||
var search by rememberSaveable { mutableStateOf(false) }
|
var search by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
@ -69,83 +67,73 @@ fun AppSelectorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: find something better for this
|
vm.nonSuggestedVersionDialogSubject?.let {
|
||||||
|
NonSuggestedVersionDialog(
|
||||||
|
suggestedVersion = suggestedVersions[it.packageName].orEmpty(),
|
||||||
|
onDismiss = vm::dismissNonSuggestedVersionDialog
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
SearchBar(
|
SearchView(
|
||||||
query = filterText,
|
query = filterText,
|
||||||
onQueryChange = { filterText = it },
|
onQueryChange = { filterText = it },
|
||||||
onSearch = { },
|
|
||||||
active = true,
|
|
||||||
onActiveChange = { search = it },
|
onActiveChange = { search = it },
|
||||||
modifier = Modifier.fillMaxSize(),
|
placeholder = { Text(stringResource(R.string.search_apps)) }
|
||||||
placeholder = { Text(stringResource(R.string.search_apps)) },
|
) {
|
||||||
leadingIcon = {
|
if (appList.isNotEmpty() && filterText.isNotEmpty()) {
|
||||||
IconButton({ search = false }) {
|
LazyColumnWithScrollbar(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = filteredAppList,
|
||||||
|
key = { it.packageName }
|
||||||
|
) { app ->
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { onAppClick(app.packageName) },
|
||||||
|
leadingContent = {
|
||||||
|
AppIcon(
|
||||||
|
app.packageInfo,
|
||||||
|
null,
|
||||||
|
Modifier.size(36.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = { AppLabel(app.packageInfo) },
|
||||||
|
supportingContent = { Text(app.packageName) },
|
||||||
|
trailingContent = app.patches?.let {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
pluralStringResource(
|
||||||
|
R.plurals.patch_count,
|
||||||
|
it,
|
||||||
|
it
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ArrowBack,
|
imageVector = Icons.Outlined.Search,
|
||||||
stringResource(R.string.back)
|
contentDescription = stringResource(R.string.search),
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.type_anything),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
content = {
|
|
||||||
|
|
||||||
if (appList.isNotEmpty() && filterText.isNotEmpty()) {
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
|
|
||||||
items(
|
|
||||||
items = filteredAppList,
|
|
||||||
key = { it.packageName }
|
|
||||||
) { app ->
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { onAppClick(app.packageName) },
|
|
||||||
leadingContent = {
|
|
||||||
AppIcon(
|
|
||||||
app.packageInfo,
|
|
||||||
null,
|
|
||||||
Modifier.size(36.dp)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
headlineContent = { AppLabel(app.packageInfo) },
|
|
||||||
supportingContent = { Text(app.packageName) },
|
|
||||||
trailingContent = app.patches?.let {
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
pluralStringResource(
|
|
||||||
R.plurals.patches_count,
|
|
||||||
it,
|
|
||||||
it
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Search,
|
|
||||||
contentDescription = stringResource(R.string.search),
|
|
||||||
modifier = Modifier.size(64.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.type_anything),
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@ -161,10 +149,11 @@ fun AppSelectorScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
LazyColumnWithScrollbar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@ -185,7 +174,7 @@ fun AppSelectorScreen(
|
|||||||
Text(stringResource(R.string.select_from_storage_description))
|
Text(stringResource(R.string.select_from_storage_description))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appList.isNotEmpty()) {
|
if (appList.isNotEmpty()) {
|
||||||
@ -193,17 +182,25 @@ fun AppSelectorScreen(
|
|||||||
items = appList,
|
items = appList,
|
||||||
key = { it.packageName }
|
key = { it.packageName }
|
||||||
) { app ->
|
) { app ->
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable { onAppClick(app.packageName) },
|
modifier = Modifier.clickable { onAppClick(app.packageName) },
|
||||||
leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
|
leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
|
||||||
headlineContent = { AppLabel(app.packageInfo) },
|
headlineContent = {
|
||||||
supportingContent = { Text(app.packageName) },
|
AppLabel(
|
||||||
|
app.packageInfo,
|
||||||
|
defaultText = app.packageName
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
suggestedVersions[app.packageName]?.let {
|
||||||
|
Text(stringResource(R.string.suggested_version_info, it))
|
||||||
|
}
|
||||||
|
},
|
||||||
trailingContent = app.patches?.let {
|
trailingContent = app.patches?.let {
|
||||||
{
|
{
|
||||||
Text(
|
Text(
|
||||||
pluralStringResource(
|
pluralStringResource(
|
||||||
R.plurals.patches_count,
|
R.plurals.patch_count,
|
||||||
it,
|
it,
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
|
@ -1,30 +1,22 @@
|
|||||||
package app.revanced.manager.ui.screen
|
package app.revanced.manager.ui.screen
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.Close
|
||||||
import androidx.compose.material.icons.outlined.Apps
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
|
||||||
import androidx.compose.material.icons.outlined.Refresh
|
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
|
||||||
import androidx.compose.material.icons.outlined.Source
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@ -32,16 +24,20 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.isDefault
|
|
||||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||||
|
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
|
||||||
|
import app.revanced.manager.patcher.aapt.Aapt
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
import app.revanced.manager.ui.component.AppTopBar
|
||||||
|
import app.revanced.manager.ui.component.AutoUpdatesDialog
|
||||||
|
import app.revanced.manager.ui.component.AvailableUpdateDialog
|
||||||
|
import app.revanced.manager.ui.component.NotificationCard
|
||||||
import app.revanced.manager.ui.component.bundle.BundleItem
|
import app.revanced.manager.ui.component.bundle.BundleItem
|
||||||
import app.revanced.manager.ui.component.bundle.BundleTopBar
|
import app.revanced.manager.ui.component.bundle.BundleTopBar
|
||||||
import app.revanced.manager.ui.component.bundle.ImportBundleDialog
|
import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog
|
||||||
import app.revanced.manager.ui.viewmodel.DashboardViewModel
|
import app.revanced.manager.ui.viewmodel.DashboardViewModel
|
||||||
import app.revanced.manager.util.toast
|
import app.revanced.manager.util.toast
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.androidx.compose.getViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
enum class DashboardPage(
|
enum class DashboardPage(
|
||||||
val titleResId: Int,
|
val titleResId: Int,
|
||||||
@ -51,48 +47,58 @@ enum class DashboardPage(
|
|||||||
BUNDLES(R.string.tab_bundles, Icons.Outlined.Source),
|
BUNDLES(R.string.tab_bundles, Icons.Outlined.Source),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardScreen(
|
fun DashboardScreen(
|
||||||
vm: DashboardViewModel = getViewModel(),
|
vm: DashboardViewModel = koinViewModel(),
|
||||||
onAppSelectorClick: () -> Unit,
|
onAppSelectorClick: () -> Unit,
|
||||||
onSettingsClick: () -> Unit,
|
onSettingsClick: () -> Unit,
|
||||||
|
onUpdateClick: () -> Unit,
|
||||||
onAppClick: (InstalledApp) -> Unit
|
onAppClick: (InstalledApp) -> Unit
|
||||||
) {
|
) {
|
||||||
var showImportBundleDialog by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } }
|
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } }
|
||||||
val pages: Array<DashboardPage> = DashboardPage.values()
|
|
||||||
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
|
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
|
||||||
val androidContext = LocalContext.current
|
val androidContext = LocalContext.current
|
||||||
|
val composableScope = rememberCoroutineScope()
|
||||||
val pagerState = rememberPagerState(
|
val pagerState = rememberPagerState(
|
||||||
initialPage = DashboardPage.DASHBOARD.ordinal,
|
initialPage = DashboardPage.DASHBOARD.ordinal,
|
||||||
initialPageOffsetFraction = 0f
|
initialPageOffsetFraction = 0f
|
||||||
) {
|
) { DashboardPage.entries.size }
|
||||||
DashboardPage.values().size
|
|
||||||
}
|
|
||||||
val composableScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
LaunchedEffect(pagerState.currentPage) {
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
if (pagerState.currentPage != DashboardPage.BUNDLES.ordinal) vm.cancelSourceSelection()
|
if (pagerState.currentPage != DashboardPage.BUNDLES.ordinal) vm.cancelSourceSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showImportBundleDialog) {
|
val firstLaunch by vm.prefs.firstLaunch.getAsState()
|
||||||
fun dismiss() {
|
if (firstLaunch) AutoUpdatesDialog(vm::applyAutoUpdatePrefs)
|
||||||
showImportBundleDialog = false
|
|
||||||
}
|
|
||||||
|
|
||||||
ImportBundleDialog(
|
var showAddBundleDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
onDismissRequest = ::dismiss,
|
if (showAddBundleDialog) {
|
||||||
onLocalSubmit = { name, patches, integrations ->
|
ImportPatchBundleDialog(
|
||||||
dismiss()
|
onDismiss = { showAddBundleDialog = false },
|
||||||
vm.createLocalSource(name, patches, integrations)
|
onLocalSubmit = { patches, integrations ->
|
||||||
},
|
showAddBundleDialog = false
|
||||||
onRemoteSubmit = { name, url, autoUpdate ->
|
vm.createLocalSource(patches, integrations)
|
||||||
dismiss()
|
|
||||||
vm.createRemoteSource(name, url, autoUpdate)
|
|
||||||
},
|
},
|
||||||
|
onRemoteSubmit = { url, autoUpdate ->
|
||||||
|
showAddBundleDialog = false
|
||||||
|
vm.createRemoteSource(url, autoUpdate)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var showDialog by rememberSaveable { mutableStateOf(vm.prefs.showManagerUpdateDialogOnLaunch.getBlocking()) }
|
||||||
|
val availableUpdate by remember {
|
||||||
|
derivedStateOf { vm.updatedManagerVersion.takeIf { showDialog } }
|
||||||
|
}
|
||||||
|
|
||||||
|
availableUpdate?.let { version ->
|
||||||
|
AvailableUpdateDialog(
|
||||||
|
onDismiss = { showDialog = false },
|
||||||
|
setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch,
|
||||||
|
onConfirm = onUpdateClick,
|
||||||
|
newVersion = version
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +108,7 @@ fun DashboardScreen(
|
|||||||
BundleTopBar(
|
BundleTopBar(
|
||||||
title = stringResource(R.string.bundles_selected, vm.selectedSources.size),
|
title = stringResource(R.string.bundles_selected, vm.selectedSources.size),
|
||||||
onBackClick = vm::cancelSourceSelection,
|
onBackClick = vm::cancelSourceSelection,
|
||||||
onBackIcon = {
|
backIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Close,
|
imageVector = Icons.Default.Close,
|
||||||
contentDescription = stringResource(R.string.back)
|
contentDescription = stringResource(R.string.back)
|
||||||
@ -137,6 +143,23 @@ fun DashboardScreen(
|
|||||||
AppTopBar(
|
AppTopBar(
|
||||||
title = stringResource(R.string.app_name),
|
title = stringResource(R.string.app_name),
|
||||||
actions = {
|
actions = {
|
||||||
|
if (!vm.updatedManagerVersion.isNullOrEmpty()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onUpdateClick,
|
||||||
|
) {
|
||||||
|
BadgedBox(
|
||||||
|
badge = {
|
||||||
|
Badge(
|
||||||
|
// A size value above 6.dp forces the Badge icon to be closer to the center, fixing a clipping issue
|
||||||
|
modifier = Modifier.size(7.dp),
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Outlined.Update, stringResource(R.string.update))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
IconButton(onClick = onSettingsClick) {
|
IconButton(onClick = onSettingsClick) {
|
||||||
Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
|
Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
|
||||||
}
|
}
|
||||||
@ -165,13 +188,11 @@ fun DashboardScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
DashboardPage.BUNDLES.ordinal -> {
|
DashboardPage.BUNDLES.ordinal -> {
|
||||||
showImportBundleDialog = true
|
showAddBundleDialog = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) { Icon(Icons.Default.Add, stringResource(R.string.add)) }
|
||||||
Icon(Icons.Default.Add, stringResource(R.string.add))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(Modifier.padding(paddingValues)) {
|
Column(Modifier.padding(paddingValues)) {
|
||||||
@ -179,7 +200,7 @@ fun DashboardScreen(
|
|||||||
selectedTabIndex = pagerState.currentPage,
|
selectedTabIndex = pagerState.currentPage,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||||
) {
|
) {
|
||||||
pages.forEachIndexed { index, page ->
|
DashboardPage.entries.forEachIndexed { index, page ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = pagerState.currentPage == index,
|
||||||
onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } },
|
onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
@ -191,12 +212,39 @@ fun DashboardScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Notifications(
|
||||||
|
if (!Aapt.supportsDevice()) {
|
||||||
|
{
|
||||||
|
NotificationCard(
|
||||||
|
isWarning = true,
|
||||||
|
icon = Icons.Outlined.WarningAmber,
|
||||||
|
text = stringResource(R.string.unsupported_architecture_warning),
|
||||||
|
onDismiss = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
if (vm.showBatteryOptimizationsWarning) {
|
||||||
|
{
|
||||||
|
NotificationCard(
|
||||||
|
isWarning = true,
|
||||||
|
icon = Icons.Default.BatteryAlert,
|
||||||
|
text = stringResource(R.string.battery_optimization_notification),
|
||||||
|
onClick = {
|
||||||
|
androidContext.startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:${androidContext.packageName}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
userScrollEnabled = true,
|
userScrollEnabled = true,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
pageContent = { index ->
|
pageContent = { index ->
|
||||||
when (pages[index]) {
|
when (DashboardPage.entries[index]) {
|
||||||
DashboardPage.DASHBOARD -> {
|
DashboardPage.DASHBOARD -> {
|
||||||
InstalledAppsScreen(
|
InstalledAppsScreen(
|
||||||
onAppClick = onAppClick
|
onAppClick = onAppClick
|
||||||
@ -215,11 +263,9 @@ fun DashboardScreen(
|
|||||||
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
|
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize(),
|
|
||||||
) {
|
) {
|
||||||
sources.forEach {
|
sources.forEach {
|
||||||
|
|
||||||
BundleItem(
|
BundleItem(
|
||||||
bundle = it,
|
bundle = it,
|
||||||
onDelete = {
|
onDelete = {
|
||||||
@ -249,4 +295,22 @@ fun DashboardScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Notifications(
|
||||||
|
vararg notifications: (@Composable () -> Unit)?,
|
||||||
|
) {
|
||||||
|
val activeNotifications = notifications.filterNotNull()
|
||||||
|
|
||||||
|
if (activeNotifications.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
activeNotifications.forEach { notification ->
|
||||||
|
notification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -6,14 +6,12 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowRight
|
import androidx.compose.material.icons.automirrored.filled.ArrowRight
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
|
||||||
import androidx.compose.material.icons.outlined.Circle
|
import androidx.compose.material.icons.outlined.Circle
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
import androidx.compose.material.icons.outlined.OpenInNew
|
|
||||||
import androidx.compose.material.icons.outlined.SettingsBackupRestore
|
import androidx.compose.material.icons.outlined.SettingsBackupRestore
|
||||||
import androidx.compose.material.icons.outlined.Update
|
import androidx.compose.material.icons.outlined.Update
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@ -31,6 +29,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -38,18 +37,22 @@ import app.revanced.manager.R
|
|||||||
import app.revanced.manager.data.room.apps.installed.InstallType
|
import app.revanced.manager.data.room.apps.installed.InstallType
|
||||||
import app.revanced.manager.ui.component.AppInfo
|
import app.revanced.manager.ui.component.AppInfo
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
import app.revanced.manager.ui.component.AppTopBar
|
||||||
import app.revanced.manager.ui.component.settings.SettingsListItem
|
import app.revanced.manager.ui.component.ColumnWithScrollbar
|
||||||
import app.revanced.manager.ui.component.SegmentedButton
|
import app.revanced.manager.ui.component.SegmentedButton
|
||||||
|
import app.revanced.manager.ui.component.settings.SettingsListItem
|
||||||
import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
|
import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
|
||||||
import app.revanced.manager.util.PatchesSelection
|
import app.revanced.manager.util.PatchSelection
|
||||||
|
import app.revanced.manager.util.toast
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun InstalledAppInfoScreen(
|
fun InstalledAppInfoScreen(
|
||||||
onPatchClick: (packageName: String, patchesSelection: PatchesSelection) -> Unit,
|
onPatchClick: (packageName: String, patchSelection: PatchSelection) -> Unit,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
viewModel: InstalledAppInfoViewModel
|
viewModel: InstalledAppInfoViewModel
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
SideEffect {
|
SideEffect {
|
||||||
viewModel.onBackClick = onBackClick
|
viewModel.onBackClick = onBackClick
|
||||||
}
|
}
|
||||||
@ -70,18 +73,17 @@ fun InstalledAppInfoScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
ColumnWithScrollbar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
) {
|
) {
|
||||||
AppInfo(viewModel.appInfo) {
|
AppInfo(viewModel.appInfo) {
|
||||||
Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
|
Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
|
||||||
if (viewModel.installedApp.installType == InstallType.ROOT) {
|
if (viewModel.installedApp.installType == InstallType.ROOT) {
|
||||||
Text(
|
Text(
|
||||||
text = if (viewModel.rootInstaller.isAppMounted(viewModel.installedApp.currentPackageName)) {
|
text = if (viewModel.isMounted) {
|
||||||
stringResource(R.string.mounted)
|
stringResource(R.string.mounted)
|
||||||
} else {
|
} else {
|
||||||
stringResource(R.string.not_mounted)
|
stringResource(R.string.not_mounted)
|
||||||
@ -98,7 +100,7 @@ fun InstalledAppInfoScreen(
|
|||||||
.clip(RoundedCornerShape(24.dp))
|
.clip(RoundedCornerShape(24.dp))
|
||||||
) {
|
) {
|
||||||
SegmentedButton(
|
SegmentedButton(
|
||||||
icon = Icons.Outlined.OpenInNew,
|
icon = Icons.AutoMirrored.Outlined.OpenInNew,
|
||||||
text = stringResource(R.string.open_app),
|
text = stringResource(R.string.open_app),
|
||||||
onClick = viewModel::launch
|
onClick = viewModel::launch
|
||||||
)
|
)
|
||||||
@ -144,17 +146,17 @@ fun InstalledAppInfoScreen(
|
|||||||
modifier = Modifier.padding(vertical = 16.dp)
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
) {
|
) {
|
||||||
SettingsListItem(
|
SettingsListItem(
|
||||||
modifier = Modifier.clickable { },
|
modifier = Modifier.clickable { context.toast("Not implemented yet!") },
|
||||||
headlineContent = stringResource(R.string.applied_patches),
|
headlineContent = stringResource(R.string.applied_patches),
|
||||||
supportingContent =
|
supportingContent =
|
||||||
(viewModel.appliedPatches?.values?.sumOf { it.size } ?: 0).let {
|
(viewModel.appliedPatches?.values?.sumOf { it.size } ?: 0).let {
|
||||||
pluralStringResource(
|
pluralStringResource(
|
||||||
id = R.plurals.applied_patches,
|
id = R.plurals.patch_count,
|
||||||
it,
|
it,
|
||||||
it
|
it
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = { Icon(Icons.Filled.ArrowRight, contentDescription = stringResource(R.string.view_applied_patches)) }
|
trailingContent = { Icon(Icons.AutoMirrored.Filled.ArrowRight, contentDescription = stringResource(R.string.view_applied_patches)) }
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingsListItem(
|
SettingsListItem(
|
||||||
@ -174,7 +176,6 @@ fun InstalledAppInfoScreen(
|
|||||||
supportingContent = stringResource(viewModel.installedApp.installType.stringResource)
|
supportingContent = stringResource(viewModel.installedApp.installType.stringResource)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,10 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.WarningAmber
|
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -21,39 +18,27 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||||
import app.revanced.manager.patcher.aapt.Aapt
|
|
||||||
import app.revanced.manager.ui.component.AppIcon
|
import app.revanced.manager.ui.component.AppIcon
|
||||||
import app.revanced.manager.ui.component.AppLabel
|
import app.revanced.manager.ui.component.AppLabel
|
||||||
|
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||||
import app.revanced.manager.ui.component.LoadingIndicator
|
import app.revanced.manager.ui.component.LoadingIndicator
|
||||||
import app.revanced.manager.ui.component.NotificationCard
|
|
||||||
import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel
|
import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel
|
||||||
import org.koin.androidx.compose.getViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InstalledAppsScreen(
|
fun InstalledAppsScreen(
|
||||||
onAppClick: (InstalledApp) -> Unit,
|
onAppClick: (InstalledApp) -> Unit,
|
||||||
viewModel: InstalledAppsViewModel = getViewModel()
|
viewModel: InstalledAppsViewModel = koinViewModel()
|
||||||
) {
|
) {
|
||||||
val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null)
|
val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null)
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
if (!Aapt.supportsDevice()) {
|
LazyColumnWithScrollbar(
|
||||||
NotificationCard(
|
|
||||||
isWarning = true,
|
|
||||||
icon = Icons.Outlined.WarningAmber,
|
|
||||||
text = stringResource(
|
|
||||||
R.string.unsupported_architecture_warning
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top
|
verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top,
|
||||||
) {
|
) {
|
||||||
installedApps?.let { installedApps ->
|
installedApps?.let { installedApps ->
|
||||||
|
|
||||||
if (installedApps.isNotEmpty()) {
|
if (installedApps.isNotEmpty()) {
|
||||||
items(
|
items(
|
||||||
installedApps,
|
installedApps,
|
||||||
|
@ -1,305 +0,0 @@
|
|||||||
package app.revanced.manager.ui.screen
|
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Cancel
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.outlined.FileDownload
|
|
||||||
import androidx.compose.material.icons.outlined.PostAdd
|
|
||||||
import androidx.compose.material.icons.outlined.Save
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import app.revanced.manager.R
|
|
||||||
import app.revanced.manager.data.room.apps.installed.InstallType
|
|
||||||
import app.revanced.manager.patcher.worker.State
|
|
||||||
import app.revanced.manager.patcher.worker.Step
|
|
||||||
import app.revanced.manager.ui.component.AppScaffold
|
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
|
||||||
import app.revanced.manager.ui.component.ArrowButton
|
|
||||||
import app.revanced.manager.ui.viewmodel.InstallerViewModel
|
|
||||||
import app.revanced.manager.util.APK_MIMETYPE
|
|
||||||
import kotlin.math.floor
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun InstallerScreen(
|
|
||||||
onBackClick: () -> Unit,
|
|
||||||
vm: InstallerViewModel
|
|
||||||
) {
|
|
||||||
BackHandler(onBack = onBackClick)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val exportApkLauncher =
|
|
||||||
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export)
|
|
||||||
val patcherState by vm.patcherState.observeAsState(null)
|
|
||||||
val steps by vm.progress.collectAsStateWithLifecycle()
|
|
||||||
val canInstall by remember { derivedStateOf { patcherState == true && (vm.installedPackageName != null || !vm.isInstalling) } }
|
|
||||||
var showInstallPicker by rememberSaveable { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showInstallPicker)
|
|
||||||
InstallPicker(
|
|
||||||
onDismiss = { showInstallPicker = false },
|
|
||||||
onConfirm = { vm.install(it) }
|
|
||||||
)
|
|
||||||
|
|
||||||
AppScaffold(
|
|
||||||
topBar = {
|
|
||||||
AppTopBar(
|
|
||||||
title = stringResource(R.string.installer),
|
|
||||||
onBackClick = onBackClick
|
|
||||||
)
|
|
||||||
},
|
|
||||||
bottomBar = {
|
|
||||||
AnimatedVisibility(patcherState != null) {
|
|
||||||
BottomAppBar(
|
|
||||||
actions = {
|
|
||||||
if (canInstall) {
|
|
||||||
IconButton(onClick = { exportApkLauncher.launch("${vm.packageName}.apk") }) {
|
|
||||||
Icon(Icons.Outlined.Save, stringResource(id = R.string.save_apk))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IconButton(onClick = { vm.exportLogs(context) }) {
|
|
||||||
Icon(Icons.Outlined.PostAdd, stringResource(id = R.string.save_logs))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
floatingActionButton = {
|
|
||||||
if (canInstall) {
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
text = { Text(stringResource(vm.appButtonText)) },
|
|
||||||
icon = { Icon(Icons.Outlined.FileDownload, stringResource(id = R.string.install_app)) },
|
|
||||||
containerColor = BottomAppBarDefaults.bottomAppBarFabColor,
|
|
||||||
elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(),
|
|
||||||
onClick = {
|
|
||||||
if (vm.installedPackageName == null)
|
|
||||||
showInstallPicker = true
|
|
||||||
else
|
|
||||||
vm.open()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(paddingValues)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.fillMaxSize()
|
|
||||||
) {
|
|
||||||
steps.forEach {
|
|
||||||
InstallStep(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InstallPicker(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: (InstallType) -> Unit
|
|
||||||
) {
|
|
||||||
var selectedInstallType by rememberSaveable { mutableStateOf(InstallType.DEFAULT) }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
dismissButton = {
|
|
||||||
Button(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onConfirm(selectedInstallType)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.install_app))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = { Text(stringResource(R.string.select_install_type)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
InstallType.values().forEach {
|
|
||||||
ListItem(
|
|
||||||
modifier = Modifier.clickable { selectedInstallType = it },
|
|
||||||
leadingContent = {
|
|
||||||
RadioButton(
|
|
||||||
selected = selectedInstallType == it,
|
|
||||||
onClick = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
headlineContent = { Text(stringResource(it.stringResource)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InstallStep(step: Step) {
|
|
||||||
var expanded by rememberSaveable { mutableStateOf(true) }
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.run {
|
|
||||||
if (expanded) {
|
|
||||||
background(MaterialTheme.colorScheme.secondaryContainer)
|
|
||||||
} else this
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 16.dp, end = 16.dp)
|
|
||||||
.background(if (expanded) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
|
|
||||||
) {
|
|
||||||
StepIcon(step.state, size = 24.dp)
|
|
||||||
|
|
||||||
Text(text = stringResource(step.name), style = MaterialTheme.typography.titleMedium)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
ArrowButton(modifier = Modifier.size(24.dp), expanded = expanded) {
|
|
||||||
expanded = !expanded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(visible = expanded) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.background.copy(0.6f))
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
step.subSteps.forEach { subStep ->
|
|
||||||
var messageExpanded by rememberSaveable { mutableStateOf(true) }
|
|
||||||
val stacktrace = subStep.message
|
|
||||||
val downloadProgress = subStep.progress?.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
StepIcon(subStep.state, downloadProgress?.value, size = 24.dp)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = subStep.name,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f, true),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (stacktrace != null) {
|
|
||||||
ArrowButton(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
expanded = messageExpanded
|
|
||||||
) {
|
|
||||||
messageExpanded = !messageExpanded
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
downloadProgress?.value?.let { (downloaded, total) ->
|
|
||||||
Text(
|
|
||||||
"$downloaded/$total MB",
|
|
||||||
style = MaterialTheme.typography.labelSmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(visible = messageExpanded && stacktrace != null) {
|
|
||||||
Text(
|
|
||||||
text = stacktrace ?: "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.secondary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StepIcon(status: State, downloadProgress: Pair<Float, Float>? = null, size: Dp) {
|
|
||||||
val strokeWidth = Dp(floor(size.value / 10) + 1)
|
|
||||||
|
|
||||||
when (status) {
|
|
||||||
State.COMPLETED -> Icon(
|
|
||||||
Icons.Filled.CheckCircle,
|
|
||||||
contentDescription = stringResource(R.string.step_completed),
|
|
||||||
tint = MaterialTheme.colorScheme.surfaceTint,
|
|
||||||
modifier = Modifier.size(size)
|
|
||||||
)
|
|
||||||
|
|
||||||
State.FAILED -> Icon(
|
|
||||||
Icons.Filled.Cancel,
|
|
||||||
contentDescription = stringResource(R.string.step_failed),
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(size)
|
|
||||||
)
|
|
||||||
|
|
||||||
State.WAITING ->
|
|
||||||
downloadProgress?.let { (downloaded, total) ->
|
|
||||||
CircularProgressIndicator(
|
|
||||||
progress = downloaded / total,
|
|
||||||
strokeWidth = strokeWidth,
|
|
||||||
modifier = stringResource(R.string.step_running).let { description ->
|
|
||||||
Modifier
|
|
||||||
.size(size)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} ?: CircularProgressIndicator(
|
|
||||||
strokeWidth = strokeWidth,
|
|
||||||
modifier = stringResource(R.string.step_running).let { description ->
|
|
||||||
Modifier
|
|
||||||
.size(size)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,175 @@
|
|||||||
|
package app.revanced.manager.ui.screen
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
|
||||||
|
import androidx.compose.material.icons.outlined.FileDownload
|
||||||
|
import androidx.compose.material.icons.outlined.PostAdd
|
||||||
|
import androidx.compose.material.icons.outlined.Save
|
||||||
|
import androidx.compose.material3.BottomAppBar
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
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.ui.component.AppScaffold
|
||||||
|
import app.revanced.manager.ui.component.AppTopBar
|
||||||
|
import app.revanced.manager.ui.component.patcher.InstallPickerDialog
|
||||||
|
import app.revanced.manager.ui.component.patcher.Steps
|
||||||
|
import app.revanced.manager.ui.model.State
|
||||||
|
import app.revanced.manager.ui.model.StepCategory
|
||||||
|
import app.revanced.manager.ui.viewmodel.PatcherViewModel
|
||||||
|
import app.revanced.manager.util.APK_MIMETYPE
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PatcherScreen(
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
vm: PatcherViewModel
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onBackClick)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val exportApkLauncher =
|
||||||
|
rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export)
|
||||||
|
|
||||||
|
val patcherSucceeded by vm.patcherSucceeded.observeAsState(null)
|
||||||
|
val canInstall by remember { derivedStateOf { patcherSucceeded == true && (vm.installedPackageName != null || !vm.isInstalling) } }
|
||||||
|
var showInstallPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val steps by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
vm.steps.groupBy { it.category }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val patchesProgress by vm.patchesProgress.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val progress by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val (patchesCompleted, patchesTotal) = patchesProgress
|
||||||
|
|
||||||
|
val current = vm.steps.count {
|
||||||
|
it.state == State.COMPLETED && it.category != StepCategory.PATCHING
|
||||||
|
} + patchesCompleted
|
||||||
|
|
||||||
|
val total = vm.steps.size - 1 + patchesTotal
|
||||||
|
|
||||||
|
current.toFloat() / total.toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showInstallPicker)
|
||||||
|
InstallPickerDialog(
|
||||||
|
onDismiss = { showInstallPicker = false },
|
||||||
|
onConfirm = vm::install
|
||||||
|
)
|
||||||
|
|
||||||
|
AppScaffold(
|
||||||
|
topBar = {
|
||||||
|
AppTopBar(
|
||||||
|
title = stringResource(R.string.patcher),
|
||||||
|
onBackClick = onBackClick
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
BottomAppBar(
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { exportApkLauncher.launch("${vm.packageName}.apk") },
|
||||||
|
enabled = canInstall
|
||||||
|
) {
|
||||||
|
Icon(Icons.Outlined.Save, stringResource(id = R.string.save_apk))
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = { vm.exportLogs(context) },
|
||||||
|
enabled = patcherSucceeded != null
|
||||||
|
) {
|
||||||
|
Icon(Icons.Outlined.PostAdd, stringResource(id = R.string.save_logs))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
AnimatedVisibility(visible = canInstall) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(if (vm.installedPackageName == null) R.string.install_app else R.string.open_app)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
vm.installedPackageName?.let {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Outlined.OpenInNew,
|
||||||
|
stringResource(R.string.open_app)
|
||||||
|
)
|
||||||
|
} ?: Icon(
|
||||||
|
Icons.Outlined.FileDownload,
|
||||||
|
stringResource(R.string.install_app)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
if (vm.installedPackageName == null)
|
||||||
|
showInstallPicker = true
|
||||||
|
else vm.open()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(16.dp)
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = steps.toList(),
|
||||||
|
key = { it.first }
|
||||||
|
) { (category, steps) ->
|
||||||
|
Steps(
|
||||||
|
category = category,
|
||||||
|
steps = steps,
|
||||||
|
stepCount = if (category == StepCategory.PATCHING) patchesProgress else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,45 +2,17 @@ package app.revanced.manager.ui.screen
|
|||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
|
||||||
import androidx.compose.material.icons.outlined.FilterList
|
import androidx.compose.material.icons.outlined.*
|
||||||
import androidx.compose.material.icons.outlined.HelpOutline
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material.icons.outlined.Restore
|
|
||||||
import androidx.compose.material.icons.outlined.Save
|
|
||||||
import androidx.compose.material.icons.outlined.Search
|
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
|
||||||
import androidx.compose.material.icons.outlined.WarningAmber
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
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.ScrollableTabRow
|
|
||||||
import androidx.compose.material3.SearchBar
|
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.surfaceColorAtElevation
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -48,7 +20,6 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -58,24 +29,26 @@ import androidx.compose.ui.window.Dialog
|
|||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.domain.manager.PreferencesManager
|
import app.revanced.manager.patcher.patch.Option
|
||||||
import app.revanced.manager.patcher.patch.PatchInfo
|
import app.revanced.manager.patcher.patch.PatchInfo
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
import app.revanced.manager.ui.component.AppTopBar
|
||||||
import app.revanced.manager.ui.component.Countdown
|
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||||
|
import app.revanced.manager.ui.component.SafeguardDialog
|
||||||
|
import app.revanced.manager.ui.component.SearchView
|
||||||
import app.revanced.manager.ui.component.patches.OptionItem
|
import app.revanced.manager.ui.component.patches.OptionItem
|
||||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
|
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
|
||||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
|
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
|
||||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
|
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
|
||||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED
|
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED
|
||||||
import app.revanced.manager.util.Options
|
import app.revanced.manager.util.Options
|
||||||
import app.revanced.manager.util.PatchesSelection
|
import app.revanced.manager.util.PatchSelection
|
||||||
|
import app.revanced.manager.util.isScrollingUp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.rememberKoinInject
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun PatchesSelectorScreen(
|
fun PatchesSelectorScreen(
|
||||||
onSave: (PatchesSelection?, Options) -> Unit,
|
onSave: (PatchSelection?, Options) -> Unit,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
vm: PatchesSelectorViewModel
|
vm: PatchesSelectorViewModel
|
||||||
) {
|
) {
|
||||||
@ -95,6 +68,23 @@ fun PatchesSelectorScreen(
|
|||||||
derivedStateOf { vm.selectionIsValid(bundles) }
|
derivedStateOf { vm.selectionIsValid(bundles) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val availablePatchCount by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
bundles.sumOf { it.patchCount }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val defaultPatchSelectionCount by vm.defaultSelectionCount
|
||||||
|
.collectAsStateWithLifecycle(initialValue = 0)
|
||||||
|
|
||||||
|
val selectedPatchCount by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
vm.customPatchSelection?.values?.sumOf { it.size } ?: defaultPatchSelectionCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val patchLazyListStates = remember(bundles) { List(bundles.size) { LazyListState() } }
|
||||||
|
|
||||||
if (showBottomSheet) {
|
if (showBottomSheet) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
@ -105,13 +95,13 @@ fun PatchesSelectorScreen(
|
|||||||
modifier = Modifier.padding(horizontal = 24.dp)
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.patches_selector_sheet_filter_title),
|
text = stringResource(R.string.patch_selector_sheet_filter_title),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.patches_selector_sheet_filter_compat_title),
|
text = stringResource(R.string.patch_selector_sheet_filter_compat_title),
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -142,11 +132,19 @@ fun PatchesSelectorScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (vm.compatibleVersions.isNotEmpty())
|
if (vm.compatibleVersions.isNotEmpty())
|
||||||
UnsupportedDialog(
|
UnsupportedPatchDialog(
|
||||||
appVersion = vm.appVersion,
|
appVersion = vm.appVersion,
|
||||||
supportedVersions = vm.compatibleVersions,
|
supportedVersions = vm.compatibleVersions,
|
||||||
onDismissRequest = vm::dismissDialogs
|
onDismissRequest = vm::dismissDialogs
|
||||||
)
|
)
|
||||||
|
var showUnsupportedPatchesDialog by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
if (showUnsupportedPatchesDialog)
|
||||||
|
UnsupportedPatchesDialog(
|
||||||
|
appVersion = vm.appVersion,
|
||||||
|
onDismissRequest = { showUnsupportedPatchesDialog = false }
|
||||||
|
)
|
||||||
|
|
||||||
vm.optionsDialog?.let { (bundle, patch) ->
|
vm.optionsDialog?.let { (bundle, patch) ->
|
||||||
OptionsDialog(
|
OptionsDialog(
|
||||||
@ -158,10 +156,16 @@ fun PatchesSelectorScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
vm.pendingSelectionAction?.let {
|
var showSelectionWarning by rememberSaveable {
|
||||||
SelectionWarningDialog(
|
mutableStateOf(false)
|
||||||
onCancel = vm::dismissSelectionWarning,
|
}
|
||||||
onConfirm = vm::confirmSelectionWarning
|
if (showSelectionWarning) {
|
||||||
|
SelectionWarningDialog(onDismiss = { showSelectionWarning = false })
|
||||||
|
}
|
||||||
|
vm.pendingUniversalPatchAction?.let {
|
||||||
|
UniversalPatchWarningDialog(
|
||||||
|
onCancel = vm::dismissUniversalPatchWarning,
|
||||||
|
onConfirm = vm::confirmUniversalPatchWarning
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,12 +197,20 @@ fun PatchesSelectorScreen(
|
|||||||
patch
|
patch
|
||||||
),
|
),
|
||||||
onToggle = {
|
onToggle = {
|
||||||
if (vm.selectionWarningEnabled) {
|
when {
|
||||||
vm.pendingSelectionAction = {
|
// Open unsupported dialog if the patch is not supported
|
||||||
vm.togglePatch(uid, patch)
|
!supported -> vm.openUnsupportedDialog(patch)
|
||||||
|
|
||||||
|
// Show selection warning if enabled
|
||||||
|
vm.selectionWarningEnabled -> showSelectionWarning = true
|
||||||
|
|
||||||
|
// Set pending universal patch action if the universal patch warning is enabled and there are no compatible packages
|
||||||
|
vm.universalPatchWarningEnabled && patch.compatiblePackages == null -> {
|
||||||
|
vm.pendingUniversalPatchAction = { vm.togglePatch(uid, patch) }
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
vm.togglePatch(uid, patch)
|
// Toggle the patch otherwise
|
||||||
|
else -> vm.togglePatch(uid, patch)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
supported = supported
|
supported = supported
|
||||||
@ -208,31 +220,17 @@ fun PatchesSelectorScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
search?.let { query ->
|
search?.let { query ->
|
||||||
SearchBar(
|
SearchView(
|
||||||
query = query,
|
query = query,
|
||||||
onQueryChange = { new ->
|
onQueryChange = { search = it },
|
||||||
search = new
|
onActiveChange = { if (!it) search = null },
|
||||||
},
|
placeholder = { Text(stringResource(R.string.search_patches)) }
|
||||||
onSearch = {},
|
|
||||||
active = true,
|
|
||||||
onActiveChange = { new ->
|
|
||||||
if (new) return@SearchBar
|
|
||||||
search = null
|
|
||||||
},
|
|
||||||
placeholder = {
|
|
||||||
Text(stringResource(R.string.search_patches))
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
IconButton(onClick = { search = null }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ArrowBack,
|
|
||||||
stringResource(R.string.back)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
val bundle = bundles[pagerState.currentPage]
|
val bundle = bundles[pagerState.currentPage]
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
|
LazyColumnWithScrollbar(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
fun List<PatchInfo>.searched() = filter {
|
fun List<PatchInfo>.searched() = filter {
|
||||||
it.name.contains(query, true)
|
it.name.contains(query, true)
|
||||||
}
|
}
|
||||||
@ -254,7 +252,7 @@ fun PatchesSelectorScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!vm.allowExperimental) return@LazyColumn
|
if (!vm.allowIncompatiblePatches) return@LazyColumnWithScrollbar
|
||||||
patchList(
|
patchList(
|
||||||
uid = bundle.uid,
|
uid = bundle.uid,
|
||||||
patches = bundle.unsupported.searched(),
|
patches = bundle.unsupported.searched(),
|
||||||
@ -263,18 +261,17 @@ fun PatchesSelectorScreen(
|
|||||||
) {
|
) {
|
||||||
ListHeader(
|
ListHeader(
|
||||||
title = stringResource(R.string.unsupported_patches),
|
title = stringResource(R.string.unsupported_patches),
|
||||||
onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) }
|
onHelpClick = { showUnsupportedPatchesDialog = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
AppTopBar(
|
AppTopBar(
|
||||||
title = stringResource(R.string.select_patches),
|
title = stringResource(R.string.patches_selected, selectedPatchCount, availablePatchCount),
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = vm::reset) {
|
IconButton(onClick = vm::reset) {
|
||||||
@ -298,7 +295,14 @@ fun PatchesSelectorScreen(
|
|||||||
|
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
text = { Text(stringResource(R.string.save)) },
|
text = { Text(stringResource(R.string.save)) },
|
||||||
icon = { Icon(Icons.Outlined.Save, null) },
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Save,
|
||||||
|
stringResource(R.string.save)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp
|
||||||
|
?: true,
|
||||||
onClick = {
|
onClick = {
|
||||||
// TODO: only allow this if all required options have been set.
|
// TODO: only allow this if all required options have been set.
|
||||||
onSave(vm.getCustomSelection(), vm.getOptions())
|
onSave(vm.getCustomSelection(), vm.getOptions())
|
||||||
@ -338,10 +342,13 @@ fun PatchesSelectorScreen(
|
|||||||
state = pagerState,
|
state = pagerState,
|
||||||
userScrollEnabled = true,
|
userScrollEnabled = true,
|
||||||
pageContent = { index ->
|
pageContent = { index ->
|
||||||
|
// Avoid crashing if the lists have not been fully initialized yet.
|
||||||
|
if (index > bundles.lastIndex || bundles.size != patchLazyListStates.size) return@HorizontalPager
|
||||||
val bundle = bundles[index]
|
val bundle = bundles[index]
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumnWithScrollbar(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
state = patchLazyListStates[index]
|
||||||
) {
|
) {
|
||||||
patchList(
|
patchList(
|
||||||
uid = bundle.uid,
|
uid = bundle.uid,
|
||||||
@ -363,11 +370,11 @@ fun PatchesSelectorScreen(
|
|||||||
uid = bundle.uid,
|
uid = bundle.uid,
|
||||||
patches = bundle.unsupported,
|
patches = bundle.unsupported,
|
||||||
filterFlag = SHOW_UNSUPPORTED,
|
filterFlag = SHOW_UNSUPPORTED,
|
||||||
supported = vm.allowExperimental
|
supported = vm.allowIncompatiblePatches
|
||||||
) {
|
) {
|
||||||
ListHeader(
|
ListHeader(
|
||||||
title = stringResource(R.string.unsupported_patches),
|
title = stringResource(R.string.unsupported_patches),
|
||||||
onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) }
|
onHelpClick = { showUnsupportedPatchesDialog = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -378,35 +385,24 @@ fun PatchesSelectorScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SelectionWarningDialog(
|
private fun SelectionWarningDialog(onDismiss: () -> Unit) {
|
||||||
onCancel: () -> Unit,
|
SafeguardDialog(
|
||||||
onConfirm: (Boolean) -> Unit
|
onDismiss = onDismiss,
|
||||||
) {
|
title = R.string.warning,
|
||||||
val prefs: PreferencesManager = rememberKoinInject()
|
body = stringResource(R.string.selection_warning_description),
|
||||||
var dismissPermanently by rememberSaveable {
|
)
|
||||||
mutableStateOf(false)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UniversalPatchWarningDialog(
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onConfirm: () -> Unit
|
||||||
|
) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onCancel,
|
onDismissRequest = onCancel,
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
val enableCountdown by prefs.enableSelectionWarningCountdown.getAsState()
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text(stringResource(R.string.continue_))
|
||||||
Countdown(start = if (enableCountdown) 3 else 0) { timer ->
|
|
||||||
LaunchedEffect(timer) {
|
|
||||||
if (timer == 0) prefs.enableSelectionWarningCountdown.update(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
TextButton(
|
|
||||||
onClick = { onConfirm(dismissPermanently) },
|
|
||||||
enabled = timer == 0
|
|
||||||
) {
|
|
||||||
val text =
|
|
||||||
if (timer == 0) stringResource(R.string.continue_) else stringResource(
|
|
||||||
R.string.selection_warning_continue_countdown, timer
|
|
||||||
)
|
|
||||||
Text(text, color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
@ -419,44 +415,18 @@ fun SelectionWarningDialog(
|
|||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.selection_warning_title),
|
text = stringResource(R.string.warning),
|
||||||
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center),
|
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center)
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Column(
|
Text(stringResource(R.string.universal_patch_warning_description))
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.selection_warning_description),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
dismissPermanently = !dismissPermanently
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Checkbox(
|
|
||||||
checked = dismissPermanently,
|
|
||||||
onCheckedChange = {
|
|
||||||
dismissPermanently = it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Text(stringResource(R.string.permanent_dismiss))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PatchItem(
|
private fun PatchItem(
|
||||||
patch: PatchInfo,
|
patch: PatchInfo,
|
||||||
onOptionsDialog: () -> Unit,
|
onOptionsDialog: () -> Unit,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
@ -465,7 +435,7 @@ fun PatchItem(
|
|||||||
) = ListItem(
|
) = ListItem(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.let { if (!supported) it.alpha(0.5f) else it }
|
.let { if (!supported) it.alpha(0.5f) else it }
|
||||||
.clickable(enabled = supported, onClick = onToggle)
|
.clickable(onClick = onToggle)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
@ -486,7 +456,7 @@ fun PatchItem(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ListHeader(
|
private fun ListHeader(
|
||||||
title: String,
|
title: String,
|
||||||
onHelpClick: (() -> Unit)? = null
|
onHelpClick: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
@ -502,7 +472,7 @@ fun ListHeader(
|
|||||||
{
|
{
|
||||||
IconButton(onClick = it) {
|
IconButton(onClick = it) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.HelpOutline,
|
Icons.AutoMirrored.Outlined.HelpOutline,
|
||||||
stringResource(R.string.help)
|
stringResource(R.string.help)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -512,18 +482,46 @@ fun ListHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UnsupportedDialog(
|
private fun UnsupportedPatchesDialog(
|
||||||
appVersion: String,
|
appVersion: String,
|
||||||
supportedVersions: List<String>,
|
|
||||||
onDismissRequest: () -> Unit
|
onDismissRequest: () -> Unit
|
||||||
) = AlertDialog(
|
) = AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Outlined.WarningAmber, null)
|
||||||
|
},
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = onDismissRequest) {
|
TextButton(onClick = onDismissRequest) {
|
||||||
Text(stringResource(R.string.ok))
|
Text(stringResource(R.string.ok))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
title = { Text(stringResource(R.string.unsupported_app)) },
|
title = { Text(stringResource(R.string.unsupported_patches)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.unsupported_patches_dialog,
|
||||||
|
appVersion
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UnsupportedPatchDialog(
|
||||||
|
appVersion: String,
|
||||||
|
supportedVersions: List<String>,
|
||||||
|
onDismissRequest: () -> Unit
|
||||||
|
) = AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Outlined.WarningAmber, null)
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.ok))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.unsupported_patch)) },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
@ -537,7 +535,7 @@ fun UnsupportedDialog(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OptionsDialog(
|
private fun OptionsDialog(
|
||||||
patch: PatchInfo,
|
patch: PatchInfo,
|
||||||
values: Map<String, Any?>?,
|
values: Map<String, Any?>?,
|
||||||
reset: () -> Unit,
|
reset: () -> Unit,
|
||||||
@ -563,18 +561,25 @@ fun OptionsDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
LazyColumnWithScrollbar(
|
||||||
modifier = Modifier.padding(paddingValues)
|
modifier = Modifier.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
if (patch.options == null) return@LazyColumn
|
if (patch.options == null) return@LazyColumnWithScrollbar
|
||||||
|
|
||||||
items(patch.options, key = { it.key }) { option ->
|
items(patch.options, key = { it.key }) { option ->
|
||||||
val key = option.key
|
val key = option.key
|
||||||
val value =
|
val value =
|
||||||
if (values == null || !values.contains(key)) option.default else values[key]
|
if (values == null || !values.contains(key)) option.default else values[key]
|
||||||
|
|
||||||
OptionItem(option = option, value = value, setValue = { set(key, it) })
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
OptionItem(
|
||||||
|
option = option as Option<Any>,
|
||||||
|
value = value,
|
||||||
|
setValue = {
|
||||||
|
set(key, it)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,12 +3,13 @@ package app.revanced.manager.ui.screen
|
|||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.ArrowRight
|
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@ -26,25 +27,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.ui.component.AppInfo
|
import app.revanced.manager.ui.component.AppInfo
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
import app.revanced.manager.ui.component.AppTopBar
|
||||||
|
import app.revanced.manager.ui.component.ColumnWithScrollbar
|
||||||
import app.revanced.manager.ui.destination.SelectedAppInfoDestination
|
import app.revanced.manager.ui.destination.SelectedAppInfoDestination
|
||||||
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
|
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
|
||||||
import app.revanced.manager.ui.model.SelectedApp
|
import app.revanced.manager.ui.model.SelectedApp
|
||||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
|
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
|
||||||
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
|
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
|
||||||
import app.revanced.manager.util.Options
|
import app.revanced.manager.util.Options
|
||||||
import app.revanced.manager.util.PatchesSelection
|
import app.revanced.manager.util.PatchSelection
|
||||||
import app.revanced.manager.util.toast
|
import app.revanced.manager.util.toast
|
||||||
import dev.olshevski.navigation.reimagined.AnimatedNavHost
|
import dev.olshevski.navigation.reimagined.AnimatedNavHost
|
||||||
import dev.olshevski.navigation.reimagined.NavBackHandler
|
import dev.olshevski.navigation.reimagined.NavBackHandler
|
||||||
import dev.olshevski.navigation.reimagined.navigate
|
import dev.olshevski.navigation.reimagined.navigate
|
||||||
import dev.olshevski.navigation.reimagined.pop
|
import dev.olshevski.navigation.reimagined.pop
|
||||||
import dev.olshevski.navigation.reimagined.rememberNavController
|
import dev.olshevski.navigation.reimagined.rememberNavController
|
||||||
import org.koin.androidx.compose.getViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SelectedAppInfoScreen(
|
fun SelectedAppInfoScreen(
|
||||||
onPatchClick: (SelectedApp, PatchesSelection, Options) -> Unit,
|
onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
vm: SelectedAppInfoViewModel
|
vm: SelectedAppInfoViewModel
|
||||||
) {
|
) {
|
||||||
@ -56,10 +58,10 @@ fun SelectedAppInfoScreen(
|
|||||||
vm.bundlesRepo.bundleInfoFlow(packageName, version)
|
vm.bundlesRepo.bundleInfoFlow(packageName, version)
|
||||||
}.collectAsStateWithLifecycle(initialValue = emptyList())
|
}.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
|
||||||
val allowExperimental by vm.prefs.allowExperimental.getAsState()
|
val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
|
||||||
val patches by remember {
|
val patches by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
vm.getPatches(bundles, allowExperimental)
|
vm.getPatches(bundles, allowIncompatiblePatches)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val selectedPatchCount by remember {
|
val selectedPatchCount by remember {
|
||||||
@ -67,11 +69,6 @@ fun SelectedAppInfoScreen(
|
|||||||
patches.values.sumOf { it.size }
|
patches.values.sumOf { it.size }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val availablePatchCount by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
bundles.sumOf { it.patchCount }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val navController =
|
val navController =
|
||||||
rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main)
|
rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main)
|
||||||
@ -99,7 +96,7 @@ fun SelectedAppInfoScreen(
|
|||||||
vm.selectedApp,
|
vm.selectedApp,
|
||||||
vm.getCustomPatches(
|
vm.getCustomPatches(
|
||||||
bundles,
|
bundles,
|
||||||
allowExperimental
|
allowIncompatiblePatches
|
||||||
),
|
),
|
||||||
vm.options
|
vm.options
|
||||||
)
|
)
|
||||||
@ -109,7 +106,6 @@ fun SelectedAppInfoScreen(
|
|||||||
navController.navigate(SelectedAppInfoDestination.VersionSelector)
|
navController.navigate(SelectedAppInfoDestination.VersionSelector)
|
||||||
},
|
},
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
availablePatchCount = availablePatchCount,
|
|
||||||
selectedPatchCount = selectedPatchCount,
|
selectedPatchCount = selectedPatchCount,
|
||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
version = version,
|
version = version,
|
||||||
@ -122,7 +118,7 @@ fun SelectedAppInfoScreen(
|
|||||||
vm.selectedApp = it
|
vm.selectedApp = it
|
||||||
navController.pop()
|
navController.pop()
|
||||||
},
|
},
|
||||||
viewModel = getViewModel { parametersOf(packageName) }
|
viewModel = koinViewModel { parametersOf(packageName) }
|
||||||
)
|
)
|
||||||
|
|
||||||
is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
|
is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
|
||||||
@ -131,7 +127,7 @@ fun SelectedAppInfoScreen(
|
|||||||
navController.pop()
|
navController.pop()
|
||||||
},
|
},
|
||||||
onBackClick = navController::pop,
|
onBackClick = navController::pop,
|
||||||
vm = getViewModel {
|
vm = koinViewModel {
|
||||||
parametersOf(
|
parametersOf(
|
||||||
PatchesSelectorViewModel.Params(
|
PatchesSelectorViewModel.Params(
|
||||||
destination.app,
|
destination.app,
|
||||||
@ -152,7 +148,6 @@ private fun SelectedAppInfoScreen(
|
|||||||
onPatchSelectorClick: () -> Unit,
|
onPatchSelectorClick: () -> Unit,
|
||||||
onVersionSelectorClick: () -> Unit,
|
onVersionSelectorClick: () -> Unit,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
availablePatchCount: Int,
|
|
||||||
selectedPatchCount: Int,
|
selectedPatchCount: Int,
|
||||||
packageName: String,
|
packageName: String,
|
||||||
version: String,
|
version: String,
|
||||||
@ -164,30 +159,33 @@ private fun SelectedAppInfoScreen(
|
|||||||
title = stringResource(R.string.app_info),
|
title = stringResource(R.string.app_info),
|
||||||
onBackClick = onBackClick
|
onBackClick = onBackClick
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
text = { Text(stringResource(R.string.patch)) },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AutoFixHigh,
|
||||||
|
stringResource(R.string.patch)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onPatchClick
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
ColumnWithScrollbar(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
AppInfo(packageInfo, placeholderLabel = packageName) {
|
AppInfo(packageInfo, placeholderLabel = packageName) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.selected_app_meta, version, availablePatchCount),
|
stringResource(R.string.selected_app_meta, version),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
PageItem(R.string.patch, stringResource(R.string.patch_item_description), onPatchClick)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.advanced),
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
PageItem(
|
PageItem(
|
||||||
R.string.patch_selector_item,
|
R.string.patch_selector_item,
|
||||||
stringResource(R.string.patch_selector_item_description, selectedPatchCount),
|
stringResource(R.string.patch_selector_item_description, selectedPatchCount),
|
||||||
@ -223,7 +221,7 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Icon(Icons.Outlined.ArrowRight, null)
|
Icon(Icons.AutoMirrored.Outlined.ArrowRight, null)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user