mirror of
https://github.com/revanced/revanced-manager.git
synced 2025-05-02 14:54: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'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Build with Gradle
|
||||
env:
|
||||
@ -38,7 +38,7 @@ jobs:
|
||||
run: mv app/build/outputs/apk/release/app-release.apk revanced-manager-${{ env.COMMIT_HASH }}.apk
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: revanced-manager
|
||||
path: 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'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
with:
|
||||
cache-disabled: true
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Build with Gradle
|
||||
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
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: peter-evans/repository-dispatch@v2
|
||||
- uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.DOCUMENTATION_REPO_ACCESS_TOKEN }}
|
||||
repository: revanced/revanced-documentation
|
||||
|
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 {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.devtools)
|
||||
alias(libs.plugins.about.libraries)
|
||||
id("kotlin-parcelize")
|
||||
kotlin("plugin.serialization") version "1.9.10"
|
||||
kotlin("plugin.serialization") version "1.9.23"
|
||||
}
|
||||
|
||||
android {
|
||||
@ -18,16 +20,15 @@ android {
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.0.1"
|
||||
resourceConfigurations.addAll(listOf(
|
||||
"en",
|
||||
))
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
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 {
|
||||
@ -42,6 +43,8 @@ android {
|
||||
resValue("string", "app_name", "ReVanced Manager Debug")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
|
||||
buildConfigField("long", "BUILD_ID", "0L")
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,7 +57,7 @@ android {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
|
||||
packaging {
|
||||
resources.excludes.addAll(listOf(
|
||||
"/prebuilt/**",
|
||||
@ -80,8 +83,21 @@ android {
|
||||
|
||||
buildFeatures.compose = 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 {
|
||||
@ -104,14 +120,16 @@ dependencies {
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.preview)
|
||||
implementation(libs.compose.ui.tooling)
|
||||
implementation(libs.compose.livedata)
|
||||
implementation(libs.compose.material.icons.extended)
|
||||
implementation(libs.compose.material3)
|
||||
|
||||
// Accompanist
|
||||
implementation(libs.accompanist.drawablepainter)
|
||||
implementation(libs.accompanist.webview)
|
||||
implementation(libs.accompanist.placeholder)
|
||||
|
||||
// Placeholder
|
||||
implementation(libs.placeholder.material3)
|
||||
|
||||
// HTML Scraper
|
||||
implementation(libs.skrapeit.dsl)
|
||||
@ -135,6 +153,13 @@ dependencies {
|
||||
implementation(libs.revanced.patcher)
|
||||
implementation(libs.revanced.library)
|
||||
|
||||
// Native processes
|
||||
implementation(libs.kotlin.process)
|
||||
|
||||
// HiddenAPI
|
||||
compileOnly(libs.hidden.api.stub)
|
||||
|
||||
// LibSU
|
||||
implementation(libs.libsu.core)
|
||||
implementation(libs.libsu.service)
|
||||
implementation(libs.libsu.nio)
|
||||
@ -162,4 +187,13 @@ dependencies {
|
||||
|
||||
// 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(...);
|
||||
}
|
||||
|
||||
# This required for the process runtime.
|
||||
-keep class app.revanced.manager.patcher.runtime.process.* {
|
||||
*;
|
||||
}
|
||||
# Required for the patcher to function correctly
|
||||
-keep class app.revanced.patcher.** {
|
||||
*;
|
||||
@ -45,6 +49,7 @@
|
||||
-keep class com.android.** {
|
||||
*;
|
||||
}
|
||||
-dontwarn com.google.auto.value.**
|
||||
-dontwarn java.awt.**
|
||||
-dontwarn javax.**
|
||||
-dontwarn org.slf4j.**
|
||||
|
@ -2,7 +2,7 @@
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "802fa2fda94b930bf0ebb85d195f1022",
|
||||
"identityHash": "1dd9d5c0201fdf3cfef3ae669fd65e46",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "patch_bundles",
|
||||
@ -51,17 +51,7 @@
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"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`)"
|
||||
}
|
||||
],
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
@ -231,7 +221,7 @@
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
@ -285,7 +275,7 @@
|
||||
},
|
||||
{
|
||||
"table": "patch_bundles",
|
||||
"onDelete": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"bundle"
|
||||
@ -407,7 +397,7 @@
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '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.compose.animation.ExperimentalAnimationApi
|
||||
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.ui.res.stringResource
|
||||
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.SettingsDestination
|
||||
import app.revanced.manager.ui.screen.AppSelectorScreen
|
||||
import app.revanced.manager.ui.screen.DashboardScreen
|
||||
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.SettingsScreen
|
||||
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.rememberNavController
|
||||
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
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
@ -46,7 +38,6 @@ class MainActivity : ComponentActivity() {
|
||||
installSplashScreen()
|
||||
|
||||
val vm: MainViewModel = getAndroidViewModel()
|
||||
|
||||
vm.importLegacySettings(this)
|
||||
|
||||
setContent {
|
||||
@ -59,37 +50,8 @@ class MainActivity : ComponentActivity() {
|
||||
) {
|
||||
val navController =
|
||||
rememberNavController<Destination>(startDestination = Destination.Dashboard)
|
||||
|
||||
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(
|
||||
controller = navController
|
||||
) { destination ->
|
||||
@ -97,6 +59,9 @@ class MainActivity : ComponentActivity() {
|
||||
is Destination.Dashboard -> DashboardScreen(
|
||||
onSettingsClick = { navController.navigate(Destination.Settings()) },
|
||||
onAppSelectorClick = { navController.navigate(Destination.AppSelector) },
|
||||
onUpdateClick = { navController.navigate(
|
||||
Destination.Settings(SettingsDestination.Update())
|
||||
) },
|
||||
onAppClick = { installedApp ->
|
||||
navController.navigate(
|
||||
Destination.InstalledApplicationInfo(
|
||||
@ -107,11 +72,11 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
|
||||
is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen(
|
||||
onPatchClick = { packageName, patchesSelection ->
|
||||
onPatchClick = { packageName, patchSelection ->
|
||||
navController.navigate(
|
||||
Destination.VersionSelector(
|
||||
packageName,
|
||||
patchesSelection
|
||||
patchSelection
|
||||
)
|
||||
)
|
||||
},
|
||||
@ -142,14 +107,14 @@ class MainActivity : ComponentActivity() {
|
||||
navController.navigate(
|
||||
Destination.SelectedApplicationInfo(
|
||||
selectedApp,
|
||||
destination.patchesSelection,
|
||||
destination.patchSelection,
|
||||
)
|
||||
)
|
||||
},
|
||||
viewModel = getComposeViewModel {
|
||||
parametersOf(
|
||||
destination.packageName,
|
||||
destination.patchesSelection
|
||||
destination.patchSelection
|
||||
)
|
||||
}
|
||||
)
|
||||
@ -157,7 +122,7 @@ class MainActivity : ComponentActivity() {
|
||||
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
|
||||
onPatchClick = { app, patches, options ->
|
||||
navController.navigate(
|
||||
Destination.Installer(
|
||||
Destination.Patcher(
|
||||
app, patches, options
|
||||
)
|
||||
)
|
||||
@ -167,13 +132,13 @@ class MainActivity : ComponentActivity() {
|
||||
parametersOf(
|
||||
SelectedAppInfoViewModel.Params(
|
||||
destination.selectedApp,
|
||||
destination.patchesSelection
|
||||
destination.patchSelection
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
is Destination.Installer -> InstallerScreen(
|
||||
is Destination.Patcher -> PatcherScreen(
|
||||
onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
|
||||
vm = getComposeViewModel { parametersOf(destination) }
|
||||
)
|
||||
|
@ -1,23 +1,18 @@
|
||||
package app.revanced.manager
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import app.revanced.manager.di.*
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||
import app.revanced.manager.service.ManagerRootService
|
||||
import app.revanced.manager.service.RootConnection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.internal.BuilderImpl
|
||||
import com.topjohnwu.superuser.ipc.RootService
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.zhanghai.android.appiconloader.coil.AppIconFetcher
|
||||
import me.zhanghai.android.appiconloader.coil.AppIconKeyer
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
@ -61,9 +56,6 @@ class ManagerApplication : Application() {
|
||||
val shellBuilder = BuilderImpl.create().setFlags(Shell.FLAG_MOUNT_MASTER)
|
||||
Shell.setDefaultBuilder(shellBuilder)
|
||||
|
||||
val intent = Intent(this, ManagerRootService::class.java)
|
||||
RootService.bind(intent, get<RootConnection>())
|
||||
|
||||
scope.launch {
|
||||
prefs.preload()
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
package app.revanced.manager.data.platform
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.
|
||||
* 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()
|
||||
mkdirs()
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ package app.revanced.manager.data.room
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
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
|
||||
|
||||
class Converters {
|
||||
@ -17,4 +17,10 @@ class Converters {
|
||||
|
||||
@TypeConverter
|
||||
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(
|
||||
PatchBundleEntity::class,
|
||||
parentColumns = ["uid"],
|
||||
childColumns = ["bundle"]
|
||||
childColumns = ["bundle"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
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.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.MapInfo
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
@ -17,12 +17,13 @@ interface InstalledAppDao {
|
||||
@Query("SELECT * FROM installed_app WHERE current_package_name = :packageName")
|
||||
suspend fun get(packageName: String): InstalledApp?
|
||||
|
||||
@MapInfo(keyColumn = "bundle", valueColumn = "patch_name")
|
||||
@Query(
|
||||
"SELECT bundle, patch_name FROM applied_patch" +
|
||||
" 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
|
||||
suspend fun upsertApp(installedApp: InstalledApp, appliedPatches: List<AppliedPatch>) {
|
||||
|
@ -9,7 +9,7 @@ interface PatchBundleDao {
|
||||
suspend fun all(): List<PatchBundleEntity>
|
||||
|
||||
@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")
|
||||
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")
|
||||
suspend fun setAutoUpdate(uid: Int, value: Boolean)
|
||||
|
||||
@Query("UPDATE patch_bundles SET name = :value WHERE uid = :uid")
|
||||
suspend fun setName(uid: Int, value: String)
|
||||
|
||||
@Query("DELETE FROM patch_bundles WHERE uid != 0")
|
||||
suspend fun purgeCustomBundles()
|
||||
|
||||
|
@ -21,7 +21,7 @@ sealed class Source {
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(value: String) = when(value) {
|
||||
fun from(value: String) = when (value) {
|
||||
Local.SENTINEL -> Local
|
||||
API.SENTINEL -> API
|
||||
else -> Remote(Url(value))
|
||||
@ -34,7 +34,7 @@ data class VersionInfo(
|
||||
@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(
|
||||
@PrimaryKey val uid: Int,
|
||||
@ColumnInfo(name = "name") val name: String,
|
||||
|
@ -3,6 +3,23 @@ package app.revanced.manager.data.room.options
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.float
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Entity(
|
||||
tableName = "options",
|
||||
@ -19,5 +36,74 @@ data class Option(
|
||||
@ColumnInfo(name = "patch_name") val patchName: String,
|
||||
@ColumnInfo(name = "key") val key: String,
|
||||
// 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.Insert
|
||||
import androidx.room.MapInfo
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@ -10,13 +10,12 @@ import kotlinx.coroutines.flow.Flow
|
||||
@Dao
|
||||
abstract class OptionDao {
|
||||
@Transaction
|
||||
@MapInfo(keyColumn = "patch_bundle")
|
||||
@Query(
|
||||
"SELECT patch_bundle, `group`, patch_name, `key`, value FROM option_groups" +
|
||||
" LEFT JOIN options ON uid = options.`group`" +
|
||||
" WHERE package_name = :packageName"
|
||||
)
|
||||
abstract suspend fun getOptions(packageName: String): Map<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")
|
||||
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.Insert
|
||||
import androidx.room.MapInfo
|
||||
import androidx.room.MapColumn
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
|
||||
@Dao
|
||||
abstract class SelectionDao {
|
||||
@Transaction
|
||||
@MapInfo(keyColumn = "patch_bundle", valueColumn = "patch_name")
|
||||
@Query(
|
||||
"SELECT patch_bundle, patch_name FROM patch_selections" +
|
||||
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
|
||||
" WHERE package_name = :packageName"
|
||||
)
|
||||
abstract suspend fun getSelectedPatches(packageName: String): Map<Int, List<String>>
|
||||
abstract suspend fun getSelectedPatches(packageName: String): Map<@MapColumn("patch_bundle") Int, List<@MapColumn(
|
||||
"patch_name"
|
||||
) String>>
|
||||
|
||||
@Transaction
|
||||
@MapInfo(keyColumn = "package_name", valueColumn = "patch_name")
|
||||
@Query(
|
||||
"SELECT package_name, patch_name FROM patch_selections" +
|
||||
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
|
||||
" WHERE patch_bundle = :bundleUid"
|
||||
)
|
||||
abstract suspend fun exportSelection(bundleUid: Int): Map<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")
|
||||
abstract suspend fun getSelectionId(bundleUid: Int, packageName: String): Int?
|
||||
|
@ -1,11 +1,9 @@
|
||||
package app.revanced.manager.di
|
||||
|
||||
import app.revanced.manager.domain.installer.RootInstaller
|
||||
import app.revanced.manager.service.RootConnection
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val rootModule = module {
|
||||
singleOf(::RootConnection)
|
||||
singleOf(::RootInstaller)
|
||||
}
|
@ -13,12 +13,15 @@ val viewModelModule = module {
|
||||
viewModelOf(::AdvancedSettingsViewModel)
|
||||
viewModelOf(::AppSelectorViewModel)
|
||||
viewModelOf(::VersionSelectorViewModel)
|
||||
viewModelOf(::InstallerViewModel)
|
||||
viewModelOf(::PatcherViewModel)
|
||||
viewModelOf(::UpdateViewModel)
|
||||
viewModelOf(::ChangelogsViewModel)
|
||||
viewModelOf(::ImportExportViewModel)
|
||||
viewModelOf(::AboutViewModel)
|
||||
viewModelOf(::DeveloperOptionsViewModel)
|
||||
viewModelOf(::ContributorViewModel)
|
||||
viewModelOf(::DownloadsViewModel)
|
||||
viewModelOf(::InstalledAppsViewModel)
|
||||
viewModelOf(::InstalledAppInfoViewModel)
|
||||
viewModelOf(::UpdatesSettingsViewModel)
|
||||
}
|
||||
|
@ -7,7 +7,8 @@ import java.io.InputStream
|
||||
import java.nio.file.Files
|
||||
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) {
|
||||
withContext(Dispatchers.IO) {
|
||||
patches?.let { inputStream ->
|
||||
@ -16,10 +17,16 @@ class LocalPatchBundle(name: String, id: Int, directory: File) : PatchBundleSour
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository
|
||||
import app.revanced.manager.patcher.patch.PatchBundle
|
||||
import app.revanced.manager.util.tag
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.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.OutputStream
|
||||
|
||||
@ -14,13 +24,21 @@ import java.io.OutputStream
|
||||
* A [PatchBundle] source.
|
||||
*/
|
||||
@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 integrationsFile = directory.resolve("integrations.apk")
|
||||
|
||||
private val _state = MutableStateFlow(load())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _nameFlow = MutableStateFlow(initialName)
|
||||
val nameFlow =
|
||||
_nameFlow.map { it.ifEmpty { app.getString(if (isDefault) R.string.bundle_name_default else R.string.bundle_name_fallback) } }
|
||||
|
||||
suspend fun getName() = nameFlow.first()
|
||||
|
||||
/**
|
||||
* Returns true if the bundle has been downloaded to local storage.
|
||||
*/
|
||||
@ -42,13 +60,38 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File)
|
||||
return try {
|
||||
State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists)))
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
_state.value = load()
|
||||
suspend fun reload(): PatchBundle? {
|
||||
val newState = load()
|
||||
_state.value = newState
|
||||
|
||||
val bundle = newState.patchBundleOrNull()
|
||||
// Try to read the name from the patch bundle manifest if the bundle does not have a name.
|
||||
if (bundle != null && _nameFlow.value.isEmpty()) {
|
||||
bundle.readManifestAttribute("Name")?.let { setName(it) }
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a flow that emits the [app.revanced.manager.data.room.bundles.BundleProperties] of this [PatchBundleSource].
|
||||
* The flow will emit null if the associated [PatchBundleSource] is deleted.
|
||||
*/
|
||||
fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default)
|
||||
suspend fun getProps() = propsFlow().first()!!
|
||||
|
||||
suspend fun currentVersion() = getProps().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 {
|
||||
@ -61,9 +104,12 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val PatchBundleSource.isDefault get() = uid == 0
|
||||
val PatchBundleSource.asRemoteOrNull get() = this as? RemotePatchBundle
|
||||
fun PatchBundleSource.propsOrNullFlow() = asRemoteOrNull?.propsFlow() ?: flowOf(null)
|
||||
companion object Extensions {
|
||||
val PatchBundleSource.isDefault inline get() = uid == 0
|
||||
val PatchBundleSource.asRemoteOrNull inline get() = this as? RemotePatchBundle
|
||||
val PatchBundleSource.nameState
|
||||
@Composable inline get() = nameFlow.collectAsStateWithLifecycle(
|
||||
""
|
||||
)
|
||||
}
|
||||
}
|
@ -2,7 +2,6 @@ package app.revanced.manager.domain.bundles
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
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.Extensions.findAssetByType
|
||||
import app.revanced.manager.network.dto.BundleAsset
|
||||
@ -15,17 +14,14 @@ import io.ktor.client.request.url
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
@Stable
|
||||
sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) :
|
||||
PatchBundleSource(name, id, directory), KoinComponent {
|
||||
private val configRepository: PatchBundlePersistenceRepository by inject()
|
||||
PatchBundleSource(name, id, directory) {
|
||||
protected val http: HttpService by inject()
|
||||
|
||||
protected abstract suspend fun getLatestInfo(): BundleInfo
|
||||
@ -70,17 +66,11 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo
|
||||
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) {
|
||||
arrayOf(patchesFile, integrationsFile).forEach(File::delete)
|
||||
reload()
|
||||
}
|
||||
|
||||
fun propsFlow() = configRepository.getProps(uid)
|
||||
|
||||
suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value)
|
||||
|
||||
companion object {
|
||||
@ -107,7 +97,7 @@ class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
|
||||
.getLatestRelease(repo)
|
||||
.getOrThrow()
|
||||
.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
|
||||
|
||||
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 com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.ipc.RootService
|
||||
import com.topjohnwu.superuser.nio.FileSystemManager
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.time.withTimeoutOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.time.Duration
|
||||
|
||||
class RootInstaller(
|
||||
private val app: Application,
|
||||
private val rootConnection: RootConnection,
|
||||
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 isAppInstalled(packageName: String) =
|
||||
rootConnection.remoteFS?.getFile("$modulesPath/$packageName-revanced")
|
||||
?.exists() ?: throw RootServiceException()
|
||||
suspend fun isAppInstalled(packageName: String) =
|
||||
awaitRemoteFS().getFile("$modulesPath/$packageName-revanced").exists()
|
||||
|
||||
fun isAppMounted(packageName: String): Boolean {
|
||||
return pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let {
|
||||
Shell.cmd("mount | grep \"$it\"").exec().isSuccess
|
||||
suspend fun isAppMounted(packageName: String) = withContext(Dispatchers.IO) {
|
||||
pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let {
|
||||
execute("mount | grep \"$it\"").isSuccess
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun mount(packageName: String) {
|
||||
suspend fun mount(packageName: String) {
|
||||
if (isAppMounted(packageName)) return
|
||||
|
||||
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
|
||||
?: throw Exception("Failed to load application info")
|
||||
val patchedAPK = "$modulesPath/$packageName-revanced/$packageName.apk"
|
||||
withContext(Dispatchers.IO) {
|
||||
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
|
||||
?: throw Exception("Failed to load application info")
|
||||
val patchedAPK = "$modulesPath/$packageName-revanced/$packageName.apk"
|
||||
|
||||
Shell.cmd("mount -o bind \"$patchedAPK\" \"$stockAPK\"").exec()
|
||||
.also { if (!it.isSuccess) throw Exception("Failed to mount APK") }
|
||||
execute("mount -o bind \"$patchedAPK\" \"$stockAPK\"").assertSuccess("Failed to mount APK")
|
||||
}
|
||||
}
|
||||
|
||||
fun unmount(packageName: String) {
|
||||
suspend fun unmount(packageName: String) {
|
||||
if (!isAppMounted(packageName)) return
|
||||
|
||||
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
|
||||
?: throw Exception("Failed to load application info")
|
||||
withContext(Dispatchers.IO) {
|
||||
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
|
||||
?: throw Exception("Failed to load application info")
|
||||
|
||||
Shell.cmd("umount -l \"$stockAPK\"").exec()
|
||||
.also { if (!it.isSuccess) throw Exception("Failed to unmount APK") }
|
||||
execute("umount -l \"$stockAPK\"").assertSuccess("Failed to unmount APK")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun install(
|
||||
@ -52,80 +96,77 @@ class RootInstaller(
|
||||
packageName: String,
|
||||
version: String,
|
||||
label: String
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
rootConnection.remoteFS?.let { remoteFS ->
|
||||
val assets = app.assets
|
||||
val modulePath = "$modulesPath/$packageName-revanced"
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val remoteFS = awaitRemoteFS()
|
||||
val assets = app.assets
|
||||
val modulePath = "$modulesPath/$packageName-revanced"
|
||||
|
||||
unmount(packageName)
|
||||
unmount(packageName)
|
||||
|
||||
stockAPK?.let { stockApp ->
|
||||
pm.getPackageInfo(packageName)?.let { packageInfo ->
|
||||
if (packageInfo.versionName <= version)
|
||||
Shell.cmd("pm uninstall -k --user 0 $packageName").exec()
|
||||
.also { if (!it.isSuccess) throw Exception("Failed to uninstall stock app") }
|
||||
stockAPK?.let { stockApp ->
|
||||
pm.getPackageInfo(packageName)?.let { packageInfo ->
|
||||
if (packageInfo.versionName <= version)
|
||||
execute("pm uninstall -k --user 0 $packageName").assertSuccess("Failed to uninstall stock app")
|
||||
}
|
||||
|
||||
execute("pm install \"${stockApp.absolutePath}\"").assertSuccess("Failed to install stock app")
|
||||
}
|
||||
|
||||
remoteFS.getFile(modulePath).mkdir()
|
||||
|
||||
listOf(
|
||||
"service.sh",
|
||||
"module.prop",
|
||||
).forEach { file ->
|
||||
assets.open("root/$file").use { inputStream ->
|
||||
remoteFS.getFile("$modulePath/$file").newOutputStream()
|
||||
.use { outputStream ->
|
||||
val content = String(inputStream.readBytes())
|
||||
.replace("__PKG_NAME__", packageName)
|
||||
.replace("__VERSION__", version)
|
||||
.replace("__LABEL__", label)
|
||||
.toByteArray()
|
||||
|
||||
outputStream.write(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Shell.cmd("pm install \"${stockApp.absolutePath}\"").exec()
|
||||
.also { if (!it.isSuccess) throw Exception("Failed to install stock app") }
|
||||
}
|
||||
"$modulePath/$packageName.apk".let { apkPath ->
|
||||
|
||||
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)
|
||||
}
|
||||
remoteFS.getFile(patchedAPK.absolutePath)
|
||||
.also { if (!it.exists()) throw Exception("File doesn't exist") }
|
||||
.newInputStream().use { inputStream ->
|
||||
remoteFS.getFile(apkPath).newOutputStream().use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
"$modulePath/$packageName.apk".let { apkPath ->
|
||||
|
||||
remoteFS.getFile(patchedAPK.absolutePath)
|
||||
.also { if (!it.exists()) throw Exception("File doesn't exist") }
|
||||
.newInputStream().use { inputStream ->
|
||||
remoteFS.getFile(apkPath).newOutputStream().use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
execute(
|
||||
"chmod 644 $apkPath",
|
||||
"chown system:system $apkPath",
|
||||
"chcon u:object_r:apk_data_file:s0 $apkPath",
|
||||
"chmod +x $modulePath/service.sh"
|
||||
).assertSuccess("Failed to set file permissions")
|
||||
}
|
||||
}
|
||||
|
||||
fun uninstall(packageName: String) {
|
||||
rootConnection.remoteFS?.let { remoteFS ->
|
||||
if (isAppMounted(packageName))
|
||||
unmount(packageName)
|
||||
suspend fun uninstall(packageName: String) {
|
||||
val remoteFS = awaitRemoteFS()
|
||||
if (isAppMounted(packageName))
|
||||
unmount(packageName)
|
||||
|
||||
remoteFS.getFile("$modulesPath/$packageName-revanced").deleteRecursively()
|
||||
.also { if (!it) throw Exception("Failed to delete files") }
|
||||
} ?: throw RootServiceException()
|
||||
remoteFS.getFile("$modulesPath/$packageName-revanced").deleteRecursively()
|
||||
.also { if (!it) throw Exception("Failed to delete files") }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val modulesPath = "/data/adb/modules"
|
||||
|
||||
private fun Shell.Result.assertSuccess(errorMessage: String) {
|
||||
if (!isSuccess) throw Exception(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RootServiceException: Exception("Root not available")
|
||||
class RootServiceException : Exception("Root not available")
|
@ -43,21 +43,23 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
|
||||
|
||||
suspend fun regenerate() = withContext(Dispatchers.Default) {
|
||||
val ks = ApkSigner.newKeyStore(
|
||||
listOf(
|
||||
setOf(
|
||||
ApkSigner.KeyStoreEntry(
|
||||
DEFAULT, DEFAULT
|
||||
)
|
||||
)
|
||||
)
|
||||
keystorePath.outputStream().use {
|
||||
ks.store(it, null)
|
||||
withContext(Dispatchers.IO) {
|
||||
keystorePath.outputStream().use {
|
||||
ks.store(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
updatePrefs(DEFAULT, DEFAULT)
|
||||
}
|
||||
|
||||
suspend fun import(cn: String, pass: String, keystore: InputStream): Boolean {
|
||||
val keystoreData = keystore.readBytes()
|
||||
val keystoreData = withContext(Dispatchers.IO) { keystore.readBytes() }
|
||||
|
||||
try {
|
||||
val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null)
|
||||
|
@ -13,7 +13,8 @@ class PreferencesManager(
|
||||
val api = stringPreference("api_url", "https://api.revanced.app")
|
||||
|
||||
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 keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
|
||||
@ -22,7 +23,10 @@ class PreferencesManager(
|
||||
|
||||
val firstLaunch = booleanPreference("first_launch", true)
|
||||
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
|
||||
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
|
||||
|
||||
val disablePatchVersionCompatCheck = booleanPreference("disable_patch_version_compatibility_check", false)
|
||||
val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
|
||||
val 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>(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
protected val default: T
|
||||
val default: T
|
||||
) {
|
||||
internal abstract fun Preferences.read(): 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.InstallType
|
||||
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
|
||||
|
||||
class InstalledAppRepository(
|
||||
@ -16,7 +16,7 @@ class InstalledAppRepository(
|
||||
|
||||
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() }
|
||||
|
||||
suspend fun addOrUpdate(
|
||||
@ -24,7 +24,7 @@ class InstalledAppRepository(
|
||||
originalPackageName: String,
|
||||
version: String,
|
||||
installType: InstallType,
|
||||
patchesSelection: PatchesSelection
|
||||
patchSelection: PatchSelection
|
||||
) {
|
||||
dao.upsertApp(
|
||||
InstalledApp(
|
||||
@ -33,7 +33,7 @@ class InstalledAppRepository(
|
||||
version = version,
|
||||
installType = installType
|
||||
),
|
||||
patchesSelection.flatMap { (uid, patches) ->
|
||||
patchSelection.flatMap { (uid, patches) ->
|
||||
patches.map { patch ->
|
||||
AppliedPatch(
|
||||
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.Source
|
||||
import app.revanced.manager.data.room.bundles.VersionInfo
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
class PatchBundlePersistenceRepository(db: AppDatabase) {
|
||||
@ -23,7 +22,6 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
|
||||
|
||||
suspend fun reset() = dao.reset()
|
||||
|
||||
|
||||
suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) =
|
||||
PatchBundleEntity(
|
||||
uid = generateUid(),
|
||||
@ -37,17 +35,19 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
|
||||
|
||||
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)
|
||||
|
||||
suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value)
|
||||
|
||||
suspend fun setName(uid: Int, name: String) = dao.setName(uid, name)
|
||||
|
||||
fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged()
|
||||
|
||||
private companion object {
|
||||
val defaultSource = PatchBundleEntity(
|
||||
uid = 0,
|
||||
name = "Main",
|
||||
name = "",
|
||||
versionInfo = VersionInfo(),
|
||||
source = Source.API,
|
||||
autoUpdate = false
|
||||
|
@ -3,6 +3,7 @@ package app.revanced.manager.domain.repository
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import app.revanced.library.PatchUtils
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.data.platform.NetworkInfo
|
||||
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.RemotePatchBundle
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.util.flatMapLatestAndCombine
|
||||
import app.revanced.manager.util.tag
|
||||
import app.revanced.manager.util.uiSafe
|
||||
@ -29,6 +32,7 @@ class PatchBundleRepository(
|
||||
private val app: Application,
|
||||
private val persistenceRepo: PatchBundlePersistenceRepository,
|
||||
private val networkInfo: NetworkInfo,
|
||||
private val prefs: PreferencesManager,
|
||||
) {
|
||||
private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
|
||||
|
||||
@ -47,6 +51,37 @@ class PatchBundleRepository(
|
||||
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.
|
||||
*/
|
||||
@ -102,16 +137,16 @@ class PatchBundleRepository(
|
||||
private fun addBundle(patchBundle: PatchBundleSource) =
|
||||
_sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
|
||||
|
||||
suspend fun createLocal(name: String, patches: InputStream, integrations: InputStream?) {
|
||||
val id = persistenceRepo.create(name, SourceInfo.Local).uid
|
||||
val bundle = LocalPatchBundle(name, id, directoryOf(id))
|
||||
suspend fun createLocal(patches: InputStream, integrations: InputStream?) = withContext(Dispatchers.Default) {
|
||||
val uid = persistenceRepo.create("", SourceInfo.Local).uid
|
||||
val bundle = LocalPatchBundle("", uid, directoryOf(uid))
|
||||
|
||||
bundle.replace(patches, integrations)
|
||||
addBundle(bundle)
|
||||
}
|
||||
|
||||
suspend fun createRemote(name: String, url: String, autoUpdate: Boolean) {
|
||||
val entity = persistenceRepo.create(name, SourceInfo.from(url), autoUpdate)
|
||||
suspend fun createRemote(url: String, autoUpdate: Boolean) = withContext(Dispatchers.Default) {
|
||||
val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate)
|
||||
addBundle(entity.load())
|
||||
}
|
||||
|
||||
@ -139,8 +174,8 @@ class PatchBundleRepository(
|
||||
|
||||
getBundlesByType<RemotePatchBundle>().forEach {
|
||||
launch {
|
||||
if (!it.propsFlow().first().autoUpdate) return@launch
|
||||
Log.d(tag, "Updating patch bundle: ${it.name}")
|
||||
if (!it.getProps().autoUpdate) return@launch
|
||||
Log.d(tag, "Updating patch bundle: ${it.getName()}")
|
||||
it.update()
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,14 @@
|
||||
package app.revanced.manager.domain.repository
|
||||
|
||||
import android.util.Log
|
||||
import app.revanced.manager.data.room.AppDatabase
|
||||
import app.revanced.manager.data.room.options.Option
|
||||
import app.revanced.manager.data.room.options.OptionGroup
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.tag
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
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) {
|
||||
private val dao = db.optionDao()
|
||||
@ -24,19 +20,37 @@ class PatchOptionsRepository(db: AppDatabase) {
|
||||
packageName = packageName
|
||||
).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)
|
||||
// Bundle -> Patches
|
||||
return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) {
|
||||
options.forEach { (sourceUid, bundlePatchOptionsList) ->
|
||||
// Patches -> Patch options
|
||||
this[sourceUid] = bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, option ->
|
||||
val patchOptions = bundlePatchOptions.getOrPut(option.patchName, ::mutableMapOf)
|
||||
this[sourceUid] =
|
||||
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) ->
|
||||
patchOptions.mapNotNull { (key, value) ->
|
||||
val serialized = serialize(value)
|
||||
?: return@mapNotNull null // Don't save options that we can't serialize.
|
||||
val serialized = try {
|
||||
Option.SerializedValue.fromValue(value)
|
||||
} catch (e: Option.SerializationException) {
|
||||
Log.e(tag, "Option $patchName:$key could not be serialized", e)
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
Option(groupId, patchName, key, serialized)
|
||||
}
|
||||
@ -61,29 +79,4 @@ class PatchOptionsRepository(db: AppDatabase) {
|
||||
suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
|
||||
suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
|
||||
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
|
||||
|
||||
import android.os.Build
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.network.dto.ReVancedRelease
|
||||
import app.revanced.manager.network.service.ReVancedService
|
||||
import app.revanced.manager.network.utils.getOrThrow
|
||||
import app.revanced.manager.network.utils.transform
|
||||
|
||||
class ReVancedAPI(
|
||||
@ -13,12 +15,23 @@ class ReVancedAPI(
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
withId = "content"
|
||||
findFirst {
|
||||
@ -66,7 +66,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
||||
}
|
||||
|
||||
return searchResults.find { url ->
|
||||
httpClient.getHtml { url(apkMirror + url) }
|
||||
httpClient.getHtml { url(APK_MIRROR + url) }
|
||||
.div {
|
||||
withId = "primary"
|
||||
findFirst {
|
||||
@ -90,11 +90,12 @@ class APKMirror : AppDownloader, KoinComponent {
|
||||
|
||||
override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow<AppDownloader.App> {
|
||||
|
||||
// Vanced music uses the same package name so we have to hardcode...
|
||||
val appCategory = if (packageName == "com.google.android.apps.youtube.music")
|
||||
"youtube-music"
|
||||
else
|
||||
getAppLink(packageName).split("/")[3]
|
||||
// We have to hardcode some apps since there are multiple apps with that package name
|
||||
val appCategory = when (packageName) {
|
||||
"com.google.android.apps.youtube.music" -> "youtube-music"
|
||||
"com.google.android.youtube" -> "youtube"
|
||||
else -> getAppLink(packageName).split("/")[3]
|
||||
}
|
||||
|
||||
var page = 1
|
||||
|
||||
@ -107,7 +108,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
||||
page <= 1
|
||||
) {
|
||||
httpClient.getHtml {
|
||||
url("$apkMirror/uploads/page/$page/")
|
||||
url("$APK_MIRROR/uploads/page/$page/")
|
||||
parameter("appcategory", appCategory)
|
||||
}.div {
|
||||
withClass = "widget_appmanager_recentpostswidget"
|
||||
@ -172,7 +173,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
||||
preferSplit: Boolean,
|
||||
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit
|
||||
) {
|
||||
val variants = httpClient.getHtml { url(apkMirror + downloadLink) }
|
||||
val variants = httpClient.getHtml { url(APK_MIRROR + downloadLink) }
|
||||
.div {
|
||||
withClass = "variants-table"
|
||||
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
|
||||
|
||||
val downloadPage = httpClient.getHtml { url(apkMirror + variant.link) }
|
||||
val downloadPage = httpClient.getHtml { url(APK_MIRROR + variant.link) }
|
||||
.a {
|
||||
withClass = "downloadButton"
|
||||
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 {
|
||||
withId = "filedownload"
|
||||
findFirst {
|
||||
@ -250,7 +251,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
||||
|
||||
try {
|
||||
httpClient.download(targetFile) {
|
||||
url(apkMirror + downloadLink)
|
||||
url(APK_MIRROR + downloadLink)
|
||||
onDownload { bytesSentTotal, contentLength ->
|
||||
onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
|
||||
}
|
||||
@ -268,7 +269,7 @@ class APKMirror : AppDownloader, KoinComponent {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val apkMirror = "https://www.apkmirror.com"
|
||||
const val APK_MIRROR = "https://www.apkmirror.com"
|
||||
|
||||
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(
|
||||
val metadata: ReVancedReleaseMeta,
|
||||
val assets: List<Asset>
|
||||
)
|
||||
) {
|
||||
val version get() = metadata.tag
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ReVancedReleaseMeta(
|
||||
|
@ -1,10 +1,12 @@
|
||||
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.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.utils.APIResponse
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.request.url
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ -31,4 +33,11 @@ class ReVancedService(
|
||||
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
|
||||
|
||||
import app.revanced.library.ApkUtils
|
||||
import app.revanced.manager.ui.viewmodel.ManagerLogger
|
||||
import android.content.Context
|
||||
import app.revanced.library.ApkUtils.applyTo
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.patcher.logger.Logger
|
||||
import app.revanced.manager.ui.model.State
|
||||
import app.revanced.patcher.Patcher
|
||||
import app.revanced.patcher.PatcherOptions
|
||||
import app.revanced.patcher.PatcherConfig
|
||||
import app.revanced.patcher.patch.Patch
|
||||
import app.revanced.patcher.patch.PatchResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -12,7 +15,6 @@ import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.logging.Logger
|
||||
|
||||
internal typealias PatchList = List<Patch<*>>
|
||||
|
||||
@ -21,67 +23,108 @@ class Session(
|
||||
frameworkDir: String,
|
||||
aaptPath: String,
|
||||
multithreadingDexFileWriter: Boolean,
|
||||
private val logger: ManagerLogger,
|
||||
private val androidContext: Context,
|
||||
private val logger: Logger,
|
||||
private val input: File,
|
||||
private val onStepSucceeded: suspend () -> Unit
|
||||
private val onPatchCompleted: () -> Unit,
|
||||
private val onProgress: (name: String?, state: State?, message: String?) -> Unit
|
||||
) : Closeable {
|
||||
private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
|
||||
onProgress(name, state, message)
|
||||
|
||||
private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
|
||||
private val patcher = Patcher(
|
||||
PatcherOptions(
|
||||
inputFile = input,
|
||||
resourceCachePath = tempDir.resolve("aapt-resources"),
|
||||
PatcherConfig(
|
||||
apkFile = input,
|
||||
temporaryFilesPath = tempDir,
|
||||
frameworkFileDirectory = frameworkDir,
|
||||
aaptBinaryPath = aaptPath,
|
||||
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) ->
|
||||
if (exception == null) {
|
||||
logger.info("$patch succeeded")
|
||||
onStepSucceeded()
|
||||
return@collect
|
||||
if (patch !in selectedPatches) return@collect
|
||||
|
||||
if (exception != null) {
|
||||
updateProgress(
|
||||
name = androidContext.getString(R.string.failed_to_execute_patch, patch.name),
|
||||
state = State.FAILED,
|
||||
message = exception.stackTraceToString()
|
||||
)
|
||||
|
||||
logger.error("${patch.name} failed:")
|
||||
logger.error(exception.stackTraceToString())
|
||||
throw exception
|
||||
}
|
||||
logger.error("$patch failed:")
|
||||
logger.error(exception.stackTraceToString())
|
||||
throw exception
|
||||
|
||||
nextPatchIndex++
|
||||
|
||||
onPatchCompleted()
|
||||
|
||||
selectedPatches.getOrNull(nextPatchIndex)?.let { nextPatch ->
|
||||
updateProgress(
|
||||
name = androidContext.getString(R.string.executing_patch, nextPatch.name)
|
||||
)
|
||||
}
|
||||
|
||||
logger.info("${patch.name} succeeded")
|
||||
}
|
||||
|
||||
updateProgress(
|
||||
state = State.COMPLETED,
|
||||
name = androidContext.resources.getQuantityString(
|
||||
R.plurals.patches_executed,
|
||||
selectedPatches.size,
|
||||
selectedPatches.size
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun run(output: File, selectedPatches: PatchList, integrations: List<File>) {
|
||||
onStepSucceeded() // Unpacking
|
||||
Logger.getLogger("").apply {
|
||||
updateProgress(state = State.COMPLETED) // Unpacking
|
||||
|
||||
java.util.logging.Logger.getLogger("").apply {
|
||||
handlers.forEach {
|
||||
it.close()
|
||||
removeHandler(it)
|
||||
}
|
||||
|
||||
addHandler(logger)
|
||||
addHandler(logger.handler)
|
||||
}
|
||||
|
||||
with(patcher) {
|
||||
logger.info("Merging integrations")
|
||||
acceptIntegrations(integrations)
|
||||
acceptPatches(selectedPatches)
|
||||
onStepSucceeded() // Merging
|
||||
acceptIntegrations(integrations.toSet())
|
||||
acceptPatches(selectedPatches.toSet())
|
||||
|
||||
logger.info("Applying patches...")
|
||||
applyPatchesVerbose()
|
||||
applyPatchesVerbose(selectedPatches.sortedBy { it.name })
|
||||
}
|
||||
|
||||
logger.info("Writing patched files...")
|
||||
val result = patcher.get()
|
||||
|
||||
val aligned = tempDir.resolve("aligned.apk")
|
||||
ApkUtils.copyAligned(input, aligned, result)
|
||||
val patched = tempDir.resolve("result.apk")
|
||||
withContext(Dispatchers.IO) {
|
||||
Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
result.applyTo(patched)
|
||||
|
||||
logger.info("Patched apk saved to $aligned")
|
||||
logger.info("Patched apk saved to $patched")
|
||||
|
||||
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() {
|
||||
@ -90,7 +133,7 @@ class Session(
|
||||
}
|
||||
|
||||
companion object {
|
||||
operator fun PatchResult.component1() = patch.name
|
||||
operator fun PatchResult.component1() = patch
|
||||
operator fun PatchResult.component2() = exception
|
||||
}
|
||||
}
|
@ -1,18 +1,12 @@
|
||||
package app.revanced.manager.patcher.aapt
|
||||
|
||||
import android.content.Context
|
||||
import app.revanced.manager.patcher.LibraryResolver
|
||||
import android.os.Build.SUPPORTED_ABIS as DEVICE_ABIS
|
||||
import java.io.File
|
||||
|
||||
object Aapt {
|
||||
private val WORKING_ABIS = setOf("arm64-v8a", "x86", "x86_64")
|
||||
object Aapt : LibraryResolver() {
|
||||
private val WORKING_ABIS = setOf("arm64-v8a", "x86", "x86_64", "armeabi-v7a")
|
||||
|
||||
fun supportsDevice() = (DEVICE_ABIS intersect WORKING_ABIS).isNotEmpty()
|
||||
|
||||
fun binary(context: Context): File? {
|
||||
return File(context.applicationInfo.nativeLibraryDir).resolveAapt()
|
||||
}
|
||||
fun binary(context: Context) = findLibrary(context, "aapt")
|
||||
}
|
||||
|
||||
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.patch.Patch
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.jar.JarFile
|
||||
|
||||
class PatchBundle(private val loader: Iterable<Patch<*>>, val integrations: File?) {
|
||||
constructor(bundleJar: File, integrations: File?) : this(
|
||||
object : Iterable<Patch<*>> {
|
||||
private fun load(): Iterable<Patch<*>> {
|
||||
bundleJar.setReadOnly()
|
||||
return PatchBundleLoader.Dex(bundleJar, optimizedDexDirectory = null)
|
||||
}
|
||||
class PatchBundle(val patchesJar: File, val integrations: File?) {
|
||||
private val loader = object : Iterable<Patch<*>> {
|
||||
private fun load(): Iterable<Patch<*>> {
|
||||
patchesJar.setReadOnly()
|
||||
return PatchBundleLoader.Dex(patchesJar, optimizedDexDirectory = null)
|
||||
}
|
||||
|
||||
override fun iterator(): Iterator<Patch<*>> = load().iterator()
|
||||
},
|
||||
integrations
|
||||
) {
|
||||
Log.d(tag, "Loaded patch bundle: $bundleJar")
|
||||
override fun iterator(): Iterator<Patch<*>> = load().iterator()
|
||||
}
|
||||
|
||||
init {
|
||||
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)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
@ -1,7 +1,9 @@
|
||||
package app.revanced.manager.patcher.patch
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import app.revanced.patcher.data.ResourceContext
|
||||
import app.revanced.patcher.patch.Patch
|
||||
import app.revanced.patcher.patch.ResourcePatch
|
||||
import app.revanced.patcher.patch.options.PatchOption
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
@ -13,7 +15,7 @@ data class PatchInfo(
|
||||
val description: String?,
|
||||
val include: Boolean,
|
||||
val compatiblePackages: ImmutableList<CompatiblePackage>?,
|
||||
val options: ImmutableList<Option>?
|
||||
val options: ImmutableList<Option<*>>?
|
||||
) {
|
||||
constructor(patch: Patch<*>) : this(
|
||||
patch.name.orEmpty(),
|
||||
@ -37,6 +39,23 @@ data class PatchInfo(
|
||||
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
|
||||
@ -48,23 +67,35 @@ data class CompatiblePackage(
|
||||
pkg.name,
|
||||
pkg.versions?.toImmutableSet()
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts this [CompatiblePackage] into a [Patch.CompatiblePackage] from patcher.
|
||||
*/
|
||||
fun toPatcherCompatiblePackage() = Patch.CompatiblePackage(
|
||||
name = packageName,
|
||||
versions = versions,
|
||||
)
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Option(
|
||||
data class Option<T>(
|
||||
val title: String,
|
||||
val key: String,
|
||||
val description: String,
|
||||
val required: Boolean,
|
||||
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.key,
|
||||
option.description.orEmpty(),
|
||||
option.required,
|
||||
option.valueType,
|
||||
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.repository.DownloadedAppRepository
|
||||
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.WorkerRepository
|
||||
import app.revanced.manager.patcher.Session
|
||||
import app.revanced.manager.patcher.aapt.Aapt
|
||||
import app.revanced.manager.patcher.logger.Logger
|
||||
import app.revanced.manager.patcher.runtime.CoroutineRuntime
|
||||
import app.revanced.manager.patcher.runtime.ProcessRuntime
|
||||
import app.revanced.manager.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.PM
|
||||
import app.revanced.manager.util.PatchesSelection
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
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.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit
|
||||
|
||||
class PatcherWorker(
|
||||
context: Context,
|
||||
parameters: WorkerParameters
|
||||
) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent {
|
||||
|
||||
private val patchBundleRepository: PatchBundleRepository by inject()
|
||||
private val workerRepository: WorkerRepository by inject()
|
||||
private val prefs: PreferencesManager by inject()
|
||||
private val keystoreManager: KeystoreManager by inject()
|
||||
@ -61,20 +58,17 @@ class PatcherWorker(
|
||||
data class Args(
|
||||
val input: SelectedApp,
|
||||
val output: String,
|
||||
val selectedPatches: PatchesSelection,
|
||||
val selectedPatches: PatchSelection,
|
||||
val options: Options,
|
||||
val progress: MutableStateFlow<ImmutableList<Step>>,
|
||||
val logger: ManagerLogger,
|
||||
val setInputFile: (File) -> Unit
|
||||
val logger: Logger,
|
||||
val downloadProgress: MutableStateFlow<Pair<Float, Float>?>,
|
||||
val patchesProgress: MutableStateFlow<Pair<Int, Int>>,
|
||||
val setInputFile: (File) -> Unit,
|
||||
val onProgress: ProgressEventHandler
|
||||
) {
|
||||
val packageName get() = input.packageName
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val logPrefix = "[Worker]:"
|
||||
private fun String.logFmt() = "$logPrefix $this"
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo() =
|
||||
ForegroundInfo(
|
||||
1,
|
||||
@ -107,8 +101,6 @@ class PatcherWorker(
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val args = workerRepository.claimInput(this)
|
||||
|
||||
try {
|
||||
// This does not always show up for some reason.
|
||||
setForeground(getForegroundInfo())
|
||||
@ -117,12 +109,14 @@ class PatcherWorker(
|
||||
}
|
||||
|
||||
val wakeLock: PowerManager.WakeLock =
|
||||
(applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher").apply {
|
||||
(applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher")
|
||||
.apply {
|
||||
acquire(10 * 60 * 1000L)
|
||||
Log.d(tag, "Acquired wakelock.")
|
||||
}
|
||||
}
|
||||
|
||||
val args = workerRepository.claimInput(this)
|
||||
|
||||
return try {
|
||||
runPatcher(args)
|
||||
@ -132,39 +126,13 @@ class PatcherWorker(
|
||||
}
|
||||
|
||||
private suspend fun runPatcher(args: Args): Result {
|
||||
val aaptPath =
|
||||
Aapt.binary(applicationContext)?.absolutePath
|
||||
?: throw FileNotFoundException("Could not resolve aapt.")
|
||||
|
||||
val frameworkPath =
|
||||
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
|
||||
|
||||
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()
|
||||
}
|
||||
fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
|
||||
args.onProgress(name, state, message)
|
||||
|
||||
val patchedApk = fs.tempDir.resolve("patched.apk")
|
||||
|
||||
return try {
|
||||
|
||||
if (args.input is SelectedApp.Installed) {
|
||||
installedAppRepository.get(args.packageName)?.let {
|
||||
if (it.installType == InstallType.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) {
|
||||
is SelectedApp.Download -> {
|
||||
downloadedAppRepository.download(
|
||||
selectedApp.app,
|
||||
prefs.preferSplits.get(),
|
||||
onDownload = { downloadProgress.emit(it) }
|
||||
onDownload = { args.downloadProgress.emit(it) }
|
||||
).also {
|
||||
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)
|
||||
}
|
||||
|
||||
Session(
|
||||
fs.tempDir.absolutePath,
|
||||
frameworkPath,
|
||||
aaptPath,
|
||||
prefs.multithreadingDexFileWriter.get(),
|
||||
args.logger,
|
||||
inputFile,
|
||||
onStepSucceeded = ::updateProgress
|
||||
).use { session ->
|
||||
session.run(patchedApk, patches, integrations)
|
||||
val runtime = if (prefs.useProcessRuntime.get()) {
|
||||
ProcessRuntime(applicationContext)
|
||||
} else {
|
||||
CoroutineRuntime(applicationContext)
|
||||
}
|
||||
|
||||
runtime.execute(
|
||||
inputFile.absolutePath,
|
||||
patchedApk.absolutePath,
|
||||
args.packageName,
|
||||
args.selectedPatches,
|
||||
args.options,
|
||||
args.logger,
|
||||
onPatchCompleted = {
|
||||
args.patchesProgress.update { (completed, total) ->
|
||||
completed + 1 to total
|
||||
}
|
||||
},
|
||||
args.onProgress
|
||||
)
|
||||
|
||||
keystoreManager.sign(patchedApk, File(args.output))
|
||||
updateProgress() // Signing
|
||||
updateProgress(state = State.COMPLETED) // Signing
|
||||
|
||||
Log.i(tag, "Patching succeeded".logFmt())
|
||||
progressManager.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) {
|
||||
Log.e(tag, "Exception while patching".logFmt(), e)
|
||||
progressManager.failure(e)
|
||||
Log.e(tag, "An exception occurred while patching".logFmt(), e)
|
||||
updateProgress(state = State.FAILED, message = e.stackTraceToString())
|
||||
Result.failure()
|
||||
} finally {
|
||||
updateProgress(false)
|
||||
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
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import app.revanced.manager.IRootSystemService
|
||||
import com.topjohnwu.superuser.ipc.RootService
|
||||
@ -14,23 +12,5 @@ class ManagerRootService : RootService() {
|
||||
FileSystemManager.getService()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
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
|
||||
}
|
||||
override fun onBind(intent: Intent): IBinder = RootSystemService()
|
||||
}
|
@ -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.vector.rememberVectorPainter
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.accompanist.placeholder.placeholder
|
||||
import io.github.fornewid.placeholder.material3.placeholder
|
||||
|
||||
@Composable
|
||||
fun AppIcon(
|
||||
@ -33,11 +33,9 @@ fun AppIcon(
|
||||
Image(
|
||||
image,
|
||||
contentDescription,
|
||||
Modifier.placeholder(visible = showPlaceHolder, color = MaterialTheme.colorScheme.inverseOnSurface, shape = RoundedCornerShape(100)).then(modifier),
|
||||
modifier,
|
||||
colorFilter = colorFilter
|
||||
)
|
||||
|
||||
showPlaceHolder = false
|
||||
} else {
|
||||
AsyncImage(
|
||||
packageInfo,
|
||||
|
@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import app.revanced.manager.R
|
||||
import com.google.accompanist.placeholder.placeholder
|
||||
import io.github.fornewid.placeholder.material3.placeholder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.RowScope
|
||||
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.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -38,7 +38,7 @@ fun AppTopBar(
|
||||
onBackClick: (() -> Unit)? = null,
|
||||
backIcon: @Composable (() -> Unit) = @Composable {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack, contentDescription = stringResource(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(
|
||||
R.string.back
|
||||
)
|
||||
)
|
||||
|
@ -13,17 +13,34 @@ import androidx.compose.ui.res.stringResource
|
||||
import app.revanced.manager.R
|
||||
|
||||
@Composable
|
||||
fun ArrowButton(modifier: Modifier = Modifier, expanded: Boolean,onClick: () -> Unit) {
|
||||
IconButton(onClick = onClick) {
|
||||
val description = if (expanded) R.string.collapse_content else R.string.expand_content
|
||||
val rotation by animateFloatAsState(targetValue = if (expanded) 0f else 180f, label = "rotation")
|
||||
fun ArrowButton(
|
||||
modifier: Modifier = Modifier,
|
||||
expanded: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
rotationInitial: Float = 0f,
|
||||
rotationFinal: Float = 180f
|
||||
) {
|
||||
val description = if (expanded) R.string.collapse_content else R.string.expand_content
|
||||
val rotation by animateFloatAsState(
|
||||
targetValue = if (expanded) rotationInitial else rotationFinal,
|
||||
label = "rotation"
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = stringResource(description),
|
||||
modifier = Modifier
|
||||
.rotate(rotation)
|
||||
.then(modifier)
|
||||
)
|
||||
}
|
||||
onClick?.let {
|
||||
IconButton(onClick = it) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = stringResource(description),
|
||||
modifier = Modifier
|
||||
.rotate(rotation)
|
||||
.then(modifier)
|
||||
)
|
||||
}
|
||||
} ?: Icon(
|
||||
imageVector = Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = stringResource(description),
|
||||
modifier = Modifier
|
||||
.rotate(rotation)
|
||||
.then(modifier)
|
||||
)
|
||||
}
|
@ -4,16 +4,14 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Source
|
||||
import androidx.compose.material.icons.outlined.Update
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@ -21,11 +19,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.revanced.manager.R
|
||||
|
||||
@ -37,52 +33,35 @@ fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onSubmit(managerEnabled, patchesEnabled) }
|
||||
) {
|
||||
TextButton(onClick = { onSubmit(managerEnabled, patchesEnabled) }) {
|
||||
Text(stringResource(R.string.save))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(Icons.Outlined.Update, null)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.auto_updates_dialog_title),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
icon = { Icon(Icons.Outlined.Update, null) },
|
||||
title = { Text(text = stringResource(R.string.auto_updates_dialog_title)) },
|
||||
text = {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.auto_updates_dialog_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(text = stringResource(R.string.auto_updates_dialog_description))
|
||||
|
||||
AutoUpdatesItem(
|
||||
headline = R.string.auto_updates_dialog_manager,
|
||||
icon = Icons.Outlined.Update,
|
||||
checked = managerEnabled,
|
||||
onCheckedChange = { managerEnabled = it }
|
||||
)
|
||||
Divider()
|
||||
AutoUpdatesItem(
|
||||
headline = R.string.auto_updates_dialog_patches,
|
||||
icon = Icons.Outlined.Source,
|
||||
checked = patchesEnabled,
|
||||
onCheckedChange = { patchesEnabled = it }
|
||||
)
|
||||
Column {
|
||||
AutoUpdatesItem(
|
||||
headline = R.string.auto_updates_dialog_manager,
|
||||
icon = Icons.Outlined.Update,
|
||||
checked = managerEnabled,
|
||||
onCheckedChange = { managerEnabled = it }
|
||||
)
|
||||
HorizontalDivider()
|
||||
AutoUpdatesItem(
|
||||
headline = R.string.auto_updates_dialog_patches,
|
||||
icon = Icons.Outlined.Source,
|
||||
checked = patchesEnabled,
|
||||
onCheckedChange = { patchesEnabled = it }
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.auto_updates_dialog_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(text = stringResource(R.string.auto_updates_dialog_note))
|
||||
}
|
||||
}
|
||||
)
|
||||
@ -94,22 +73,9 @@ private fun AutoUpdatesItem(
|
||||
icon: ImageVector,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurface) },
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(headline),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onCheckedChange(!checked) }
|
||||
)
|
||||
}
|
||||
) = ListItem(
|
||||
leadingContent = { Icon(icon, null) },
|
||||
headlineContent = { Text(stringResource(headline)) },
|
||||
trailingContent = { Checkbox(checked = checked, onCheckedChange = null) },
|
||||
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
|
||||
|
||||
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.Text
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
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
|
||||
fun LoadingIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
progress: Float? = null,
|
||||
text: String? = null
|
||||
progress: () -> Float? = { null },
|
||||
color: Color = ProgressIndicatorDefaults.circularColor,
|
||||
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
|
||||
trackColor: Color = ProgressIndicatorDefaults.circularTrackColor,
|
||||
strokeCap: StrokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
text?.let { Text(text) }
|
||||
|
||||
progress?.let {
|
||||
CircularProgressIndicator(
|
||||
progress = progress,
|
||||
modifier = Modifier.padding(vertical = 16.dp).then(modifier)
|
||||
)
|
||||
} ?:
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(vertical = 16.dp).then(modifier)
|
||||
)
|
||||
}
|
||||
progress()?.let {
|
||||
CircularProgressIndicator(
|
||||
progress = { it },
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
strokeWidth = strokeWidth,
|
||||
trackColor = trackColor,
|
||||
strokeCap = strokeCap
|
||||
)
|
||||
} ?:
|
||||
CircularProgressIndicator(
|
||||
modifier = modifier,
|
||||
color = color,
|
||||
strokeWidth = strokeWidth,
|
||||
trackColor = trackColor,
|
||||
strokeCap = strokeCap
|
||||
)
|
||||
}
|
@ -3,10 +3,9 @@ package app.revanced.manager.ui.component
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.mikepenz.markdown.compose.Markdown
|
||||
import com.mikepenz.markdown.model.markdownColor
|
||||
import com.mikepenz.markdown.model.markdownTypography
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
|
||||
@Composable
|
||||
fun Markdown(
|
||||
@ -19,7 +18,8 @@ fun Markdown(
|
||||
colors = markdownColor(
|
||||
text = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
codeBackground = MaterialTheme.colorScheme.secondaryContainer,
|
||||
codeText = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
codeText = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
linkText = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
typography = markdownTypography(
|
||||
h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),
|
||||
|
@ -1,8 +1,10 @@
|
||||
package app.revanced.manager.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@ -11,7 +13,6 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -27,74 +28,73 @@ import app.revanced.manager.R
|
||||
|
||||
@Composable
|
||||
fun NotificationCard(
|
||||
isWarning: Boolean = false,
|
||||
title: String? = null,
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
actions: (@Composable () -> Unit)?
|
||||
modifier: Modifier = Modifier,
|
||||
actions: (@Composable RowScope.() -> Unit)? = null,
|
||||
title: String? = null,
|
||||
isWarning: Boolean = false
|
||||
) {
|
||||
val color =
|
||||
if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
NotificationCardInstance(isWarning = isWarning) {
|
||||
Column(
|
||||
modifier = Modifier.padding(if (title != null) 20.dp else 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
NotificationCardInstance(modifier = modifier, isWarning = isWarning) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
if (title != null) {
|
||||
Box(
|
||||
modifier = Modifier.size(28.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(36.dp),
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = title,
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = color,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = color,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = color,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
actions?.invoke(this)
|
||||
}
|
||||
}
|
||||
actions?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationCard(
|
||||
isWarning: Boolean = false,
|
||||
title: String? = null,
|
||||
text: String,
|
||||
icon: ImageVector,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
isWarning: Boolean = false,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
primaryAction: (() -> Unit)? = null
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val color =
|
||||
if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
NotificationCardInstance(isWarning = isWarning, onClick = primaryAction) {
|
||||
NotificationCardInstance(modifier = modifier, isWarning = isWarning, onClick = onClick) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -102,12 +102,17 @@ fun NotificationCard(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(if (title != null) 36.dp else 24.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.size(28.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
if (title != null) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
@ -145,32 +150,31 @@ fun NotificationCard(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NotificationCardInstance(
|
||||
modifier: Modifier = Modifier,
|
||||
isWarning: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors =
|
||||
CardDefaults.cardColors(containerColor = if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)
|
||||
val modifier = Modifier
|
||||
val defaultModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
|
||||
if (onClick != null) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
colors = colors,
|
||||
modifier = modifier
|
||||
modifier = modifier.then(defaultModifier)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
Card(
|
||||
colors = colors,
|
||||
modifier = modifier,
|
||||
modifier = modifier.then(defaultModifier)
|
||||
) {
|
||||
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 androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowRight
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
|
||||
import androidx.compose.material.icons.outlined.Extension
|
||||
import androidx.compose.material.icons.outlined.Inventory2
|
||||
import androidx.compose.material.icons.outlined.Sell
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.ui.component.ColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.TextInputDialog
|
||||
import app.revanced.manager.util.isDebuggable
|
||||
|
||||
@Composable
|
||||
fun BaseBundleDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
isDefault: Boolean,
|
||||
name: String,
|
||||
onNameChange: ((String) -> Unit)? = null,
|
||||
name: String?,
|
||||
remoteUrl: String?,
|
||||
onRemoteUrlChange: ((String) -> Unit)? = null,
|
||||
patchCount: Int,
|
||||
@ -42,143 +36,145 @@ fun BaseBundleDialog(
|
||||
autoUpdate: Boolean,
|
||||
onAutoUpdateChange: (Boolean) -> Unit,
|
||||
onPatchesClick: () -> Unit,
|
||||
onBundleTypeClick: () -> 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 {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
if (showNameInputDialog) {
|
||||
TextInputDialog(
|
||||
initial = name,
|
||||
title = stringResource(R.string.bundle_input_name),
|
||||
onDismissRequest = {
|
||||
showNameInputDialog = false
|
||||
},
|
||||
onConfirm = {
|
||||
showNameInputDialog = false
|
||||
onNameChange?.invoke(it)
|
||||
},
|
||||
validator = {
|
||||
it.length in 1..19
|
||||
}
|
||||
)
|
||||
}
|
||||
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
|
||||
ColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(modifier),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Inventory2,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
onAutoUpdateChange(!autoUpdate)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
name?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 2.dp)
|
||||
) {
|
||||
version?.let {
|
||||
Tag(Icons.Outlined.Sell, it)
|
||||
}
|
||||
Tag(Icons.Outlined.Extension, patchCount.toString())
|
||||
}
|
||||
}
|
||||
|
||||
if (version != null || patchCount > 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.information),
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 16.dp,
|
||||
vertical = 12.dp
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
|
||||
val patchesClickable = LocalContext.current.isDebuggable && patchCount > 0
|
||||
BundleListItem(
|
||||
headlineText = stringResource(R.string.patches),
|
||||
supportingText = if (patchCount == 0) stringResource(R.string.no_patches)
|
||||
else stringResource(R.string.patches_available, patchCount),
|
||||
modifier = Modifier.clickable(enabled = patchesClickable, onClick = onPatchesClick)
|
||||
) {
|
||||
if (patchesClickable)
|
||||
Icon(
|
||||
Icons.Outlined.ArrowRight,
|
||||
stringResource(R.string.patches)
|
||||
if (remoteUrl != null) {
|
||||
BundleListItem(
|
||||
headlineText = stringResource(R.string.bundle_auto_update),
|
||||
supportingText = stringResource(R.string.bundle_auto_update_description),
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = autoUpdate,
|
||||
onCheckedChange = onAutoUpdateChange
|
||||
)
|
||||
},
|
||||
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(
|
||||
headlineText = stringResource(R.string.version),
|
||||
supportingText = it,
|
||||
headlineText = stringResource(R.string.patches),
|
||||
supportingText = stringResource(R.string.bundle_view_patches),
|
||||
modifier = Modifier.clickable(
|
||||
enabled = patchesClickable,
|
||||
onClick = onPatchesClick
|
||||
)
|
||||
) {
|
||||
if (patchesClickable) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Outlined.ArrowRight,
|
||||
stringResource(R.string.patches)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extraFields()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Tag(
|
||||
icon: ImageVector,
|
||||
text: String
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
@ -1,33 +1,32 @@
|
||||
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.rememberScrollState
|
||||
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.Refresh
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.material.icons.outlined.Share
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.bundles.LocalPatchBundle
|
||||
import app.revanced.manager.domain.bundles.RemotePatchBundle
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.asRemoteOrNull
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.isDefault
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.propsOrNullFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
|
||||
import app.revanced.manager.ui.component.ColumnWithScrollbar
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -36,17 +35,18 @@ fun BundleInformationDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onDeleteRequest: () -> Unit,
|
||||
bundle: PatchBundleSource,
|
||||
onRefreshButton: () -> Unit,
|
||||
onUpdate: () -> Unit,
|
||||
) {
|
||||
val composableScope = rememberCoroutineScope()
|
||||
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
|
||||
val isLocal = bundle is LocalPatchBundle
|
||||
val patchCount by remember(bundle) {
|
||||
bundle.state.map { it.patchBundleOrNull()?.patches?.size ?: 0 }
|
||||
}.collectAsStateWithLifecycle(0)
|
||||
val state by bundle.state.collectAsStateWithLifecycle()
|
||||
val props by remember(bundle) {
|
||||
bundle.propsOrNullFlow()
|
||||
bundle.propsFlow()
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
val patchCount = remember(state) {
|
||||
state.patchBundleOrNull()?.patches?.size ?: 0
|
||||
}
|
||||
|
||||
if (viewCurrentBundlePatches) {
|
||||
BundlePatchesDialog(
|
||||
@ -64,14 +64,16 @@ fun BundleInformationDialog(
|
||||
dismissOnBackPress = true
|
||||
)
|
||||
) {
|
||||
val bundleName by bundle.nameState
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BundleTopBar(
|
||||
title = bundle.name,
|
||||
title = stringResource(R.string.patch_bundle_field),
|
||||
onBackClick = onDismissRequest,
|
||||
onBackIcon = {
|
||||
backIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
},
|
||||
@ -85,9 +87,9 @@ fun BundleInformationDialog(
|
||||
}
|
||||
}
|
||||
if (!isLocal) {
|
||||
IconButton(onClick = onRefreshButton) {
|
||||
IconButton(onClick = onUpdate) {
|
||||
Icon(
|
||||
Icons.Outlined.Refresh,
|
||||
Icons.Outlined.Update,
|
||||
stringResource(R.string.refresh)
|
||||
)
|
||||
}
|
||||
@ -99,7 +101,7 @@ fun BundleInformationDialog(
|
||||
BaseBundleDialog(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
isDefault = bundle.isDefault,
|
||||
name = bundle.name,
|
||||
name = bundleName,
|
||||
remoteUrl = bundle.asRemoteOrNull?.endpoint,
|
||||
patchCount = patchCount,
|
||||
version = props?.versionInfo?.patches,
|
||||
@ -112,7 +114,95 @@ fun BundleInformationDialog(
|
||||
onPatchesClick = {
|
||||
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 app.revanced.manager.R
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@ -45,8 +45,9 @@ fun BundleItem(
|
||||
val state by bundle.state.collectAsStateWithLifecycle()
|
||||
|
||||
val version by remember(bundle) {
|
||||
bundle.propsOrNullFlow().map { props -> props?.versionInfo?.patches }
|
||||
bundle.propsFlow().map { props -> props?.versionInfo?.patches }
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
val name by bundle.nameState
|
||||
|
||||
if (viewBundleDialogPage) {
|
||||
BundleInformationDialog(
|
||||
@ -56,7 +57,7 @@ fun BundleItem(
|
||||
onDelete()
|
||||
},
|
||||
bundle = bundle,
|
||||
onRefreshButton = onUpdate,
|
||||
onUpdate = onUpdate,
|
||||
)
|
||||
}
|
||||
|
||||
@ -65,34 +66,22 @@ fun BundleItem(
|
||||
.height(64.dp)
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
viewBundleDialogPage = true
|
||||
},
|
||||
onClick = { viewBundleDialogPage = true },
|
||||
onLongClick = onSelect,
|
||||
),
|
||||
leadingContent = {
|
||||
if(selectable) {
|
||||
leadingContent = if (selectable) {
|
||||
{
|
||||
Checkbox(
|
||||
checked = isBundleSelected,
|
||||
onCheckedChange = toggleSelection,
|
||||
)
|
||||
}
|
||||
},
|
||||
} else null,
|
||||
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = bundle.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
},
|
||||
headlineContent = { Text(name) },
|
||||
supportingContent = {
|
||||
state.patchBundleOrNull()?.patches?.size?.let { patchCount ->
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.patches_count, patchCount, patchCount),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount))
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
@ -107,20 +96,14 @@ fun BundleItem(
|
||||
|
||||
icon?.let { (vector, description) ->
|
||||
Icon(
|
||||
imageVector = vector,
|
||||
vector,
|
||||
contentDescription = stringResource(description),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
version?.let { txt ->
|
||||
Text(
|
||||
text = txt,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
version?.let { Text(text = it) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@ -3,15 +3,12 @@ package app.revanced.manager.ui.component.bundle
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Lightbulb
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@ -29,6 +26,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.NotificationCard
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -52,16 +50,16 @@ fun BundlePatchesDialog(
|
||||
BundleTopBar(
|
||||
title = stringResource(R.string.bundle_patches),
|
||||
onBackClick = onDismissRequest,
|
||||
onBackIcon = {
|
||||
backIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -51,6 +53,7 @@ fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSourc
|
||||
)
|
||||
}
|
||||
bundles.forEach {
|
||||
val name by it.nameState
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
@ -62,7 +65,7 @@ fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSourc
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = it.name,
|
||||
name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
@ -19,7 +19,7 @@ fun BundleTopBar(
|
||||
onBackClick: (() -> Unit)? = null,
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
onBackIcon: @Composable () -> Unit,
|
||||
backIcon: @Composable () -> Unit,
|
||||
) {
|
||||
val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||
|
||||
@ -34,7 +34,7 @@ fun BundleTopBar(
|
||||
navigationIcon = {
|
||||
if (onBackClick != null) {
|
||||
IconButton(onClick = onBackClick) {
|
||||
onBackIcon()
|
||||
backIcon()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -4,56 +4,61 @@ import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Topic
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
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.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
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.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.JAR_MIMETYPE
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImportBundleDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onRemoteSubmit: (String, String, Boolean) -> Unit,
|
||||
onLocalSubmit: (String, Uri, Uri?) -> Unit
|
||||
fun ImportPatchBundleDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onRemoteSubmit: (String, Boolean) -> Unit,
|
||||
onLocalSubmit: (Uri, Uri?) -> Unit
|
||||
) {
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
var remoteUrl by rememberSaveable { mutableStateOf("") }
|
||||
var autoUpdate by rememberSaveable { mutableStateOf(true) }
|
||||
var isLocal by rememberSaveable { mutableStateOf(false) }
|
||||
var currentStep by rememberSaveable { mutableIntStateOf(0) }
|
||||
var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) }
|
||||
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
var integrations by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val inputsAreValid by remember {
|
||||
derivedStateOf {
|
||||
name.isNotEmpty() && if (isLocal) patchBundle != null else remoteUrl.isNotEmpty()
|
||||
}
|
||||
}
|
||||
var remoteUrl by rememberSaveable { mutableStateOf("") }
|
||||
var autoUpdate by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val patchActivityLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { patchBundle = it }
|
||||
}
|
||||
|
||||
fun launchPatchActivity() {
|
||||
patchActivityLauncher.launch(JAR_MIMETYPE)
|
||||
}
|
||||
@ -62,101 +67,212 @@ fun ImportBundleDialog(
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { integrations = it }
|
||||
}
|
||||
|
||||
fun launchIntegrationsActivity() {
|
||||
integrationsActivityLauncher.launch(APK_MIMETYPE)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true
|
||||
)
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BundleTopBar(
|
||||
title = stringResource(R.string.import_bundle),
|
||||
onBackClick = onDismissRequest,
|
||||
onBackIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.close)
|
||||
)
|
||||
},
|
||||
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()
|
||||
}
|
||||
)
|
||||
val steps = listOf<@Composable () -> Unit>(
|
||||
{
|
||||
SelectBundleTypeStep(bundleType) { selectedType ->
|
||||
bundleType = selectedType
|
||||
}
|
||||
},
|
||||
{
|
||||
ImportBundleStep(
|
||||
bundleType,
|
||||
patchBundle,
|
||||
integrations,
|
||||
remoteUrl,
|
||||
autoUpdate,
|
||||
{ launchPatchActivity() },
|
||||
{ launchIntegrationsActivity() },
|
||||
{ remoteUrl = it },
|
||||
{ autoUpdate = it }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
BundleListItem(
|
||||
headlineText = stringResource(R.string.integrations_field),
|
||||
supportingText = 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
|
||||
)
|
||||
val inputsAreValid by remember {
|
||||
derivedStateOf {
|
||||
(bundleType == BundleType.Local && patchBundle != null) ||
|
||||
(bundleType == BundleType.Remote && remoteUrl.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialogExtended(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(stringResource(if (currentStep == 0) R.string.select else R.string.add_patch_bundle))
|
||||
},
|
||||
text = {
|
||||
steps[currentStep]()
|
||||
},
|
||||
confirmButton = {
|
||||
if (currentStep == steps.lastIndex) {
|
||||
TextButton(
|
||||
enabled = inputsAreValid,
|
||||
onClick = {
|
||||
when (bundleType) {
|
||||
BundleType.Local -> patchBundle?.let {
|
||||
onLocalSubmit(
|
||||
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
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.Folder
|
||||
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.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisallowComposableCalls
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
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.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.data.platform.Filesystem
|
||||
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 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 typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit
|
||||
|
||||
@Composable
|
||||
private fun OptionListItem(
|
||||
option: Option,
|
||||
onClick: () -> Unit,
|
||||
trailingContent: @Composable () -> Unit
|
||||
private class OptionEditorScope<T : Any>(
|
||||
private val editor: OptionEditor<T>,
|
||||
val option: Option<T>,
|
||||
val openDialog: () -> Unit,
|
||||
val dismissDialog: () -> Unit,
|
||||
val value: T?,
|
||||
val setValue: (T?) -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
headlineContent = { Text(option.title) },
|
||||
supportingContent = { Text(option.description) },
|
||||
trailingContent = trailingContent
|
||||
)
|
||||
fun submitDialog(value: T?) {
|
||||
setValue(value)
|
||||
dismissDialog()
|
||||
}
|
||||
|
||||
fun clickAction() = editor.clickAction(this)
|
||||
|
||||
@Composable
|
||||
fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
|
||||
|
||||
@Composable
|
||||
fun Dialog() = editor.Dialog(this)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StringOptionDialog(
|
||||
name: String,
|
||||
value: String?,
|
||||
onSubmit: (String) -> Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
var showFileDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var fieldValue by rememberSaveable(value) {
|
||||
mutableStateOf(value.orEmpty())
|
||||
}
|
||||
private interface OptionEditor<T : Any> {
|
||||
fun clickAction(scope: OptionEditorScope<T>) = scope.openDialog()
|
||||
|
||||
val fs: Filesystem = rememberKoinInject()
|
||||
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()
|
||||
}
|
||||
@Composable
|
||||
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
|
||||
IconButton(onClick = { clickAction(scope) }) {
|
||||
Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
@Composable
|
||||
fun Dialog(scope: OptionEditorScope<T>)
|
||||
}
|
||||
|
||||
private val unknownOption: OptionImpl = { option, _, _ ->
|
||||
val context = LocalContext.current
|
||||
OptionListItem(
|
||||
option = option,
|
||||
onClick = { context.toast("Unknown type: ${option.type}") },
|
||||
trailingContent = {})
|
||||
}
|
||||
|
||||
private val optionImplementations = mapOf<String, OptionImpl>(
|
||||
// These are the only two types that are currently used by the official patches
|
||||
"Boolean" to { option, value, setValue ->
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val optionEditors = mapOf(
|
||||
"Boolean" to BooleanOptionEditor,
|
||||
"String" to StringOptionEditor,
|
||||
"Int" to IntOptionEditor,
|
||||
"Long" to LongOptionEditor,
|
||||
"Float" to FloatOptionEditor,
|
||||
"BooleanArray" to ListOptionEditor(BooleanOptionEditor),
|
||||
"StringArray" to ListOptionEditor(StringOptionEditor),
|
||||
"IntArray" to ListOptionEditor(IntOptionEditor),
|
||||
"LongArray" to ListOptionEditor(LongOptionEditor),
|
||||
"FloatArray" to ListOptionEditor(FloatOptionEditor),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
|
||||
val implementation = remember(option.type) {
|
||||
optionImplementations.getOrDefault(
|
||||
option.type,
|
||||
unknownOption
|
||||
private inline fun <T : Any> WithOptionEditor(
|
||||
editor: OptionEditor<T>,
|
||||
option: Option<T>,
|
||||
value: T?,
|
||||
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.compose.foundation.clickable
|
||||
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.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile
|
||||
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.InsertDriveFile
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -30,6 +28,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.GroupHeader
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.util.saver.PathSaver
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.absolutePathString
|
||||
@ -71,7 +70,7 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
|
||||
currentDirectory = currentDirectory.parent
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
item(key = "current") {
|
||||
@ -86,7 +85,7 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
|
||||
item(key = "parent") {
|
||||
PathItem(
|
||||
onClick = { currentDirectory = currentDirectory.parent },
|
||||
icon = Icons.Outlined.ArrowBack,
|
||||
icon = Icons.AutoMirrored.Outlined.ArrowBack,
|
||||
name = stringResource(R.string.path_selector_parent_dir)
|
||||
)
|
||||
}
|
||||
@ -113,7 +112,7 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
|
||||
items(files, key = { it.absolutePathString() }) {
|
||||
PathItem(
|
||||
onClick = { onSelect(it) },
|
||||
icon = Icons.Outlined.InsertDriveFile,
|
||||
icon = Icons.AutoMirrored.Outlined.InsertDriveFile,
|
||||
name = it.name
|
||||
)
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun BooleanItem(
|
||||
modifier: Modifier = Modifier,
|
||||
preference: Preference<Boolean>,
|
||||
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||
@StringRes headline: Int,
|
||||
@ -22,6 +23,7 @@ fun BooleanItem(
|
||||
val value by preference.getAsState()
|
||||
|
||||
BooleanItem(
|
||||
modifier = modifier,
|
||||
value = value,
|
||||
onValueChange = { coroutineScope.launch { preference.update(it) } },
|
||||
headline = headline,
|
||||
@ -31,12 +33,15 @@ fun BooleanItem(
|
||||
|
||||
@Composable
|
||||
fun BooleanItem(
|
||||
modifier: Modifier = Modifier,
|
||||
value: Boolean,
|
||||
onValueChange: (Boolean) -> Unit,
|
||||
@StringRes headline: Int,
|
||||
@StringRes description: Int
|
||||
) = SettingsListItem(
|
||||
modifier = Modifier.clickable { onValueChange(!value) },
|
||||
modifier = Modifier
|
||||
.clickable { onValueChange(!value) }
|
||||
.then(modifier),
|
||||
headlineContent = stringResource(headline),
|
||||
supportingContent = stringResource(description),
|
||||
trailingContent = {
|
||||
|
@ -55,10 +55,6 @@ fun Changelog(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Tag(
|
||||
Icons.Outlined.Sell,
|
||||
version
|
||||
)
|
||||
Tag(
|
||||
Icons.Outlined.FileDownload,
|
||||
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.ui.model.SelectedApp
|
||||
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.RawValue
|
||||
|
||||
@ -23,12 +23,12 @@ sealed interface Destination : Parcelable {
|
||||
data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination
|
||||
|
||||
@Parcelize
|
||||
data class VersionSelector(val packageName: String, val patchesSelection: PatchesSelection? = null) : Destination
|
||||
data class VersionSelector(val packageName: String, val patchSelection: PatchSelection? = null) : Destination
|
||||
|
||||
@Parcelize
|
||||
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
|
||||
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination
|
||||
|
||||
@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 app.revanced.manager.ui.model.SelectedApp
|
||||
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.RawValue
|
||||
|
||||
@ -12,7 +12,7 @@ sealed interface SelectedAppInfoDestination : Parcelable {
|
||||
data object Main : SelectedAppInfoDestination
|
||||
|
||||
@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
|
||||
data object VersionSelector: SelectedAppInfoDestination
|
||||
|
@ -6,35 +6,38 @@ import kotlinx.parcelize.Parcelize
|
||||
sealed interface SettingsDestination : Parcelable {
|
||||
|
||||
@Parcelize
|
||||
object Settings : SettingsDestination
|
||||
data object Settings : SettingsDestination
|
||||
|
||||
@Parcelize
|
||||
object General : SettingsDestination
|
||||
data object General : SettingsDestination
|
||||
|
||||
@Parcelize
|
||||
object Advanced : SettingsDestination
|
||||
data object Advanced : SettingsDestination
|
||||
|
||||
@Parcelize
|
||||
object Updates : SettingsDestination
|
||||
data object Updates : SettingsDestination
|
||||
|
||||
@Parcelize
|
||||
object Downloads : SettingsDestination
|
||||
data object Downloads : SettingsDestination
|
||||
|
||||
@Parcelize
|
||||
object ImportExport : SettingsDestination
|
||||
data object ImportExport : SettingsDestination
|
||||
|
||||
@Parcelize
|
||||
object About : SettingsDestination
|
||||
data object About : SettingsDestination
|
||||
|
||||
@Parcelize
|
||||
data class Update(val downloadOnScreenEntry: Boolean) : SettingsDestination
|
||||
data class Update(val downloadOnScreenEntry: Boolean = false) : SettingsDestination
|
||||
|
||||
@Parcelize
|
||||
object Changelogs : SettingsDestination
|
||||
data object Changelogs : SettingsDestination
|
||||
|
||||
@Parcelize
|
||||
object Contributors: SettingsDestination
|
||||
data object Contributors: SettingsDestination
|
||||
|
||||
@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.patcher.patch.PatchInfo
|
||||
import app.revanced.manager.util.PatchesSelection
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import app.revanced.manager.util.flatMapLatestAndCombine
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@ -34,7 +34,7 @@ data class BundleInfo(
|
||||
}
|
||||
|
||||
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 =
|
||||
bundle.patchSequence(allowUnsupported)
|
||||
.mapNotNullTo(mutableSetOf()) { patch ->
|
||||
@ -75,8 +75,13 @@ data class BundleInfo(
|
||||
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()
|
||||
|
||||
@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
|
||||
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.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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.outlined.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -19,7 +18,6 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.AppLabel
|
||||
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.NonSuggestedVersionDialog
|
||||
import app.revanced.manager.ui.component.SearchView
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
|
||||
import app.revanced.manager.util.APK_MIMETYPE
|
||||
import app.revanced.manager.util.toast
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -41,21 +41,19 @@ fun AppSelectorScreen(
|
||||
onAppClick: (packageName: String) -> Unit,
|
||||
onStorageClick: (SelectedApp.Local) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
vm: AppSelectorViewModel = getViewModel()
|
||||
vm: AppSelectorViewModel = koinViewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
SideEffect {
|
||||
vm.onStorageClick = onStorageClick
|
||||
}
|
||||
|
||||
val pickApkLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
uri?.let { apkUri ->
|
||||
vm.loadSelectedFile(apkUri)?.let(onStorageClick) ?: context.toast(
|
||||
context.getString(
|
||||
R.string.failed_to_load_apk
|
||||
)
|
||||
)
|
||||
}
|
||||
uri?.let(vm::handleStorageResult)
|
||||
}
|
||||
|
||||
val suggestedVersions by vm.suggestedAppVersions.collectAsStateWithLifecycle(emptyMap())
|
||||
|
||||
var filterText by rememberSaveable { mutableStateOf("") }
|
||||
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) {
|
||||
SearchBar(
|
||||
SearchView(
|
||||
query = filterText,
|
||||
onQueryChange = { filterText = it },
|
||||
onSearch = { },
|
||||
active = true,
|
||||
onActiveChange = { search = it },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
placeholder = { Text(stringResource(R.string.search_apps)) },
|
||||
leadingIcon = {
|
||||
IconButton({ search = false }) {
|
||||
placeholder = { Text(stringResource(R.string.search_apps)) }
|
||||
) {
|
||||
if (appList.isNotEmpty() && filterText.isNotEmpty()) {
|
||||
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(
|
||||
Icons.Default.ArrowBack,
|
||||
stringResource(R.string.back)
|
||||
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
|
||||
)
|
||||
}
|
||||
},
|
||||
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(
|
||||
@ -161,10 +149,11 @@ fun AppSelectorScreen(
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(paddingValues),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
ListItem(
|
||||
@ -185,7 +174,7 @@ fun AppSelectorScreen(
|
||||
Text(stringResource(R.string.select_from_storage_description))
|
||||
}
|
||||
)
|
||||
Divider()
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (appList.isNotEmpty()) {
|
||||
@ -193,17 +182,25 @@ fun AppSelectorScreen(
|
||||
items = appList,
|
||||
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) },
|
||||
headlineContent = {
|
||||
AppLabel(
|
||||
app.packageInfo,
|
||||
defaultText = app.packageName
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
suggestedVersions[app.packageName]?.let {
|
||||
Text(stringResource(R.string.suggested_version_info, it))
|
||||
}
|
||||
},
|
||||
trailingContent = app.patches?.let {
|
||||
{
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.patches_count,
|
||||
R.plurals.patch_count,
|
||||
it,
|
||||
it
|
||||
)
|
||||
|
@ -1,30 +1,22 @@
|
||||
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.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.BatteryAlert
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.outlined.Apps
|
||||
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.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
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.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@ -32,16 +24,20 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.isDefault
|
||||
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.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.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.util.toast
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
enum class DashboardPage(
|
||||
val titleResId: Int,
|
||||
@ -51,48 +47,58 @@ enum class DashboardPage(
|
||||
BUNDLES(R.string.tab_bundles, Icons.Outlined.Source),
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
vm: DashboardViewModel = getViewModel(),
|
||||
vm: DashboardViewModel = koinViewModel(),
|
||||
onAppSelectorClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onUpdateClick: () -> Unit,
|
||||
onAppClick: (InstalledApp) -> Unit
|
||||
) {
|
||||
var showImportBundleDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } }
|
||||
val pages: Array<DashboardPage> = DashboardPage.values()
|
||||
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
|
||||
val androidContext = LocalContext.current
|
||||
|
||||
val composableScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = DashboardPage.DASHBOARD.ordinal,
|
||||
initialPageOffsetFraction = 0f
|
||||
) {
|
||||
DashboardPage.values().size
|
||||
}
|
||||
val composableScope = rememberCoroutineScope()
|
||||
) { DashboardPage.entries.size }
|
||||
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
if (pagerState.currentPage != DashboardPage.BUNDLES.ordinal) vm.cancelSourceSelection()
|
||||
}
|
||||
|
||||
if (showImportBundleDialog) {
|
||||
fun dismiss() {
|
||||
showImportBundleDialog = false
|
||||
}
|
||||
val firstLaunch by vm.prefs.firstLaunch.getAsState()
|
||||
if (firstLaunch) AutoUpdatesDialog(vm::applyAutoUpdatePrefs)
|
||||
|
||||
ImportBundleDialog(
|
||||
onDismissRequest = ::dismiss,
|
||||
onLocalSubmit = { name, patches, integrations ->
|
||||
dismiss()
|
||||
vm.createLocalSource(name, patches, integrations)
|
||||
},
|
||||
onRemoteSubmit = { name, url, autoUpdate ->
|
||||
dismiss()
|
||||
vm.createRemoteSource(name, url, autoUpdate)
|
||||
var showAddBundleDialog by rememberSaveable { mutableStateOf(false) }
|
||||
if (showAddBundleDialog) {
|
||||
ImportPatchBundleDialog(
|
||||
onDismiss = { showAddBundleDialog = false },
|
||||
onLocalSubmit = { patches, integrations ->
|
||||
showAddBundleDialog = false
|
||||
vm.createLocalSource(patches, integrations)
|
||||
},
|
||||
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(
|
||||
title = stringResource(R.string.bundles_selected, vm.selectedSources.size),
|
||||
onBackClick = vm::cancelSourceSelection,
|
||||
onBackIcon = {
|
||||
backIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
@ -137,6 +143,23 @@ fun DashboardScreen(
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.app_name),
|
||||
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) {
|
||||
Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
|
||||
}
|
||||
@ -165,13 +188,11 @@ fun DashboardScreen(
|
||||
}
|
||||
|
||||
DashboardPage.BUNDLES.ordinal -> {
|
||||
showImportBundleDialog = true
|
||||
showAddBundleDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Add, stringResource(R.string.add))
|
||||
}
|
||||
) { Icon(Icons.Default.Add, stringResource(R.string.add)) }
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(Modifier.padding(paddingValues)) {
|
||||
@ -179,7 +200,7 @@ fun DashboardScreen(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
|
||||
) {
|
||||
pages.forEachIndexed { index, page ->
|
||||
DashboardPage.entries.forEachIndexed { index, page ->
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 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(
|
||||
state = pagerState,
|
||||
userScrollEnabled = true,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
pageContent = { index ->
|
||||
when (pages[index]) {
|
||||
when (DashboardPage.entries[index]) {
|
||||
DashboardPage.DASHBOARD -> {
|
||||
InstalledAppsScreen(
|
||||
onAppClick = onAppClick
|
||||
@ -215,11 +263,9 @@ fun DashboardScreen(
|
||||
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
sources.forEach {
|
||||
|
||||
BundleItem(
|
||||
bundle = it,
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.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.Delete
|
||||
import androidx.compose.material.icons.outlined.OpenInNew
|
||||
import androidx.compose.material.icons.outlined.SettingsBackupRestore
|
||||
import androidx.compose.material.icons.outlined.Update
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@ -31,6 +29,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.ui.component.AppInfo
|
||||
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.settings.SettingsListItem
|
||||
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)
|
||||
@Composable
|
||||
fun InstalledAppInfoScreen(
|
||||
onPatchClick: (packageName: String, patchesSelection: PatchesSelection) -> Unit,
|
||||
onPatchClick: (packageName: String, patchSelection: PatchSelection) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
viewModel: InstalledAppInfoViewModel
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
SideEffect {
|
||||
viewModel.onBackClick = onBackClick
|
||||
}
|
||||
@ -70,18 +73,17 @@ fun InstalledAppInfoScreen(
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
ColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
AppInfo(viewModel.appInfo) {
|
||||
Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
|
||||
|
||||
if (viewModel.installedApp.installType == InstallType.ROOT) {
|
||||
Text(
|
||||
text = if (viewModel.rootInstaller.isAppMounted(viewModel.installedApp.currentPackageName)) {
|
||||
text = if (viewModel.isMounted) {
|
||||
stringResource(R.string.mounted)
|
||||
} else {
|
||||
stringResource(R.string.not_mounted)
|
||||
@ -98,7 +100,7 @@ fun InstalledAppInfoScreen(
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
) {
|
||||
SegmentedButton(
|
||||
icon = Icons.Outlined.OpenInNew,
|
||||
icon = Icons.AutoMirrored.Outlined.OpenInNew,
|
||||
text = stringResource(R.string.open_app),
|
||||
onClick = viewModel::launch
|
||||
)
|
||||
@ -144,17 +146,17 @@ fun InstalledAppInfoScreen(
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
SettingsListItem(
|
||||
modifier = Modifier.clickable { },
|
||||
modifier = Modifier.clickable { context.toast("Not implemented yet!") },
|
||||
headlineContent = stringResource(R.string.applied_patches),
|
||||
supportingContent =
|
||||
(viewModel.appliedPatches?.values?.sumOf { it.size } ?: 0).let {
|
||||
pluralStringResource(
|
||||
id = R.plurals.applied_patches,
|
||||
id = R.plurals.patch_count,
|
||||
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(
|
||||
@ -174,7 +176,6 @@ fun InstalledAppInfoScreen(
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@ -21,39 +18,27 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
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.AppLabel
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.LoadingIndicator
|
||||
import app.revanced.manager.ui.component.NotificationCard
|
||||
import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun InstalledAppsScreen(
|
||||
onAppClick: (InstalledApp) -> Unit,
|
||||
viewModel: InstalledAppsViewModel = getViewModel()
|
||||
viewModel: InstalledAppsViewModel = koinViewModel()
|
||||
) {
|
||||
val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null)
|
||||
|
||||
Column {
|
||||
if (!Aapt.supportsDevice()) {
|
||||
NotificationCard(
|
||||
isWarning = true,
|
||||
icon = Icons.Outlined.WarningAmber,
|
||||
text = stringResource(
|
||||
R.string.unsupported_architecture_warning
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top
|
||||
verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top,
|
||||
) {
|
||||
installedApps?.let { installedApps ->
|
||||
|
||||
if (installedApps.isNotEmpty()) {
|
||||
items(
|
||||
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.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.FilterList
|
||||
import androidx.compose.material.icons.outlined.HelpOutline
|
||||
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.material.icons.automirrored.outlined.HelpOutline
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -48,7 +20,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@ -58,24 +29,26 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.patcher.patch.Option
|
||||
import app.revanced.manager.patcher.patch.PatchInfo
|
||||
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.viewmodel.PatchesSelectorViewModel
|
||||
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_UNSUPPORTED
|
||||
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 org.koin.compose.rememberKoinInject
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PatchesSelectorScreen(
|
||||
onSave: (PatchesSelection?, Options) -> Unit,
|
||||
onSave: (PatchSelection?, Options) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
vm: PatchesSelectorViewModel
|
||||
) {
|
||||
@ -95,6 +68,23 @@ fun PatchesSelectorScreen(
|
||||
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) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
@ -105,13 +95,13 @@ fun PatchesSelectorScreen(
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.patches_selector_sheet_filter_title),
|
||||
text = stringResource(R.string.patch_selector_sheet_filter_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
@ -142,11 +132,19 @@ fun PatchesSelectorScreen(
|
||||
}
|
||||
|
||||
if (vm.compatibleVersions.isNotEmpty())
|
||||
UnsupportedDialog(
|
||||
UnsupportedPatchDialog(
|
||||
appVersion = vm.appVersion,
|
||||
supportedVersions = vm.compatibleVersions,
|
||||
onDismissRequest = vm::dismissDialogs
|
||||
)
|
||||
var showUnsupportedPatchesDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
if (showUnsupportedPatchesDialog)
|
||||
UnsupportedPatchesDialog(
|
||||
appVersion = vm.appVersion,
|
||||
onDismissRequest = { showUnsupportedPatchesDialog = false }
|
||||
)
|
||||
|
||||
vm.optionsDialog?.let { (bundle, patch) ->
|
||||
OptionsDialog(
|
||||
@ -158,10 +156,16 @@ fun PatchesSelectorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
vm.pendingSelectionAction?.let {
|
||||
SelectionWarningDialog(
|
||||
onCancel = vm::dismissSelectionWarning,
|
||||
onConfirm = vm::confirmSelectionWarning
|
||||
var showSelectionWarning by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
if (showSelectionWarning) {
|
||||
SelectionWarningDialog(onDismiss = { showSelectionWarning = false })
|
||||
}
|
||||
vm.pendingUniversalPatchAction?.let {
|
||||
UniversalPatchWarningDialog(
|
||||
onCancel = vm::dismissUniversalPatchWarning,
|
||||
onConfirm = vm::confirmUniversalPatchWarning
|
||||
)
|
||||
}
|
||||
|
||||
@ -193,12 +197,20 @@ fun PatchesSelectorScreen(
|
||||
patch
|
||||
),
|
||||
onToggle = {
|
||||
if (vm.selectionWarningEnabled) {
|
||||
vm.pendingSelectionAction = {
|
||||
vm.togglePatch(uid, patch)
|
||||
when {
|
||||
// Open unsupported dialog if the patch is not supported
|
||||
!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
|
||||
@ -208,31 +220,17 @@ fun PatchesSelectorScreen(
|
||||
}
|
||||
|
||||
search?.let { query ->
|
||||
SearchBar(
|
||||
SearchView(
|
||||
query = query,
|
||||
onQueryChange = { new ->
|
||||
search = new
|
||||
},
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
onQueryChange = { search = it },
|
||||
onActiveChange = { if (!it) search = null },
|
||||
placeholder = { Text(stringResource(R.string.search_patches)) }
|
||||
) {
|
||||
val bundle = bundles[pagerState.currentPage]
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
fun List<PatchInfo>.searched() = filter {
|
||||
it.name.contains(query, true)
|
||||
}
|
||||
@ -254,7 +252,7 @@ fun PatchesSelectorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (!vm.allowExperimental) return@LazyColumn
|
||||
if (!vm.allowIncompatiblePatches) return@LazyColumnWithScrollbar
|
||||
patchList(
|
||||
uid = bundle.uid,
|
||||
patches = bundle.unsupported.searched(),
|
||||
@ -263,18 +261,17 @@ fun PatchesSelectorScreen(
|
||||
) {
|
||||
ListHeader(
|
||||
title = stringResource(R.string.unsupported_patches),
|
||||
onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) }
|
||||
onHelpClick = { showUnsupportedPatchesDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.select_patches),
|
||||
title = stringResource(R.string.patches_selected, selectedPatchCount, availablePatchCount),
|
||||
onBackClick = onBackClick,
|
||||
actions = {
|
||||
IconButton(onClick = vm::reset) {
|
||||
@ -298,7 +295,14 @@ fun PatchesSelectorScreen(
|
||||
|
||||
ExtendedFloatingActionButton(
|
||||
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 = {
|
||||
// TODO: only allow this if all required options have been set.
|
||||
onSave(vm.getCustomSelection(), vm.getOptions())
|
||||
@ -338,10 +342,13 @@ fun PatchesSelectorScreen(
|
||||
state = pagerState,
|
||||
userScrollEnabled = true,
|
||||
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]
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = patchLazyListStates[index]
|
||||
) {
|
||||
patchList(
|
||||
uid = bundle.uid,
|
||||
@ -363,11 +370,11 @@ fun PatchesSelectorScreen(
|
||||
uid = bundle.uid,
|
||||
patches = bundle.unsupported,
|
||||
filterFlag = SHOW_UNSUPPORTED,
|
||||
supported = vm.allowExperimental
|
||||
supported = vm.allowIncompatiblePatches
|
||||
) {
|
||||
ListHeader(
|
||||
title = stringResource(R.string.unsupported_patches),
|
||||
onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) }
|
||||
onHelpClick = { showUnsupportedPatchesDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -378,35 +385,24 @@ fun PatchesSelectorScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectionWarningDialog(
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: (Boolean) -> Unit
|
||||
) {
|
||||
val prefs: PreferencesManager = rememberKoinInject()
|
||||
var dismissPermanently by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
private fun SelectionWarningDialog(onDismiss: () -> Unit) {
|
||||
SafeguardDialog(
|
||||
onDismiss = onDismiss,
|
||||
title = R.string.warning,
|
||||
body = stringResource(R.string.selection_warning_description),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UniversalPatchWarningDialog(
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onCancel,
|
||||
confirmButton = {
|
||||
val enableCountdown by prefs.enableSelectionWarningCountdown.getAsState()
|
||||
|
||||
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)
|
||||
}
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.continue_))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
@ -419,44 +415,18 @@ fun SelectionWarningDialog(
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.selection_warning_title),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
text = stringResource(R.string.warning),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
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))
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.universal_patch_warning_description))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PatchItem(
|
||||
private fun PatchItem(
|
||||
patch: PatchInfo,
|
||||
onOptionsDialog: () -> Unit,
|
||||
selected: Boolean,
|
||||
@ -465,7 +435,7 @@ fun PatchItem(
|
||||
) = ListItem(
|
||||
modifier = Modifier
|
||||
.let { if (!supported) it.alpha(0.5f) else it }
|
||||
.clickable(enabled = supported, onClick = onToggle)
|
||||
.clickable(onClick = onToggle)
|
||||
.fillMaxSize(),
|
||||
leadingContent = {
|
||||
Checkbox(
|
||||
@ -486,7 +456,7 @@ fun PatchItem(
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ListHeader(
|
||||
private fun ListHeader(
|
||||
title: String,
|
||||
onHelpClick: (() -> Unit)? = null
|
||||
) {
|
||||
@ -502,7 +472,7 @@ fun ListHeader(
|
||||
{
|
||||
IconButton(onClick = it) {
|
||||
Icon(
|
||||
Icons.Outlined.HelpOutline,
|
||||
Icons.AutoMirrored.Outlined.HelpOutline,
|
||||
stringResource(R.string.help)
|
||||
)
|
||||
}
|
||||
@ -512,18 +482,46 @@ fun ListHeader(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UnsupportedDialog(
|
||||
private fun UnsupportedPatchesDialog(
|
||||
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_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(
|
||||
stringResource(
|
||||
@ -537,7 +535,7 @@ fun UnsupportedDialog(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OptionsDialog(
|
||||
private fun OptionsDialog(
|
||||
patch: PatchInfo,
|
||||
values: Map<String, Any?>?,
|
||||
reset: () -> Unit,
|
||||
@ -563,18 +561,25 @@ fun OptionsDialog(
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
if (patch.options == null) return@LazyColumn
|
||||
if (patch.options == null) return@LazyColumnWithScrollbar
|
||||
|
||||
items(patch.options, key = { it.key }) { option ->
|
||||
val key = option.key
|
||||
val value =
|
||||
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 androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@ -26,25 +27,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.ui.component.AppInfo
|
||||
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.model.BundleInfo.Extensions.bundleInfoFlow
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
|
||||
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
|
||||
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 dev.olshevski.navigation.reimagined.AnimatedNavHost
|
||||
import dev.olshevski.navigation.reimagined.NavBackHandler
|
||||
import dev.olshevski.navigation.reimagined.navigate
|
||||
import dev.olshevski.navigation.reimagined.pop
|
||||
import dev.olshevski.navigation.reimagined.rememberNavController
|
||||
import org.koin.androidx.compose.getViewModel
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun SelectedAppInfoScreen(
|
||||
onPatchClick: (SelectedApp, PatchesSelection, Options) -> Unit,
|
||||
onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
vm: SelectedAppInfoViewModel
|
||||
) {
|
||||
@ -56,10 +58,10 @@ fun SelectedAppInfoScreen(
|
||||
vm.bundlesRepo.bundleInfoFlow(packageName, version)
|
||||
}.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
|
||||
val allowExperimental by vm.prefs.allowExperimental.getAsState()
|
||||
val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
|
||||
val patches by remember {
|
||||
derivedStateOf {
|
||||
vm.getPatches(bundles, allowExperimental)
|
||||
vm.getPatches(bundles, allowIncompatiblePatches)
|
||||
}
|
||||
}
|
||||
val selectedPatchCount by remember {
|
||||
@ -67,11 +69,6 @@ fun SelectedAppInfoScreen(
|
||||
patches.values.sumOf { it.size }
|
||||
}
|
||||
}
|
||||
val availablePatchCount by remember {
|
||||
derivedStateOf {
|
||||
bundles.sumOf { it.patchCount }
|
||||
}
|
||||
}
|
||||
|
||||
val navController =
|
||||
rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main)
|
||||
@ -99,7 +96,7 @@ fun SelectedAppInfoScreen(
|
||||
vm.selectedApp,
|
||||
vm.getCustomPatches(
|
||||
bundles,
|
||||
allowExperimental
|
||||
allowIncompatiblePatches
|
||||
),
|
||||
vm.options
|
||||
)
|
||||
@ -109,7 +106,6 @@ fun SelectedAppInfoScreen(
|
||||
navController.navigate(SelectedAppInfoDestination.VersionSelector)
|
||||
},
|
||||
onBackClick = onBackClick,
|
||||
availablePatchCount = availablePatchCount,
|
||||
selectedPatchCount = selectedPatchCount,
|
||||
packageName = packageName,
|
||||
version = version,
|
||||
@ -122,7 +118,7 @@ fun SelectedAppInfoScreen(
|
||||
vm.selectedApp = it
|
||||
navController.pop()
|
||||
},
|
||||
viewModel = getViewModel { parametersOf(packageName) }
|
||||
viewModel = koinViewModel { parametersOf(packageName) }
|
||||
)
|
||||
|
||||
is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
|
||||
@ -131,7 +127,7 @@ fun SelectedAppInfoScreen(
|
||||
navController.pop()
|
||||
},
|
||||
onBackClick = navController::pop,
|
||||
vm = getViewModel {
|
||||
vm = koinViewModel {
|
||||
parametersOf(
|
||||
PatchesSelectorViewModel.Params(
|
||||
destination.app,
|
||||
@ -152,7 +148,6 @@ private fun SelectedAppInfoScreen(
|
||||
onPatchSelectorClick: () -> Unit,
|
||||
onVersionSelectorClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
availablePatchCount: Int,
|
||||
selectedPatchCount: Int,
|
||||
packageName: String,
|
||||
version: String,
|
||||
@ -164,30 +159,33 @@ private fun SelectedAppInfoScreen(
|
||||
title = stringResource(R.string.app_info),
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
text = { Text(stringResource(R.string.patch)) },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.AutoFixHigh,
|
||||
stringResource(R.string.patch)
|
||||
)
|
||||
},
|
||||
onClick = onPatchClick
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
ColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
AppInfo(packageInfo, placeholderLabel = packageName) {
|
||||
Text(
|
||||
stringResource(R.string.selected_app_meta, version, availablePatchCount),
|
||||
stringResource(R.string.selected_app_meta, version),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
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(
|
||||
R.string.patch_selector_item,
|
||||
stringResource(R.string.patch_selector_item_description, selectedPatchCount),
|
||||
@ -223,7 +221,7 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
|
||||
)
|
||||
},
|
||||
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