🔮 Merge repository updated to latest snapshot!

Script Execution UTC Time: null

Signed-off-by: validcube <pun.butrach@gmail.com>
This commit is contained in:
validcube 2024-08-18 18:56:57 +07:00
commit ace6701aaf
No known key found for this signature in database
GPG Key ID: DBA94253E1D3F267
141 changed files with 5819 additions and 2866 deletions

View File

@ -23,8 +23,8 @@ jobs:
java-version: '17' java-version: '17'
distribution: 'temurin' distribution: 'temurin'
- name: Setup Gradle - name: Set up Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/actions/setup-gradle@v4
- name: Build with Gradle - name: Build with Gradle
env: env:
@ -38,7 +38,7 @@ jobs:
run: mv app/build/outputs/apk/release/app-release.apk revanced-manager-${{ env.COMMIT_HASH }}.apk run: mv app/build/outputs/apk/release/app-release.apk revanced-manager-${{ env.COMMIT_HASH }}.apk
- name: Upload build - name: Upload build
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: revanced-manager name: revanced-manager
path: revanced-manager-${{ env.COMMIT_HASH }}.apk path: revanced-manager-${{ env.COMMIT_HASH }}.apk

View File

@ -20,10 +20,8 @@ jobs:
java-version: '17' java-version: '17'
distribution: 'temurin' distribution: 'temurin'
- name: Setup Gradle - name: Set up Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/actions/setup-gradle@v4
with:
cache-disabled: true
- name: Build with Gradle - name: Build with Gradle
env: env:

View File

@ -11,7 +11,7 @@ jobs:
name: Dispatch event to documentation repository name: Dispatch event to documentation repository
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
steps: steps:
- uses: peter-evans/repository-dispatch@v2 - uses: peter-evans/repository-dispatch@v3
with: with:
token: ${{ secrets.DOCUMENTATION_REPO_ACCESS_TOKEN }} token: ${{ secrets.DOCUMENTATION_REPO_ACCESS_TOKEN }}
repository: revanced/revanced-documentation repository: revanced/revanced-documentation

78
SECURITY.md Normal file
View 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>&nbsp;&nbsp;&nbsp;
<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>&nbsp;&nbsp;&nbsp;
<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>&nbsp;&nbsp;&nbsp;
<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>&nbsp;&nbsp;&nbsp;
<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>&nbsp;&nbsp;&nbsp;
<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>&nbsp;&nbsp;&nbsp;
<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: |

View File

@ -1,10 +1,12 @@
import kotlin.random.Random
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.devtools) alias(libs.plugins.devtools)
alias(libs.plugins.about.libraries) alias(libs.plugins.about.libraries)
id("kotlin-parcelize") id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.9.10" kotlin("plugin.serialization") version "1.9.23"
} }
android { android {
@ -18,16 +20,15 @@ android {
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "0.0.1" versionName = "0.0.1"
resourceConfigurations.addAll(listOf(
"en",
))
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
} }
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
resValue("string", "app_name", "ReVanced Manager Debug") resValue("string", "app_name", "ReVanced Manager (dev)")
buildConfigField("long", "BUILD_ID", "${Random.nextLong()}L")
} }
release { release {
@ -42,6 +43,8 @@ android {
resValue("string", "app_name", "ReVanced Manager Debug") resValue("string", "app_name", "ReVanced Manager Debug")
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }
buildConfigField("long", "BUILD_ID", "0L")
} }
} }
@ -80,8 +83,21 @@ android {
buildFeatures.compose = true buildFeatures.compose = true
buildFeatures.aidl = true buildFeatures.aidl = true
buildFeatures.buildConfig=true
composeOptions.kotlinCompilerExtensionVersion = "1.5.3" android {
androidResources {
generateLocaleConfig = true
}
}
composeOptions.kotlinCompilerExtensionVersion = "1.5.10"
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
} }
kotlin { kotlin {
@ -104,14 +120,16 @@ dependencies {
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.preview) implementation(libs.compose.ui.preview)
implementation(libs.compose.ui.tooling)
implementation(libs.compose.livedata) implementation(libs.compose.livedata)
implementation(libs.compose.material.icons.extended) implementation(libs.compose.material.icons.extended)
implementation(libs.compose.material3) implementation(libs.compose.material3)
// Accompanist // Accompanist
implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.drawablepainter)
implementation(libs.accompanist.webview)
implementation(libs.accompanist.placeholder) // Placeholder
implementation(libs.placeholder.material3)
// HTML Scraper // HTML Scraper
implementation(libs.skrapeit.dsl) implementation(libs.skrapeit.dsl)
@ -135,6 +153,13 @@ dependencies {
implementation(libs.revanced.patcher) implementation(libs.revanced.patcher)
implementation(libs.revanced.library) implementation(libs.revanced.library)
// Native processes
implementation(libs.kotlin.process)
// HiddenAPI
compileOnly(libs.hidden.api.stub)
// LibSU
implementation(libs.libsu.core) implementation(libs.libsu.core)
implementation(libs.libsu.service) implementation(libs.libsu.service)
implementation(libs.libsu.nio) implementation(libs.libsu.nio)
@ -162,4 +187,13 @@ dependencies {
// Fading Edges // Fading Edges
implementation(libs.fading.edges) implementation(libs.fading.edges)
// Scrollbars
implementation(libs.scrollbars)
// Reorderable lists
implementation(libs.reorderable)
// Compose Icons
implementation(libs.compose.icons.fontawesome)
} }

View File

@ -26,6 +26,10 @@
kotlinx.serialization.KSerializer serializer(...); kotlinx.serialization.KSerializer serializer(...);
} }
# This required for the process runtime.
-keep class app.revanced.manager.patcher.runtime.process.* {
*;
}
# Required for the patcher to function correctly # Required for the patcher to function correctly
-keep class app.revanced.patcher.** { -keep class app.revanced.patcher.** {
*; *;
@ -45,6 +49,7 @@
-keep class com.android.** { -keep class com.android.** {
*; *;
} }
-dontwarn com.google.auto.value.**
-dontwarn java.awt.** -dontwarn java.awt.**
-dontwarn javax.** -dontwarn javax.**
-dontwarn org.slf4j.** -dontwarn org.slf4j.**

View File

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "802fa2fda94b930bf0ebb85d195f1022", "identityHash": "1dd9d5c0201fdf3cfef3ae669fd65e46",
"entities": [ "entities": [
{ {
"tableName": "patch_bundles", "tableName": "patch_bundles",
@ -51,17 +51,7 @@
"uid" "uid"
] ]
}, },
"indices": [ "indices": [],
{
"name": "index_patch_bundles_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_bundles_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": [] "foreignKeys": []
}, },
{ {
@ -231,7 +221,7 @@
}, },
{ {
"tableName": "applied_patch", "tableName": "applied_patch",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `bundle` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`package_name`, `bundle`, `patch_name`), FOREIGN KEY(`package_name`) REFERENCES `installed_app`(`current_package_name`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`bundle`) REFERENCES `patch_bundles`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [ "fields": [
{ {
"fieldPath": "packageName", "fieldPath": "packageName",
@ -285,7 +275,7 @@
}, },
{ {
"table": "patch_bundles", "table": "patch_bundles",
"onDelete": "NO ACTION", "onDelete": "CASCADE",
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"columns": [ "columns": [
"bundle" "bundle"
@ -407,7 +397,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '802fa2fda94b930bf0ebb85d195f1022')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1dd9d5c0201fdf3cfef3ae669fd65e46')"
] ]
} }
} }

View File

@ -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);
}

View File

@ -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);
}

View File

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

View 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)

View 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);
}

View File

@ -5,22 +5,14 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.destination.Destination import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.destination.SettingsDestination import app.revanced.manager.ui.destination.SettingsDestination
import app.revanced.manager.ui.screen.AppSelectorScreen import app.revanced.manager.ui.screen.AppSelectorScreen
import app.revanced.manager.ui.screen.DashboardScreen import app.revanced.manager.ui.screen.DashboardScreen
import app.revanced.manager.ui.screen.InstalledAppInfoScreen import app.revanced.manager.ui.screen.InstalledAppInfoScreen
import app.revanced.manager.ui.screen.InstallerScreen import app.revanced.manager.ui.screen.PatcherScreen
import app.revanced.manager.ui.screen.SelectedAppInfoScreen import app.revanced.manager.ui.screen.SelectedAppInfoScreen
import app.revanced.manager.ui.screen.SettingsScreen import app.revanced.manager.ui.screen.SettingsScreen
import app.revanced.manager.ui.screen.VersionSelectorScreen import app.revanced.manager.ui.screen.VersionSelectorScreen
@ -35,7 +27,7 @@ import dev.olshevski.navigation.reimagined.pop
import dev.olshevski.navigation.reimagined.popUpTo import dev.olshevski.navigation.reimagined.popUpTo
import dev.olshevski.navigation.reimagined.rememberNavController import dev.olshevski.navigation.reimagined.rememberNavController
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koin.androidx.compose.getViewModel as getComposeViewModel import org.koin.androidx.compose.koinViewModel as getComposeViewModel
import org.koin.androidx.viewmodel.ext.android.getViewModel as getAndroidViewModel import org.koin.androidx.viewmodel.ext.android.getViewModel as getAndroidViewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -46,7 +38,6 @@ class MainActivity : ComponentActivity() {
installSplashScreen() installSplashScreen()
val vm: MainViewModel = getAndroidViewModel() val vm: MainViewModel = getAndroidViewModel()
vm.importLegacySettings(this) vm.importLegacySettings(this)
setContent { setContent {
@ -59,37 +50,8 @@ class MainActivity : ComponentActivity() {
) { ) {
val navController = val navController =
rememberNavController<Destination>(startDestination = Destination.Dashboard) rememberNavController<Destination>(startDestination = Destination.Dashboard)
NavBackHandler(navController) NavBackHandler(navController)
val firstLaunch by vm.prefs.firstLaunch.getAsState()
if (firstLaunch) AutoUpdatesDialog(vm::applyAutoUpdatePrefs)
vm.updatedManagerVersion?.let {
AlertDialog(
onDismissRequest = vm::dismissUpdateDialog,
confirmButton = {
TextButton(
onClick = {
vm.dismissUpdateDialog()
navController.navigate(Destination.Settings(SettingsDestination.Update(false)))
}
) {
Text(stringResource(R.string.update))
}
},
dismissButton = {
TextButton(onClick = vm::dismissUpdateDialog) {
Text(stringResource(R.string.dismiss_temporary))
}
},
icon = { Icon(Icons.Outlined.Update, null) },
title = { Text(stringResource(R.string.update_available_dialog_title)) },
text = { Text(stringResource(R.string.update_available_dialog_description, it)) }
)
}
AnimatedNavHost( AnimatedNavHost(
controller = navController controller = navController
) { destination -> ) { destination ->
@ -97,6 +59,9 @@ class MainActivity : ComponentActivity() {
is Destination.Dashboard -> DashboardScreen( is Destination.Dashboard -> DashboardScreen(
onSettingsClick = { navController.navigate(Destination.Settings()) }, onSettingsClick = { navController.navigate(Destination.Settings()) },
onAppSelectorClick = { navController.navigate(Destination.AppSelector) }, onAppSelectorClick = { navController.navigate(Destination.AppSelector) },
onUpdateClick = { navController.navigate(
Destination.Settings(SettingsDestination.Update())
) },
onAppClick = { installedApp -> onAppClick = { installedApp ->
navController.navigate( navController.navigate(
Destination.InstalledApplicationInfo( Destination.InstalledApplicationInfo(
@ -107,11 +72,11 @@ class MainActivity : ComponentActivity() {
) )
is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen( is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen(
onPatchClick = { packageName, patchesSelection -> onPatchClick = { packageName, patchSelection ->
navController.navigate( navController.navigate(
Destination.VersionSelector( Destination.VersionSelector(
packageName, packageName,
patchesSelection patchSelection
) )
) )
}, },
@ -142,14 +107,14 @@ class MainActivity : ComponentActivity() {
navController.navigate( navController.navigate(
Destination.SelectedApplicationInfo( Destination.SelectedApplicationInfo(
selectedApp, selectedApp,
destination.patchesSelection, destination.patchSelection,
) )
) )
}, },
viewModel = getComposeViewModel { viewModel = getComposeViewModel {
parametersOf( parametersOf(
destination.packageName, destination.packageName,
destination.patchesSelection destination.patchSelection
) )
} }
) )
@ -157,7 +122,7 @@ class MainActivity : ComponentActivity() {
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen( is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
onPatchClick = { app, patches, options -> onPatchClick = { app, patches, options ->
navController.navigate( navController.navigate(
Destination.Installer( Destination.Patcher(
app, patches, options app, patches, options
) )
) )
@ -167,13 +132,13 @@ class MainActivity : ComponentActivity() {
parametersOf( parametersOf(
SelectedAppInfoViewModel.Params( SelectedAppInfoViewModel.Params(
destination.selectedApp, destination.selectedApp,
destination.patchesSelection destination.patchSelection
) )
) )
} }
) )
is Destination.Installer -> InstallerScreen( is Destination.Patcher -> PatcherScreen(
onBackClick = { navController.popUpTo { it is Destination.Dashboard } }, onBackClick = { navController.popUpTo { it is Destination.Dashboard } },
vm = getComposeViewModel { parametersOf(destination) } vm = getComposeViewModel { parametersOf(destination) }
) )

View File

@ -1,23 +1,18 @@
package app.revanced.manager package app.revanced.manager
import android.app.Application import android.app.Application
import android.content.Intent
import app.revanced.manager.di.* import app.revanced.manager.di.*
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.service.ManagerRootService
import app.revanced.manager.service.RootConnection
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import coil.Coil import coil.Coil
import coil.ImageLoader import coil.ImageLoader
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.internal.BuilderImpl import com.topjohnwu.superuser.internal.BuilderImpl
import com.topjohnwu.superuser.ipc.RootService
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.zhanghai.android.appiconloader.coil.AppIconFetcher import me.zhanghai.android.appiconloader.coil.AppIconFetcher
import me.zhanghai.android.appiconloader.coil.AppIconKeyer import me.zhanghai.android.appiconloader.coil.AppIconKeyer
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
@ -61,9 +56,6 @@ class ManagerApplication : Application() {
val shellBuilder = BuilderImpl.create().setFlags(Shell.FLAG_MOUNT_MASTER) val shellBuilder = BuilderImpl.create().setFlags(Shell.FLAG_MOUNT_MASTER)
Shell.setDefaultBuilder(shellBuilder) Shell.setDefaultBuilder(shellBuilder)
val intent = Intent(this, ManagerRootService::class.java)
RootService.bind(intent, get<RootConnection>())
scope.launch { scope.launch {
prefs.preload() prefs.preload()
} }

View File

@ -1,10 +1,11 @@
package app.revanced.manager.data.platform package app.revanced.manager.data.platform
import android.Manifest
import android.app.Application import android.app.Application
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import app.revanced.manager.util.RequestManageStorageContract import app.revanced.manager.util.RequestManageStorageContract
@ -16,7 +17,7 @@ class Filesystem(private val app: Application) {
* A directory that gets cleared when the app restarts. * A directory that gets cleared when the app restarts.
* Do not store paths to this directory in a parcel. * Do not store paths to this directory in a parcel.
*/ */
val tempDir = app.cacheDir.resolve("ephemeral").apply { val tempDir = app.getDir("ephemeral", Context.MODE_PRIVATE).apply {
deleteRecursively() deleteRecursively()
mkdirs() mkdirs()
} }

View File

@ -2,7 +2,7 @@ package app.revanced.manager.data.room
import androidx.room.TypeConverter import androidx.room.TypeConverter
import app.revanced.manager.data.room.bundles.Source import app.revanced.manager.data.room.bundles.Source
import io.ktor.http.* import app.revanced.manager.data.room.options.Option.SerializedValue
import java.io.File import java.io.File
class Converters { class Converters {
@ -17,4 +17,10 @@ class Converters {
@TypeConverter @TypeConverter
fun fileToString(file: File): String = file.path fun fileToString(file: File): String = file.path
@TypeConverter
fun serializedOptionFromString(value: String) = SerializedValue.fromJsonString(value)
@TypeConverter
fun serializedOptionToString(value: SerializedValue) = value.toJsonString()
} }

View File

@ -22,7 +22,8 @@ import kotlinx.parcelize.Parcelize
ForeignKey( ForeignKey(
PatchBundleEntity::class, PatchBundleEntity::class,
parentColumns = ["uid"], parentColumns = ["uid"],
childColumns = ["bundle"] childColumns = ["bundle"],
onDelete = ForeignKey.CASCADE
) )
], ],
indices = [Index(value = ["bundle"], unique = false)] indices = [Index(value = ["bundle"], unique = false)]

View File

@ -3,7 +3,7 @@ package app.revanced.manager.data.room.apps.installed
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.MapInfo import androidx.room.MapColumn
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
@ -17,12 +17,13 @@ interface InstalledAppDao {
@Query("SELECT * FROM installed_app WHERE current_package_name = :packageName") @Query("SELECT * FROM installed_app WHERE current_package_name = :packageName")
suspend fun get(packageName: String): InstalledApp? suspend fun get(packageName: String): InstalledApp?
@MapInfo(keyColumn = "bundle", valueColumn = "patch_name")
@Query( @Query(
"SELECT bundle, patch_name FROM applied_patch" + "SELECT bundle, patch_name FROM applied_patch" +
" WHERE package_name = :packageName" " WHERE package_name = :packageName"
) )
suspend fun getPatchesSelection(packageName: String): Map<Int, List<String>> suspend fun getPatchesSelection(packageName: String): Map<@MapColumn("bundle") Int, List<@MapColumn(
"patch_name"
) String>>
@Transaction @Transaction
suspend fun upsertApp(installedApp: InstalledApp, appliedPatches: List<AppliedPatch>) { suspend fun upsertApp(installedApp: InstalledApp, appliedPatches: List<AppliedPatch>) {

View File

@ -9,7 +9,7 @@ interface PatchBundleDao {
suspend fun all(): List<PatchBundleEntity> suspend fun all(): List<PatchBundleEntity>
@Query("SELECT version, integrations_version, auto_update FROM patch_bundles WHERE uid = :uid") @Query("SELECT version, integrations_version, auto_update FROM patch_bundles WHERE uid = :uid")
fun getPropsById(uid: Int): Flow<BundleProperties> fun getPropsById(uid: Int): Flow<BundleProperties?>
@Query("UPDATE patch_bundles SET version = :patches, integrations_version = :integrations WHERE uid = :uid") @Query("UPDATE patch_bundles SET version = :patches, integrations_version = :integrations WHERE uid = :uid")
suspend fun updateVersion(uid: Int, patches: String?, integrations: String?) suspend fun updateVersion(uid: Int, patches: String?, integrations: String?)
@ -17,6 +17,9 @@ interface PatchBundleDao {
@Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid") @Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid")
suspend fun setAutoUpdate(uid: Int, value: Boolean) suspend fun setAutoUpdate(uid: Int, value: Boolean)
@Query("UPDATE patch_bundles SET name = :value WHERE uid = :uid")
suspend fun setName(uid: Int, value: String)
@Query("DELETE FROM patch_bundles WHERE uid != 0") @Query("DELETE FROM patch_bundles WHERE uid != 0")
suspend fun purgeCustomBundles() suspend fun purgeCustomBundles()

View File

@ -21,7 +21,7 @@ sealed class Source {
} }
companion object { companion object {
fun from(value: String) = when(value) { fun from(value: String) = when (value) {
Local.SENTINEL -> Local Local.SENTINEL -> Local
API.SENTINEL -> API API.SENTINEL -> API
else -> Remote(Url(value)) else -> Remote(Url(value))
@ -34,7 +34,7 @@ data class VersionInfo(
@ColumnInfo(name = "integrations_version") val integrations: String? = null, @ColumnInfo(name = "integrations_version") val integrations: String? = null,
) )
@Entity(tableName = "patch_bundles", indices = [Index(value = ["name"], unique = true)]) @Entity(tableName = "patch_bundles")
data class PatchBundleEntity( data class PatchBundleEntity(
@PrimaryKey val uid: Int, @PrimaryKey val uid: Int,
@ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "name") val name: String,

View File

@ -3,6 +3,23 @@ package app.revanced.manager.data.room.options
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import app.revanced.manager.patcher.patch.Option
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.float
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlin.reflect.KClass
@Entity( @Entity(
tableName = "options", tableName = "options",
@ -19,5 +36,74 @@ data class Option(
@ColumnInfo(name = "patch_name") val patchName: String, @ColumnInfo(name = "patch_name") val patchName: String,
@ColumnInfo(name = "key") val key: String, @ColumnInfo(name = "key") val key: String,
// Encoded as Json. // Encoded as Json.
@ColumnInfo(name = "value") val value: String, @ColumnInfo(name = "value") val value: SerializedValue,
) ) {
@Serializable
data class SerializedValue(val raw: JsonElement) {
fun toJsonString() = json.encodeToString(raw)
fun deserializeFor(option: Option<*>): Any? {
if (raw is JsonNull) return null
val errorMessage = "Cannot deserialize value as ${option.type}"
try {
if (option.type.endsWith("Array")) {
val elementType = option.type.removeSuffix("Array")
return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) }
}
return deserializeBasicType(option.type, raw.jsonPrimitive)
} catch (e: IllegalArgumentException) {
throw SerializationException(errorMessage, e)
} catch (e: IllegalStateException) {
throw SerializationException(errorMessage, e)
} catch (e: kotlinx.serialization.SerializationException) {
throw SerializationException(errorMessage, e)
}
}
companion object {
private val json = Json {
// Patcher does not forbid the use of these values, so we should support them.
allowSpecialFloatingPointValues = true
}
private fun deserializeBasicType(type: String, value: JsonPrimitive) = when (type) {
"Boolean" -> value.boolean
"Int" -> value.int
"Long" -> value.long
"Float" -> value.float
"String" -> value.content.also { if (!value.isString) throw SerializationException("Expected value to be a string: $value") }
else -> throw SerializationException("Unknown type: $type")
}
fun fromJsonString(value: String) = SerializedValue(json.decodeFromString(value))
fun fromValue(value: Any?) = SerializedValue(when (value) {
null -> JsonNull
is Number -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is String -> JsonPrimitive(value)
is List<*> -> buildJsonArray {
var elementClass: KClass<out Any>? = null
value.forEach {
when (it) {
null -> throw SerializationException("List elements must not be null")
is Number -> add(it)
is Boolean -> add(it)
is String -> add(it)
else -> throw SerializationException("Unknown element type: ${it::class.simpleName}")
}
if (elementClass == null) elementClass = it::class
else if (elementClass != it::class) throw SerializationException("List elements must have the same type")
}
}
else -> throw SerializationException("Unknown type: ${value::class.simpleName}")
})
}
}
class SerializationException(message: String, cause: Throwable? = null) :
Exception(message, cause)
}

View File

@ -2,7 +2,7 @@ package app.revanced.manager.data.room.options
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.MapInfo import androidx.room.MapColumn
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -10,13 +10,12 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
abstract class OptionDao { abstract class OptionDao {
@Transaction @Transaction
@MapInfo(keyColumn = "patch_bundle")
@Query( @Query(
"SELECT patch_bundle, `group`, patch_name, `key`, value FROM option_groups" + "SELECT patch_bundle, `group`, patch_name, `key`, value FROM option_groups" +
" LEFT JOIN options ON uid = options.`group`" + " LEFT JOIN options ON uid = options.`group`" +
" WHERE package_name = :packageName" " WHERE package_name = :packageName"
) )
abstract suspend fun getOptions(packageName: String): Map<Int, List<Option>> abstract suspend fun getOptions(packageName: String): Map<@MapColumn("patch_bundle") Int, List<Option>>
@Query("SELECT uid FROM option_groups WHERE patch_bundle = :bundleUid AND package_name = :packageName") @Query("SELECT uid FROM option_groups WHERE patch_bundle = :bundleUid AND package_name = :packageName")
abstract suspend fun getGroupId(bundleUid: Int, packageName: String): Int? abstract suspend fun getGroupId(bundleUid: Int, packageName: String): Int?

View File

@ -2,29 +2,31 @@ package app.revanced.manager.data.room.selection
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.MapInfo import androidx.room.MapColumn
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction import androidx.room.Transaction
@Dao @Dao
abstract class SelectionDao { abstract class SelectionDao {
@Transaction @Transaction
@MapInfo(keyColumn = "patch_bundle", valueColumn = "patch_name")
@Query( @Query(
"SELECT patch_bundle, patch_name FROM patch_selections" + "SELECT patch_bundle, patch_name FROM patch_selections" +
" LEFT JOIN selected_patches ON uid = selected_patches.selection" + " LEFT JOIN selected_patches ON uid = selected_patches.selection" +
" WHERE package_name = :packageName" " WHERE package_name = :packageName"
) )
abstract suspend fun getSelectedPatches(packageName: String): Map<Int, List<String>> abstract suspend fun getSelectedPatches(packageName: String): Map<@MapColumn("patch_bundle") Int, List<@MapColumn(
"patch_name"
) String>>
@Transaction @Transaction
@MapInfo(keyColumn = "package_name", valueColumn = "patch_name")
@Query( @Query(
"SELECT package_name, patch_name FROM patch_selections" + "SELECT package_name, patch_name FROM patch_selections" +
" LEFT JOIN selected_patches ON uid = selected_patches.selection" + " LEFT JOIN selected_patches ON uid = selected_patches.selection" +
" WHERE patch_bundle = :bundleUid" " WHERE patch_bundle = :bundleUid"
) )
abstract suspend fun exportSelection(bundleUid: Int): Map<String, List<String>> abstract suspend fun exportSelection(bundleUid: Int): Map<@MapColumn("package_name") String, List<@MapColumn(
"patch_name"
) String>>
@Query("SELECT uid FROM patch_selections WHERE patch_bundle = :bundleUid AND package_name = :packageName") @Query("SELECT uid FROM patch_selections WHERE patch_bundle = :bundleUid AND package_name = :packageName")
abstract suspend fun getSelectionId(bundleUid: Int, packageName: String): Int? abstract suspend fun getSelectionId(bundleUid: Int, packageName: String): Int?

View File

@ -1,11 +1,9 @@
package app.revanced.manager.di package app.revanced.manager.di
import app.revanced.manager.domain.installer.RootInstaller import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.service.RootConnection
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
val rootModule = module { val rootModule = module {
singleOf(::RootConnection)
singleOf(::RootInstaller) singleOf(::RootInstaller)
} }

View File

@ -13,12 +13,15 @@ val viewModelModule = module {
viewModelOf(::AdvancedSettingsViewModel) viewModelOf(::AdvancedSettingsViewModel)
viewModelOf(::AppSelectorViewModel) viewModelOf(::AppSelectorViewModel)
viewModelOf(::VersionSelectorViewModel) viewModelOf(::VersionSelectorViewModel)
viewModelOf(::InstallerViewModel) viewModelOf(::PatcherViewModel)
viewModelOf(::UpdateViewModel) viewModelOf(::UpdateViewModel)
viewModelOf(::ChangelogsViewModel) viewModelOf(::ChangelogsViewModel)
viewModelOf(::ImportExportViewModel) viewModelOf(::ImportExportViewModel)
viewModelOf(::AboutViewModel)
viewModelOf(::DeveloperOptionsViewModel)
viewModelOf(::ContributorViewModel) viewModelOf(::ContributorViewModel)
viewModelOf(::DownloadsViewModel) viewModelOf(::DownloadsViewModel)
viewModelOf(::InstalledAppsViewModel) viewModelOf(::InstalledAppsViewModel)
viewModelOf(::InstalledAppInfoViewModel) viewModelOf(::InstalledAppInfoViewModel)
viewModelOf(::UpdatesSettingsViewModel)
} }

View File

@ -7,7 +7,8 @@ import java.io.InputStream
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
class LocalPatchBundle(name: String, id: Int, directory: File) : PatchBundleSource(name, id, directory) { class LocalPatchBundle(name: String, id: Int, directory: File) :
PatchBundleSource(name, id, directory) {
suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) { suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
patches?.let { inputStream -> patches?.let { inputStream ->
@ -16,10 +17,16 @@ class LocalPatchBundle(name: String, id: Int, directory: File) : PatchBundleSour
} }
} }
integrations?.let { integrations?.let {
Files.copy(it, this@LocalPatchBundle.integrationsFile.toPath(), StandardCopyOption.REPLACE_EXISTING) Files.copy(
it,
this@LocalPatchBundle.integrationsFile.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
} }
} }
reload() reload()?.also {
saveVersion(it.readManifestAttribute("Version"), null)
}
} }
} }

View File

@ -1,12 +1,22 @@
package app.revanced.manager.domain.bundles package app.revanced.manager.domain.bundles
import android.app.Application
import android.util.Log import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository
import app.revanced.manager.patcher.patch.PatchBundle import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File import java.io.File
import java.io.OutputStream import java.io.OutputStream
@ -14,13 +24,21 @@ import java.io.OutputStream
* A [PatchBundle] source. * A [PatchBundle] source.
*/ */
@Stable @Stable
sealed class PatchBundleSource(val name: String, val uid: Int, directory: File) { sealed class PatchBundleSource(initialName: String, val uid: Int, directory: File) : KoinComponent {
protected val configRepository: PatchBundlePersistenceRepository by inject()
private val app: Application by inject()
protected val patchesFile = directory.resolve("patches.jar") protected val patchesFile = directory.resolve("patches.jar")
protected val integrationsFile = directory.resolve("integrations.apk") protected val integrationsFile = directory.resolve("integrations.apk")
private val _state = MutableStateFlow(load()) private val _state = MutableStateFlow(load())
val state = _state.asStateFlow() val state = _state.asStateFlow()
private val _nameFlow = MutableStateFlow(initialName)
val nameFlow =
_nameFlow.map { it.ifEmpty { app.getString(if (isDefault) R.string.bundle_name_default else R.string.bundle_name_fallback) } }
suspend fun getName() = nameFlow.first()
/** /**
* Returns true if the bundle has been downloaded to local storage. * Returns true if the bundle has been downloaded to local storage.
*/ */
@ -42,13 +60,38 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File)
return try { return try {
State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists))) State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists)))
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(tag, "Failed to load patch bundle $name", t) Log.e(tag, "Failed to load patch bundle with UID $uid", t)
State.Failed(t) State.Failed(t)
} }
} }
fun reload() { suspend fun reload(): PatchBundle? {
_state.value = load() val newState = load()
_state.value = newState
val bundle = newState.patchBundleOrNull()
// Try to read the name from the patch bundle manifest if the bundle does not have a name.
if (bundle != null && _nameFlow.value.isEmpty()) {
bundle.readManifestAttribute("Name")?.let { setName(it) }
}
return bundle
}
/**
* Create a flow that emits the [app.revanced.manager.data.room.bundles.BundleProperties] of this [PatchBundleSource].
* The flow will emit null if the associated [PatchBundleSource] is deleted.
*/
fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default)
suspend fun getProps() = propsFlow().first()!!
suspend fun currentVersion() = getProps().versionInfo
protected suspend fun saveVersion(patches: String?, integrations: String?) =
configRepository.updateVersion(uid, patches, integrations)
suspend fun setName(name: String) {
configRepository.setName(uid, name)
_nameFlow.value = name
} }
sealed interface State { sealed interface State {
@ -61,9 +104,12 @@ sealed class PatchBundleSource(val name: String, val uid: Int, directory: File)
} }
} }
companion object { companion object Extensions {
val PatchBundleSource.isDefault get() = uid == 0 val PatchBundleSource.isDefault inline get() = uid == 0
val PatchBundleSource.asRemoteOrNull get() = this as? RemotePatchBundle val PatchBundleSource.asRemoteOrNull inline get() = this as? RemotePatchBundle
fun PatchBundleSource.propsOrNullFlow() = asRemoteOrNull?.propsFlow() ?: flowOf(null) val PatchBundleSource.nameState
@Composable inline get() = nameFlow.collectAsStateWithLifecycle(
""
)
} }
} }

View File

@ -2,7 +2,6 @@ package app.revanced.manager.domain.bundles
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import app.revanced.manager.data.room.bundles.VersionInfo import app.revanced.manager.data.room.bundles.VersionInfo
import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository
import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType
import app.revanced.manager.network.dto.BundleAsset import app.revanced.manager.network.dto.BundleAsset
@ -15,17 +14,14 @@ import io.ktor.client.request.url
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import java.io.File import java.io.File
@Stable @Stable
sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) : sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) :
PatchBundleSource(name, id, directory), KoinComponent { PatchBundleSource(name, id, directory) {
private val configRepository: PatchBundlePersistenceRepository by inject()
protected val http: HttpService by inject() protected val http: HttpService by inject()
protected abstract suspend fun getLatestInfo(): BundleInfo protected abstract suspend fun getLatestInfo(): BundleInfo
@ -70,17 +66,11 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo
true true
} }
private suspend fun currentVersion() = configRepository.getProps(uid).first().versionInfo
private suspend fun saveVersion(patches: String, integrations: String) =
configRepository.updateVersion(uid, patches, integrations)
suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) { suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) {
arrayOf(patchesFile, integrationsFile).forEach(File::delete) arrayOf(patchesFile, integrationsFile).forEach(File::delete)
reload() reload()
} }
fun propsFlow() = configRepository.getProps(uid)
suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value) suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value)
companion object { companion object {
@ -107,7 +97,7 @@ class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
.getLatestRelease(repo) .getLatestRelease(repo)
.getOrThrow() .getOrThrow()
.let { .let {
BundleAsset(it.metadata.tag, it.findAssetByType(mime).downloadUrl) BundleAsset(it.version, it.findAssetByType(mime).downloadUrl)
} }
} }

View File

@ -1,49 +1,93 @@
package app.revanced.manager.domain.installer package app.revanced.manager.domain.installer
import android.app.Application import android.app.Application
import app.revanced.manager.service.RootConnection import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import app.revanced.manager.IRootSystemService
import app.revanced.manager.service.ManagerRootService
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import com.topjohnwu.superuser.ipc.RootService
import com.topjohnwu.superuser.nio.FileSystemManager
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.time.withTimeoutOrNull
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.time.Duration
class RootInstaller( class RootInstaller(
private val app: Application, private val app: Application,
private val rootConnection: RootConnection,
private val pm: PM private val pm: PM
) { ) : ServiceConnection {
private var remoteFS = CompletableDeferred<FileSystemManager>()
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val ipc = IRootSystemService.Stub.asInterface(service)
val binder = ipc.fileSystemService
remoteFS.complete(FileSystemManager.getRemote(binder))
}
override fun onServiceDisconnected(name: ComponentName?) {
remoteFS = CompletableDeferred()
}
private suspend fun awaitRemoteFS(): FileSystemManager {
if (remoteFS.isActive) {
withContext(Dispatchers.Main) {
val intent = Intent(app, ManagerRootService::class.java)
RootService.bind(intent, this@RootInstaller)
}
}
return withTimeoutOrNull(Duration.ofSeconds(120L)) {
remoteFS.await()
} ?: throw RootServiceException()
}
private suspend fun getShell() = with(CompletableDeferred<Shell>()) {
Shell.getShell(::complete)
await()
}
suspend fun execute(vararg commands: String) = getShell().newJob().add(*commands).exec()
fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false
fun isAppInstalled(packageName: String) = suspend fun isAppInstalled(packageName: String) =
rootConnection.remoteFS?.getFile("$modulesPath/$packageName-revanced") awaitRemoteFS().getFile("$modulesPath/$packageName-revanced").exists()
?.exists() ?: throw RootServiceException()
fun isAppMounted(packageName: String): Boolean { suspend fun isAppMounted(packageName: String) = withContext(Dispatchers.IO) {
return pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let { pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let {
Shell.cmd("mount | grep \"$it\"").exec().isSuccess execute("mount | grep \"$it\"").isSuccess
} ?: false } ?: false
} }
fun mount(packageName: String) { suspend fun mount(packageName: String) {
if (isAppMounted(packageName)) return if (isAppMounted(packageName)) return
withContext(Dispatchers.IO) {
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
?: throw Exception("Failed to load application info") ?: throw Exception("Failed to load application info")
val patchedAPK = "$modulesPath/$packageName-revanced/$packageName.apk" val patchedAPK = "$modulesPath/$packageName-revanced/$packageName.apk"
Shell.cmd("mount -o bind \"$patchedAPK\" \"$stockAPK\"").exec() execute("mount -o bind \"$patchedAPK\" \"$stockAPK\"").assertSuccess("Failed to mount APK")
.also { if (!it.isSuccess) throw Exception("Failed to mount APK") } }
} }
fun unmount(packageName: String) { suspend fun unmount(packageName: String) {
if (!isAppMounted(packageName)) return if (!isAppMounted(packageName)) return
withContext(Dispatchers.IO) {
val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir val stockAPK = pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir
?: throw Exception("Failed to load application info") ?: throw Exception("Failed to load application info")
Shell.cmd("umount -l \"$stockAPK\"").exec() execute("umount -l \"$stockAPK\"").assertSuccess("Failed to unmount APK")
.also { if (!it.isSuccess) throw Exception("Failed to unmount APK") } }
} }
suspend fun install( suspend fun install(
@ -52,9 +96,8 @@ class RootInstaller(
packageName: String, packageName: String,
version: String, version: String,
label: String label: String
) { ) = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { val remoteFS = awaitRemoteFS()
rootConnection.remoteFS?.let { remoteFS ->
val assets = app.assets val assets = app.assets
val modulePath = "$modulesPath/$packageName-revanced" val modulePath = "$modulesPath/$packageName-revanced"
@ -63,12 +106,10 @@ class RootInstaller(
stockAPK?.let { stockApp -> stockAPK?.let { stockApp ->
pm.getPackageInfo(packageName)?.let { packageInfo -> pm.getPackageInfo(packageName)?.let { packageInfo ->
if (packageInfo.versionName <= version) if (packageInfo.versionName <= version)
Shell.cmd("pm uninstall -k --user 0 $packageName").exec() execute("pm uninstall -k --user 0 $packageName").assertSuccess("Failed to uninstall stock app")
.also { if (!it.isSuccess) throw Exception("Failed to uninstall stock app") }
} }
Shell.cmd("pm install \"${stockApp.absolutePath}\"").exec() execute("pm install \"${stockApp.absolutePath}\"").assertSuccess("Failed to install stock app")
.also { if (!it.isSuccess) throw Exception("Failed to install stock app") }
} }
remoteFS.getFile(modulePath).mkdir() remoteFS.getFile(modulePath).mkdir()
@ -101,31 +142,31 @@ class RootInstaller(
} }
} }
Shell.cmd( execute(
"chmod 644 $apkPath", "chmod 644 $apkPath",
"chown system:system $apkPath", "chown system:system $apkPath",
"chcon u:object_r:apk_data_file:s0 $apkPath", "chcon u:object_r:apk_data_file:s0 $apkPath",
"chmod +x $modulePath/service.sh" "chmod +x $modulePath/service.sh"
).exec() ).assertSuccess("Failed to set file permissions")
.let { if (!it.isSuccess) throw Exception("Failed to set file permissions") }
}
} ?: throw RootServiceException()
} }
} }
fun uninstall(packageName: String) { suspend fun uninstall(packageName: String) {
rootConnection.remoteFS?.let { remoteFS -> val remoteFS = awaitRemoteFS()
if (isAppMounted(packageName)) if (isAppMounted(packageName))
unmount(packageName) unmount(packageName)
remoteFS.getFile("$modulesPath/$packageName-revanced").deleteRecursively() remoteFS.getFile("$modulesPath/$packageName-revanced").deleteRecursively()
.also { if (!it) throw Exception("Failed to delete files") } .also { if (!it) throw Exception("Failed to delete files") }
} ?: throw RootServiceException()
} }
companion object { companion object {
const val modulesPath = "/data/adb/modules" const val modulesPath = "/data/adb/modules"
private fun Shell.Result.assertSuccess(errorMessage: String) {
if (!isSuccess) throw Exception(errorMessage)
}
} }
} }
class RootServiceException: Exception("Root not available") class RootServiceException : Exception("Root not available")

View File

@ -43,21 +43,23 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
suspend fun regenerate() = withContext(Dispatchers.Default) { suspend fun regenerate() = withContext(Dispatchers.Default) {
val ks = ApkSigner.newKeyStore( val ks = ApkSigner.newKeyStore(
listOf( setOf(
ApkSigner.KeyStoreEntry( ApkSigner.KeyStoreEntry(
DEFAULT, DEFAULT DEFAULT, DEFAULT
) )
) )
) )
withContext(Dispatchers.IO) {
keystorePath.outputStream().use { keystorePath.outputStream().use {
ks.store(it, null) ks.store(it, null)
} }
}
updatePrefs(DEFAULT, DEFAULT) updatePrefs(DEFAULT, DEFAULT)
} }
suspend fun import(cn: String, pass: String, keystore: InputStream): Boolean { suspend fun import(cn: String, pass: String, keystore: InputStream): Boolean {
val keystoreData = keystore.readBytes() val keystoreData = withContext(Dispatchers.IO) { keystore.readBytes() }
try { try {
val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null) val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null)

View File

@ -13,7 +13,8 @@ class PreferencesManager(
val api = stringPreference("api_url", "https://api.revanced.app") val api = stringPreference("api_url", "https://api.revanced.app")
val multithreadingDexFileWriter = booleanPreference("multithreading_dex_file_writer", true) val multithreadingDexFileWriter = booleanPreference("multithreading_dex_file_writer", true)
val allowExperimental = booleanPreference("allow_experimental", false) val useProcessRuntime = booleanPreference("use_process_runtime", false)
val patcherProcessMemoryLimit = intPreference("process_runtime_memory_limit", 700)
val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT) val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT) val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
@ -22,7 +23,10 @@ class PreferencesManager(
val firstLaunch = booleanPreference("first_launch", true) val firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false) val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
val disablePatchVersionCompatCheck = booleanPreference("disable_patch_version_compatibility_check", false)
val disableSelectionWarning = booleanPreference("disable_selection_warning", false) val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
val enableSelectionWarningCountdown = booleanPreference("enable_selection_warning_countdown", true) val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false)
val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true)
} }

View File

@ -56,7 +56,7 @@ class EditorContext(private val prefs: MutablePreferences) {
abstract class Preference<T>( abstract class Preference<T>(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
protected val default: T val default: T
) { ) {
internal abstract fun Preferences.read(): T internal abstract fun Preferences.read(): T
internal abstract fun MutablePreferences.write(value: T) internal abstract fun MutablePreferences.write(value: T)

View File

@ -4,7 +4,7 @@ import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.apps.installed.AppliedPatch import app.revanced.manager.data.room.apps.installed.AppliedPatch
import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchSelection
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
class InstalledAppRepository( class InstalledAppRepository(
@ -16,7 +16,7 @@ class InstalledAppRepository(
suspend fun get(packageName: String) = dao.get(packageName) suspend fun get(packageName: String) = dao.get(packageName)
suspend fun getAppliedPatches(packageName: String): PatchesSelection = suspend fun getAppliedPatches(packageName: String): PatchSelection =
dao.getPatchesSelection(packageName).mapValues { (_, patches) -> patches.toSet() } dao.getPatchesSelection(packageName).mapValues { (_, patches) -> patches.toSet() }
suspend fun addOrUpdate( suspend fun addOrUpdate(
@ -24,7 +24,7 @@ class InstalledAppRepository(
originalPackageName: String, originalPackageName: String,
version: String, version: String,
installType: InstallType, installType: InstallType,
patchesSelection: PatchesSelection patchSelection: PatchSelection
) { ) {
dao.upsertApp( dao.upsertApp(
InstalledApp( InstalledApp(
@ -33,7 +33,7 @@ class InstalledAppRepository(
version = version, version = version,
installType = installType installType = installType
), ),
patchesSelection.flatMap { (uid, patches) -> patchSelection.flatMap { (uid, patches) ->
patches.map { patch -> patches.map { patch ->
AppliedPatch( AppliedPatch(
packageName = currentPackageName, packageName = currentPackageName,

View File

@ -5,7 +5,6 @@ import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.bundles.PatchBundleEntity
import app.revanced.manager.data.room.bundles.Source import app.revanced.manager.data.room.bundles.Source
import app.revanced.manager.data.room.bundles.VersionInfo import app.revanced.manager.data.room.bundles.VersionInfo
import io.ktor.http.*
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
class PatchBundlePersistenceRepository(db: AppDatabase) { class PatchBundlePersistenceRepository(db: AppDatabase) {
@ -23,7 +22,6 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
suspend fun reset() = dao.reset() suspend fun reset() = dao.reset()
suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) = suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) =
PatchBundleEntity( PatchBundleEntity(
uid = generateUid(), uid = generateUid(),
@ -37,17 +35,19 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
suspend fun delete(uid: Int) = dao.remove(uid) suspend fun delete(uid: Int) = dao.remove(uid)
suspend fun updateVersion(uid: Int, patches: String, integrations: String) = suspend fun updateVersion(uid: Int, patches: String?, integrations: String?) =
dao.updateVersion(uid, patches, integrations) dao.updateVersion(uid, patches, integrations)
suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value) suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value)
suspend fun setName(uid: Int, name: String) = dao.setName(uid, name)
fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged() fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged()
private companion object { private companion object {
val defaultSource = PatchBundleEntity( val defaultSource = PatchBundleEntity(
uid = 0, uid = 0,
name = "Main", name = "",
versionInfo = VersionInfo(), versionInfo = VersionInfo(),
source = Source.API, source = Source.API,
autoUpdate = false autoUpdate = false

View File

@ -3,6 +3,7 @@ package app.revanced.manager.domain.repository
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import app.revanced.library.PatchUtils
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.bundles.PatchBundleEntity
@ -12,6 +13,8 @@ import app.revanced.manager.data.room.bundles.Source as SourceInfo
import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.flatMapLatestAndCombine import app.revanced.manager.util.flatMapLatestAndCombine
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.uiSafe
@ -29,6 +32,7 @@ class PatchBundleRepository(
private val app: Application, private val app: Application,
private val persistenceRepo: PatchBundlePersistenceRepository, private val persistenceRepo: PatchBundlePersistenceRepository,
private val networkInfo: NetworkInfo, private val networkInfo: NetworkInfo,
private val prefs: PreferencesManager,
) { ) {
private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE) private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
@ -47,6 +51,37 @@ class PatchBundleRepository(
it.state.map { state -> it.uid to state } it.state.map { state -> it.uid to state }
} }
val suggestedVersions = bundles.map {
val allPatches =
it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()
PatchUtils.getMostCommonCompatibleVersions(allPatches, countUnusedPatches = true)
.mapValues { (_, versions) ->
if (versions.keys.size < 2)
return@mapValues versions.keys.firstOrNull()
// The entries are ordered from most compatible to least compatible.
// If there are entries with the same number of compatible patches, older versions will be first, which is undesirable.
// This means we have to pick the last entry we find that has the highest patch count.
// The order may change in future versions of ReVanced Library.
var currentHighestPatchCount = -1
versions.entries.last { (_, patchCount) ->
if (patchCount >= currentHighestPatchCount) {
currentHighestPatchCount = patchCount
true
} else false
}.key
}
}
suspend fun isVersionAllowed(packageName: String, version: String) =
withContext(Dispatchers.Default) {
if (!prefs.suggestedVersionSafeguard.get()) return@withContext true
val suggestedVersion = suggestedVersions.first()[packageName] ?: return@withContext true
suggestedVersion == version
}
/** /**
* Get the directory of the [PatchBundleSource] with the specified [uid], creating it if needed. * Get the directory of the [PatchBundleSource] with the specified [uid], creating it if needed.
*/ */
@ -102,16 +137,16 @@ class PatchBundleRepository(
private fun addBundle(patchBundle: PatchBundleSource) = private fun addBundle(patchBundle: PatchBundleSource) =
_sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } } _sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
suspend fun createLocal(name: String, patches: InputStream, integrations: InputStream?) { suspend fun createLocal(patches: InputStream, integrations: InputStream?) = withContext(Dispatchers.Default) {
val id = persistenceRepo.create(name, SourceInfo.Local).uid val uid = persistenceRepo.create("", SourceInfo.Local).uid
val bundle = LocalPatchBundle(name, id, directoryOf(id)) val bundle = LocalPatchBundle("", uid, directoryOf(uid))
bundle.replace(patches, integrations) bundle.replace(patches, integrations)
addBundle(bundle) addBundle(bundle)
} }
suspend fun createRemote(name: String, url: String, autoUpdate: Boolean) { suspend fun createRemote(url: String, autoUpdate: Boolean) = withContext(Dispatchers.Default) {
val entity = persistenceRepo.create(name, SourceInfo.from(url), autoUpdate) val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate)
addBundle(entity.load()) addBundle(entity.load())
} }
@ -139,8 +174,8 @@ class PatchBundleRepository(
getBundlesByType<RemotePatchBundle>().forEach { getBundlesByType<RemotePatchBundle>().forEach {
launch { launch {
if (!it.propsFlow().first().autoUpdate) return@launch if (!it.getProps().autoUpdate) return@launch
Log.d(tag, "Updating patch bundle: ${it.name}") Log.d(tag, "Updating patch bundle: ${it.getName()}")
it.update() it.update()
} }
} }

View File

@ -1,18 +1,14 @@
package app.revanced.manager.domain.repository package app.revanced.manager.domain.repository
import android.util.Log
import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.options.Option import app.revanced.manager.data.room.options.Option
import app.revanced.manager.data.room.options.OptionGroup import app.revanced.manager.data.room.options.OptionGroup
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.tag
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.intOrNull
class PatchOptionsRepository(db: AppDatabase) { class PatchOptionsRepository(db: AppDatabase) {
private val dao = db.optionDao() private val dao = db.optionDao()
@ -24,16 +20,34 @@ class PatchOptionsRepository(db: AppDatabase) {
packageName = packageName packageName = packageName
).also { dao.createOptionGroup(it) }.uid ).also { dao.createOptionGroup(it) }.uid
suspend fun getOptions(packageName: String): Options { suspend fun getOptions(
packageName: String,
bundlePatches: Map<Int, Map<String, PatchInfo>>
): Options {
val options = dao.getOptions(packageName) val options = dao.getOptions(packageName)
// Bundle -> Patches // Bundle -> Patches
return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) { return buildMap<Int, MutableMap<String, MutableMap<String, Any?>>>(options.size) {
options.forEach { (sourceUid, bundlePatchOptionsList) -> options.forEach { (sourceUid, bundlePatchOptionsList) ->
// Patches -> Patch options // Patches -> Patch options
this[sourceUid] = bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, option -> this[sourceUid] =
val patchOptions = bundlePatchOptions.getOrPut(option.patchName, ::mutableMapOf) bundlePatchOptionsList.fold(mutableMapOf()) { bundlePatchOptions, dbOption ->
val deserializedPatchOptions =
bundlePatchOptions.getOrPut(dbOption.patchName, ::mutableMapOf)
patchOptions[option.key] = deserialize(option.value) val option =
bundlePatches[sourceUid]?.get(dbOption.patchName)?.options?.find { it.key == dbOption.key }
if (option != null) {
try {
deserializedPatchOptions[option.key] =
dbOption.value.deserializeFor(option)
} catch (e: Option.SerializationException) {
Log.w(
tag,
"Option ${dbOption.patchName}:${option.key} could not be deserialized",
e
)
}
}
bundlePatchOptions bundlePatchOptions
} }
@ -47,8 +61,12 @@ class PatchOptionsRepository(db: AppDatabase) {
groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) -> groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) ->
patchOptions.mapNotNull { (key, value) -> patchOptions.mapNotNull { (key, value) ->
val serialized = serialize(value) val serialized = try {
?: return@mapNotNull null // Don't save options that we can't serialize. Option.SerializedValue.fromValue(value)
} catch (e: Option.SerializationException) {
Log.e(tag, "Option $patchName:$key could not be serialized", e)
return@mapNotNull null
}
Option(groupId, patchName, key, serialized) Option(groupId, patchName, key, serialized)
} }
@ -61,29 +79,4 @@ class PatchOptionsRepository(db: AppDatabase) {
suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName) suspend fun clearOptionsForPackage(packageName: String) = dao.clearForPackage(packageName)
suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid) suspend fun clearOptionsForPatchBundle(uid: Int) = dao.clearForPatchBundle(uid)
suspend fun reset() = dao.reset() suspend fun reset() = dao.reset()
private companion object {
fun deserialize(value: String): Any? {
val primitive = Json.decodeFromString<JsonPrimitive>(value)
return when {
primitive.isString -> primitive.content
primitive is JsonNull -> null
else -> primitive.booleanOrNull ?: primitive.intOrNull ?: primitive.floatOrNull
}
}
fun serialize(value: Any?): String? {
val primitive = when (value) {
null -> JsonNull
is String -> JsonPrimitive(value)
is Int -> JsonPrimitive(value)
is Float -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
else -> return null
}
return Json.encodeToString(primitive)
}
}
} }

View File

@ -1,8 +1,10 @@
package app.revanced.manager.network.api package app.revanced.manager.network.api
import android.os.Build
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.network.dto.ReVancedRelease import app.revanced.manager.network.dto.ReVancedRelease
import app.revanced.manager.network.service.ReVancedService import app.revanced.manager.network.service.ReVancedService
import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.network.utils.transform import app.revanced.manager.network.utils.transform
class ReVancedAPI( class ReVancedAPI(
@ -13,12 +15,23 @@ class ReVancedAPI(
suspend fun getContributors() = service.getContributors(apiUrl()).transform { it.repositories } suspend fun getContributors() = service.getContributors(apiUrl()).transform { it.repositories }
suspend fun getLatestRelease(name: String) = service.getLatestRelease(apiUrl(), name).transform { it.release } suspend fun getLatestRelease(name: String) =
service.getLatestRelease(apiUrl(), name).transform { it.release }
suspend fun getReleases(name: String) =
service.getReleases(apiUrl(), name).transform { it.releases }
suspend fun getAppUpdate() =
getLatestRelease("revanced-manager")
.getOrThrow()
.takeIf { it.version != Build.VERSION.RELEASE }
suspend fun getInfo(api: String? = null) = service.getInfo(api ?: apiUrl()).transform { it.info }
suspend fun getReleases(name: String) = service.getReleases(apiUrl(), name).transform { it.releases }
companion object Extensions { companion object Extensions {
fun ReVancedRelease.findAssetByType(mime: String) = assets.singleOrNull { it.contentType == mime } ?: throw MissingAssetException(mime) fun ReVancedRelease.findAssetByType(mime: String) =
assets.singleOrNull { it.contentType == mime } ?: throw MissingAssetException(mime)
} }
} }

View File

@ -35,7 +35,7 @@ class APKMirror : AppDownloader, KoinComponent {
) )
private suspend fun getAppLink(packageName: String): String { private suspend fun getAppLink(packageName: String): String {
val searchResults = httpClient.getHtml { url("$apkMirror/?post_type=app_release&searchtype=app&s=$packageName") } val searchResults = httpClient.getHtml { url("$APK_MIRROR/?post_type=app_release&searchtype=app&s=$packageName") }
.div { .div {
withId = "content" withId = "content"
findFirst { findFirst {
@ -66,7 +66,7 @@ class APKMirror : AppDownloader, KoinComponent {
} }
return searchResults.find { url -> return searchResults.find { url ->
httpClient.getHtml { url(apkMirror + url) } httpClient.getHtml { url(APK_MIRROR + url) }
.div { .div {
withId = "primary" withId = "primary"
findFirst { findFirst {
@ -90,11 +90,12 @@ class APKMirror : AppDownloader, KoinComponent {
override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow<AppDownloader.App> { override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow<AppDownloader.App> {
// Vanced music uses the same package name so we have to hardcode... // We have to hardcode some apps since there are multiple apps with that package name
val appCategory = if (packageName == "com.google.android.apps.youtube.music") val appCategory = when (packageName) {
"youtube-music" "com.google.android.apps.youtube.music" -> "youtube-music"
else "com.google.android.youtube" -> "youtube"
getAppLink(packageName).split("/")[3] else -> getAppLink(packageName).split("/")[3]
}
var page = 1 var page = 1
@ -107,7 +108,7 @@ class APKMirror : AppDownloader, KoinComponent {
page <= 1 page <= 1
) { ) {
httpClient.getHtml { httpClient.getHtml {
url("$apkMirror/uploads/page/$page/") url("$APK_MIRROR/uploads/page/$page/")
parameter("appcategory", appCategory) parameter("appcategory", appCategory)
}.div { }.div {
withClass = "widget_appmanager_recentpostswidget" withClass = "widget_appmanager_recentpostswidget"
@ -172,7 +173,7 @@ class APKMirror : AppDownloader, KoinComponent {
preferSplit: Boolean, preferSplit: Boolean,
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit
) { ) {
val variants = httpClient.getHtml { url(apkMirror + downloadLink) } val variants = httpClient.getHtml { url(APK_MIRROR + downloadLink) }
.div { .div {
withClass = "variants-table" withClass = "variants-table"
findFirst { // list of variants findFirst { // list of variants
@ -217,7 +218,7 @@ class APKMirror : AppDownloader, KoinComponent {
if (variant.apkType == APKType.BUNDLE) throw Exception("Split apks are not supported yet") // TODO if (variant.apkType == APKType.BUNDLE) throw Exception("Split apks are not supported yet") // TODO
val downloadPage = httpClient.getHtml { url(apkMirror + variant.link) } val downloadPage = httpClient.getHtml { url(APK_MIRROR + variant.link) }
.a { .a {
withClass = "downloadButton" withClass = "downloadButton"
findFirst { findFirst {
@ -225,7 +226,7 @@ class APKMirror : AppDownloader, KoinComponent {
} }
} }
val downloadLink = httpClient.getHtml { url(apkMirror + downloadPage) } val downloadLink = httpClient.getHtml { url(APK_MIRROR + downloadPage) }
.form { .form {
withId = "filedownload" withId = "filedownload"
findFirst { findFirst {
@ -250,7 +251,7 @@ class APKMirror : AppDownloader, KoinComponent {
try { try {
httpClient.download(targetFile) { httpClient.download(targetFile) {
url(apkMirror + downloadLink) url(APK_MIRROR + downloadLink)
onDownload { bytesSentTotal, contentLength -> onDownload { bytesSentTotal, contentLength ->
onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10)) onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
} }
@ -268,7 +269,7 @@ class APKMirror : AppDownloader, KoinComponent {
} }
companion object { companion object {
const val apkMirror = "https://www.apkmirror.com" const val APK_MIRROR = "https://www.apkmirror.com"
val supportedArches = listOf("universal", "noarch") + SUPPORTED_ABIS val supportedArches = listOf("universal", "noarch") + SUPPORTED_ABIS
} }

View File

@ -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,
)

View File

@ -17,7 +17,9 @@ data class ReVancedReleases(
data class ReVancedRelease( data class ReVancedRelease(
val metadata: ReVancedReleaseMeta, val metadata: ReVancedReleaseMeta,
val assets: List<Asset> val assets: List<Asset>
) ) {
val version get() = metadata.tag
}
@Serializable @Serializable
data class ReVancedReleaseMeta( data class ReVancedReleaseMeta(

View File

@ -1,10 +1,12 @@
package app.revanced.manager.network.service package app.revanced.manager.network.service
import app.revanced.manager.network.dto.ReVancedLatestRelease
import app.revanced.manager.network.dto.ReVancedGitRepositories import app.revanced.manager.network.dto.ReVancedGitRepositories
import app.revanced.manager.network.dto.ReVancedInfo
import app.revanced.manager.network.dto.ReVancedInfoParent
import app.revanced.manager.network.dto.ReVancedLatestRelease
import app.revanced.manager.network.dto.ReVancedReleases import app.revanced.manager.network.dto.ReVancedReleases
import app.revanced.manager.network.utils.APIResponse import app.revanced.manager.network.utils.APIResponse
import io.ktor.client.request.* import io.ktor.client.request.url
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -31,4 +33,11 @@ class ReVancedService(
url("$api/contributors") url("$api/contributors")
} }
} }
suspend fun getInfo(api: String): APIResponse<ReVancedInfoParent> =
withContext(Dispatchers.IO) {
client.request {
url("$api/v2/info")
}
}
} }

View File

@ -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) }
}
}

View File

@ -1,9 +1,12 @@
package app.revanced.manager.patcher package app.revanced.manager.patcher
import app.revanced.library.ApkUtils import android.content.Context
import app.revanced.manager.ui.viewmodel.ManagerLogger import app.revanced.library.ApkUtils.applyTo
import app.revanced.manager.R
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.ui.model.State
import app.revanced.patcher.Patcher import app.revanced.patcher.Patcher
import app.revanced.patcher.PatcherOptions import app.revanced.patcher.PatcherConfig
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchResult import app.revanced.patcher.patch.PatchResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -12,7 +15,6 @@ import java.io.Closeable
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
import java.util.logging.Logger
internal typealias PatchList = List<Patch<*>> internal typealias PatchList = List<Patch<*>>
@ -21,67 +23,108 @@ class Session(
frameworkDir: String, frameworkDir: String,
aaptPath: String, aaptPath: String,
multithreadingDexFileWriter: Boolean, multithreadingDexFileWriter: Boolean,
private val logger: ManagerLogger, private val androidContext: Context,
private val logger: Logger,
private val input: File, private val input: File,
private val onStepSucceeded: suspend () -> Unit private val onPatchCompleted: () -> Unit,
private val onProgress: (name: String?, state: State?, message: String?) -> Unit
) : Closeable { ) : Closeable {
private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
onProgress(name, state, message)
private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() } private val tempDir = File(cacheDir).resolve("patcher").also { it.mkdirs() }
private val patcher = Patcher( private val patcher = Patcher(
PatcherOptions( PatcherConfig(
inputFile = input, apkFile = input,
resourceCachePath = tempDir.resolve("aapt-resources"), temporaryFilesPath = tempDir,
frameworkFileDirectory = frameworkDir, frameworkFileDirectory = frameworkDir,
aaptBinaryPath = aaptPath, aaptBinaryPath = aaptPath,
multithreadingDexFileWriter = multithreadingDexFileWriter, multithreadingDexFileWriter = multithreadingDexFileWriter,
) )
) )
private suspend fun Patcher.applyPatchesVerbose(selectedPatches: PatchList) {
var nextPatchIndex = 0
updateProgress(
name = androidContext.getString(R.string.executing_patch, selectedPatches[nextPatchIndex]),
state = State.RUNNING
)
private suspend fun Patcher.applyPatchesVerbose() {
this.apply(true).collect { (patch, exception) -> this.apply(true).collect { (patch, exception) ->
if (exception == null) { if (patch !in selectedPatches) return@collect
logger.info("$patch succeeded")
onStepSucceeded() if (exception != null) {
return@collect updateProgress(
} name = androidContext.getString(R.string.failed_to_execute_patch, patch.name),
logger.error("$patch failed:") state = State.FAILED,
message = exception.stackTraceToString()
)
logger.error("${patch.name} failed:")
logger.error(exception.stackTraceToString()) logger.error(exception.stackTraceToString())
throw exception 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>) { suspend fun run(output: File, selectedPatches: PatchList, integrations: List<File>) {
onStepSucceeded() // Unpacking updateProgress(state = State.COMPLETED) // Unpacking
Logger.getLogger("").apply {
java.util.logging.Logger.getLogger("").apply {
handlers.forEach { handlers.forEach {
it.close() it.close()
removeHandler(it) removeHandler(it)
} }
addHandler(logger) addHandler(logger.handler)
} }
with(patcher) { with(patcher) {
logger.info("Merging integrations") logger.info("Merging integrations")
acceptIntegrations(integrations) acceptIntegrations(integrations.toSet())
acceptPatches(selectedPatches) acceptPatches(selectedPatches.toSet())
onStepSucceeded() // Merging
logger.info("Applying patches...") logger.info("Applying patches...")
applyPatchesVerbose() applyPatchesVerbose(selectedPatches.sortedBy { it.name })
} }
logger.info("Writing patched files...") logger.info("Writing patched files...")
val result = patcher.get() val result = patcher.get()
val aligned = tempDir.resolve("aligned.apk") val patched = tempDir.resolve("result.apk")
ApkUtils.copyAligned(input, aligned, result) withContext(Dispatchers.IO) {
Files.copy(input.toPath(), patched.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
result.applyTo(patched)
logger.info("Patched apk saved to $aligned") logger.info("Patched apk saved to $patched")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Files.move(aligned.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING) Files.move(patched.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
} }
onStepSucceeded() // Saving updateProgress(state = State.COMPLETED) // Saving
} }
override fun close() { override fun close() {
@ -90,7 +133,7 @@ class Session(
} }
companion object { companion object {
operator fun PatchResult.component1() = patch.name operator fun PatchResult.component1() = patch
operator fun PatchResult.component2() = exception operator fun PatchResult.component2() = exception
} }
} }

View File

@ -1,18 +1,12 @@
package app.revanced.manager.patcher.aapt package app.revanced.manager.patcher.aapt
import android.content.Context import android.content.Context
import app.revanced.manager.patcher.LibraryResolver
import android.os.Build.SUPPORTED_ABIS as DEVICE_ABIS import android.os.Build.SUPPORTED_ABIS as DEVICE_ABIS
import java.io.File object Aapt : LibraryResolver() {
private val WORKING_ABIS = setOf("arm64-v8a", "x86", "x86_64", "armeabi-v7a")
object Aapt {
private val WORKING_ABIS = setOf("arm64-v8a", "x86", "x86_64")
fun supportsDevice() = (DEVICE_ABIS intersect WORKING_ABIS).isNotEmpty() fun supportsDevice() = (DEVICE_ABIS intersect WORKING_ABIS).isNotEmpty()
fun binary(context: Context): File? { fun binary(context: Context) = findLibrary(context, "aapt")
return File(context.applicationInfo.nativeLibraryDir).resolveAapt()
}
} }
private fun File.resolveAapt() =
list { _, f -> !File(f).isDirectory && f.contains("aapt") }?.firstOrNull()?.let { resolve(it) }

View File

@ -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,
}

View File

@ -5,20 +5,21 @@ import app.revanced.manager.util.tag
import app.revanced.patcher.PatchBundleLoader import app.revanced.patcher.PatchBundleLoader
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.Patch
import java.io.File import java.io.File
import java.io.IOException
import java.util.jar.JarFile
class PatchBundle(private val loader: Iterable<Patch<*>>, val integrations: File?) { class PatchBundle(val patchesJar: File, val integrations: File?) {
constructor(bundleJar: File, integrations: File?) : this( private val loader = object : Iterable<Patch<*>> {
object : Iterable<Patch<*>> {
private fun load(): Iterable<Patch<*>> { private fun load(): Iterable<Patch<*>> {
bundleJar.setReadOnly() patchesJar.setReadOnly()
return PatchBundleLoader.Dex(bundleJar, optimizedDexDirectory = null) return PatchBundleLoader.Dex(patchesJar, optimizedDexDirectory = null)
} }
override fun iterator(): Iterator<Patch<*>> = load().iterator() override fun iterator(): Iterator<Patch<*>> = load().iterator()
}, }
integrations
) { init {
Log.d(tag, "Loaded patch bundle: $bundleJar") Log.d(tag, "Loaded patch bundle: $patchesJar")
} }
/** /**
@ -26,6 +27,17 @@ class PatchBundle(private val loader: Iterable<Patch<*>>, val integrations: File
*/ */
val patches = loader.map(::PatchInfo) val patches = loader.map(::PatchInfo)
/**
* The [java.util.jar.Manifest] of [patchesJar].
*/
private val manifest = try {
JarFile(patchesJar).use { it.manifest }
} catch (_: IOException) {
null
}
fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)
/** /**
* Load all patches compatible with the specified package. * Load all patches compatible with the specified package.
*/ */

View File

@ -1,7 +1,9 @@
package app.revanced.manager.patcher.patch package app.revanced.manager.patcher.patch
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import app.revanced.patcher.data.ResourceContext
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.ResourcePatch
import app.revanced.patcher.patch.options.PatchOption import app.revanced.patcher.patch.options.PatchOption
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
@ -13,7 +15,7 @@ data class PatchInfo(
val description: String?, val description: String?,
val include: Boolean, val include: Boolean,
val compatiblePackages: ImmutableList<CompatiblePackage>?, val compatiblePackages: ImmutableList<CompatiblePackage>?,
val options: ImmutableList<Option>? val options: ImmutableList<Option<*>>?
) { ) {
constructor(patch: Patch<*>) : this( constructor(patch: Patch<*>) : this(
patch.name.orEmpty(), patch.name.orEmpty(),
@ -37,6 +39,23 @@ data class PatchInfo(
pkg.versions == null || pkg.versions.contains(versionName) pkg.versions == null || pkg.versions.contains(versionName)
} }
} }
/**
* Create a fake [Patch] with the same metadata as the [PatchInfo] instance.
* The resulting patch cannot be executed.
* This is necessary because some functions in ReVanced Library only accept full [Patch] objects.
*/
fun toPatcherPatch(): Patch<*> = object : ResourcePatch(
name = name,
description = description,
compatiblePackages = compatiblePackages
?.map(app.revanced.manager.patcher.patch.CompatiblePackage::toPatcherCompatiblePackage)
?.toSet(),
use = include,
) {
override fun execute(context: ResourceContext) =
throw Exception("Metadata patches cannot be executed")
}
} }
@Immutable @Immutable
@ -48,23 +67,35 @@ data class CompatiblePackage(
pkg.name, pkg.name,
pkg.versions?.toImmutableSet() pkg.versions?.toImmutableSet()
) )
/**
* Converts this [CompatiblePackage] into a [Patch.CompatiblePackage] from patcher.
*/
fun toPatcherCompatiblePackage() = Patch.CompatiblePackage(
name = packageName,
versions = versions,
)
} }
@Immutable @Immutable
data class Option( data class Option<T>(
val title: String, val title: String,
val key: String, val key: String,
val description: String, val description: String,
val required: Boolean, val required: Boolean,
val type: String, val type: String,
val default: Any? val default: T?,
val presets: Map<String, T?>?,
val validator: (T?) -> Boolean,
) { ) {
constructor(option: PatchOption<*>) : this( constructor(option: PatchOption<T>) : this(
option.title ?: option.key, option.title ?: option.key,
option.key, option.key,
option.description.orEmpty(), option.description.orEmpty(),
option.required, option.required,
option.valueType, option.valueType,
option.default, option.default,
option.values,
{ option.validator(option, it) },
) )
} }

View File

@ -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
)
}
}
}

View File

@ -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()
}

View File

@ -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,
)
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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)
}

View File

@ -23,32 +23,29 @@ import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.worker.Worker import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.Session import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.runtime.CoroutineRuntime
import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.ManagerLogger import app.revanced.manager.ui.model.State
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import java.io.File import java.io.File
import java.io.FileNotFoundException
typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit
class PatcherWorker( class PatcherWorker(
context: Context, context: Context,
parameters: WorkerParameters parameters: WorkerParameters
) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent { ) : Worker<PatcherWorker.Args>(context, parameters), KoinComponent {
private val patchBundleRepository: PatchBundleRepository by inject()
private val workerRepository: WorkerRepository by inject() private val workerRepository: WorkerRepository by inject()
private val prefs: PreferencesManager by inject() private val prefs: PreferencesManager by inject()
private val keystoreManager: KeystoreManager by inject() private val keystoreManager: KeystoreManager by inject()
@ -61,20 +58,17 @@ class PatcherWorker(
data class Args( data class Args(
val input: SelectedApp, val input: SelectedApp,
val output: String, val output: String,
val selectedPatches: PatchesSelection, val selectedPatches: PatchSelection,
val options: Options, val options: Options,
val progress: MutableStateFlow<ImmutableList<Step>>, val logger: Logger,
val logger: ManagerLogger, val downloadProgress: MutableStateFlow<Pair<Float, Float>?>,
val setInputFile: (File) -> Unit val patchesProgress: MutableStateFlow<Pair<Int, Int>>,
val setInputFile: (File) -> Unit,
val onProgress: ProgressEventHandler
) { ) {
val packageName get() = input.packageName val packageName get() = input.packageName
} }
companion object {
private const val logPrefix = "[Worker]:"
private fun String.logFmt() = "$logPrefix $this"
}
override suspend fun getForegroundInfo() = override suspend fun getForegroundInfo() =
ForegroundInfo( ForegroundInfo(
1, 1,
@ -107,8 +101,6 @@ class PatcherWorker(
return Result.failure() return Result.failure()
} }
val args = workerRepository.claimInput(this)
try { try {
// This does not always show up for some reason. // This does not always show up for some reason.
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
@ -117,12 +109,14 @@ class PatcherWorker(
} }
val wakeLock: PowerManager.WakeLock = val wakeLock: PowerManager.WakeLock =
(applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager).run { (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher").apply { .newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher")
.apply {
acquire(10 * 60 * 1000L) acquire(10 * 60 * 1000L)
Log.d(tag, "Acquired wakelock.") Log.d(tag, "Acquired wakelock.")
} }
}
val args = workerRepository.claimInput(this)
return try { return try {
runPatcher(args) runPatcher(args)
@ -132,39 +126,13 @@ class PatcherWorker(
} }
private suspend fun runPatcher(args: Args): Result { private suspend fun runPatcher(args: Args): Result {
val aaptPath =
Aapt.binary(applicationContext)?.absolutePath
?: throw FileNotFoundException("Could not resolve aapt.")
val frameworkPath = fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
applicationContext.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath args.onProgress(name, state, message)
val bundles = patchBundleRepository.bundles.first()
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
val downloadProgress = MutableStateFlow<Pair<Float, Float>?>(null)
val progressManager =
PatcherProgressManager(
applicationContext,
args.selectedPatches.flatMap { it.value },
args.input,
downloadProgress
)
val progressFlow = args.progress
fun updateProgress(advanceCounter: Boolean = true) {
if (advanceCounter) {
progressManager.success()
}
progressFlow.value = progressManager.getProgress().toImmutableList()
}
val patchedApk = fs.tempDir.resolve("patched.apk") val patchedApk = fs.tempDir.resolve("patched.apk")
return try { return try {
if (args.input is SelectedApp.Installed) { if (args.input is SelectedApp.Installed) {
installedAppRepository.get(args.packageName)?.let { installedAppRepository.get(args.packageName)?.let {
if (it.installType == InstallType.ROOT) { if (it.installType == InstallType.ROOT) {
@ -173,41 +141,15 @@ class PatcherWorker(
} }
} }
// TODO: consider passing all the classes directly now that the input no longer needs to be serializable.
val selectedBundles = args.selectedPatches.keys
val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
.mapValues { (_, bundle) -> bundle.patchClasses(args.packageName) }
// Set all patch options.
args.options.forEach { (bundle, bundlePatchOptions) ->
val patches = allPatches[bundle] ?: return@forEach
bundlePatchOptions.forEach { (patchName, configuredPatchOptions) ->
val patchOptions = patches.single { it.name == patchName }.options
configuredPatchOptions.forEach { (key, value) ->
patchOptions[key] = value
}
}
}
val patches = args.selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { selected.contains(it.name) }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
}
// Ensure they are in the correct order so we can track progress properly.
progressManager.replacePatchesList(patches.map { it.name.orEmpty() })
updateProgress() // Loading patches
val inputFile = when (val selectedApp = args.input) { val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> { is SelectedApp.Download -> {
downloadedAppRepository.download( downloadedAppRepository.download(
selectedApp.app, selectedApp.app,
prefs.preferSplits.get(), prefs.preferSplits.get(),
onDownload = { downloadProgress.emit(it) } onDownload = { args.downloadProgress.emit(it) }
).also { ).also {
args.setInputFile(it) args.setInputFile(it)
updateProgress() // Downloading updateProgress(state = State.COMPLETED) // Download APK
} }
} }
@ -215,31 +157,50 @@ class PatcherWorker(
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo.sourceDir) is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo.sourceDir)
} }
Session( val runtime = if (prefs.useProcessRuntime.get()) {
fs.tempDir.absolutePath, ProcessRuntime(applicationContext)
frameworkPath, } else {
aaptPath, CoroutineRuntime(applicationContext)
prefs.multithreadingDexFileWriter.get(),
args.logger,
inputFile,
onStepSucceeded = ::updateProgress
).use { session ->
session.run(patchedApk, patches, integrations)
} }
runtime.execute(
inputFile.absolutePath,
patchedApk.absolutePath,
args.packageName,
args.selectedPatches,
args.options,
args.logger,
onPatchCompleted = {
args.patchesProgress.update { (completed, total) ->
completed + 1 to total
}
},
args.onProgress
)
keystoreManager.sign(patchedApk, File(args.output)) keystoreManager.sign(patchedApk, File(args.output))
updateProgress() // Signing updateProgress(state = State.COMPLETED) // Signing
Log.i(tag, "Patching succeeded".logFmt()) Log.i(tag, "Patching succeeded".logFmt())
progressManager.success()
Result.success() Result.success()
} catch (e: ProcessRuntime.RemoteFailureException) {
Log.e(tag, "An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt())
updateProgress(state = State.FAILED, message = e.originalStackTrace)
Result.failure()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "Exception while patching".logFmt(), e) Log.e(tag, "An exception occurred while patching".logFmt(), e)
progressManager.failure(e) updateProgress(state = State.FAILED, message = e.stackTraceToString())
Result.failure() Result.failure()
} finally { } finally {
updateProgress(false)
patchedApk.delete() patchedApk.delete()
if (args.input is SelectedApp.Local && args.input.temporary) {
args.input.file.delete()
} }
} }
}
companion object {
private const val LOG_PREFIX = "[Worker]"
private fun String.logFmt() = "$LOG_PREFIX $this"
}
} }

View File

@ -1,8 +1,6 @@
package app.revanced.manager.service package app.revanced.manager.service
import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder import android.os.IBinder
import app.revanced.manager.IRootSystemService import app.revanced.manager.IRootSystemService
import com.topjohnwu.superuser.ipc.RootService import com.topjohnwu.superuser.ipc.RootService
@ -14,23 +12,5 @@ class ManagerRootService : RootService() {
FileSystemManager.getService() FileSystemManager.getService()
} }
override fun onBind(intent: Intent): IBinder { override fun onBind(intent: Intent): IBinder = RootSystemService()
return RootSystemService()
}
}
class RootConnection : ServiceConnection {
var remoteFS: FileSystemManager? = null
private set
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val ipc = IRootSystemService.Stub.asInterface(service)
val binder = ipc.fileSystemService
remoteFS = FileSystemManager.getRemote(binder)
}
override fun onServiceDisconnected(name: ComponentName?) {
remoteFS = null
}
} }

View File

@ -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)

View File

@ -16,7 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.accompanist.placeholder.placeholder import io.github.fornewid.placeholder.material3.placeholder
@Composable @Composable
fun AppIcon( fun AppIcon(
@ -33,11 +33,9 @@ fun AppIcon(
Image( Image(
image, image,
contentDescription, contentDescription,
Modifier.placeholder(visible = showPlaceHolder, color = MaterialTheme.colorScheme.inverseOnSurface, shape = RoundedCornerShape(100)).then(modifier), modifier,
colorFilter = colorFilter colorFilter = colorFilter
) )
showPlaceHolder = false
} else { } else {
AsyncImage( AsyncImage(
packageInfo, packageInfo,

View File

@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import app.revanced.manager.R import app.revanced.manager.R
import com.google.accompanist.placeholder.placeholder import io.github.fornewid.placeholder.material3.placeholder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext

View File

@ -3,7 +3,7 @@ package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -38,7 +38,7 @@ fun AppTopBar(
onBackClick: (() -> Unit)? = null, onBackClick: (() -> Unit)? = null,
backIcon: @Composable (() -> Unit) = @Composable { backIcon: @Composable (() -> Unit) = @Composable {
Icon( Icon(
imageVector = Icons.Default.ArrowBack, contentDescription = stringResource( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(
R.string.back R.string.back
) )
) )

View File

@ -13,11 +13,21 @@ import androidx.compose.ui.res.stringResource
import app.revanced.manager.R import app.revanced.manager.R
@Composable @Composable
fun ArrowButton(modifier: Modifier = Modifier, expanded: Boolean,onClick: () -> Unit) { fun ArrowButton(
IconButton(onClick = onClick) { modifier: Modifier = Modifier,
expanded: Boolean,
onClick: (() -> Unit)?,
rotationInitial: Float = 0f,
rotationFinal: Float = 180f
) {
val description = if (expanded) R.string.collapse_content else R.string.expand_content 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") val rotation by animateFloatAsState(
targetValue = if (expanded) rotationInitial else rotationFinal,
label = "rotation"
)
onClick?.let {
IconButton(onClick = it) {
Icon( Icon(
imageVector = Icons.Filled.KeyboardArrowUp, imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(description), contentDescription = stringResource(description),
@ -26,4 +36,11 @@ fun ArrowButton(modifier: Modifier = Modifier, expanded: Boolean,onClick: () ->
.then(modifier) .then(modifier)
) )
} }
} ?: Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(description),
modifier = Modifier
.rotate(rotation)
.then(modifier)
)
} }

View File

@ -4,16 +4,14 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Source import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -21,11 +19,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
@ -37,52 +33,35 @@ fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) {
AlertDialog( AlertDialog(
onDismissRequest = {}, onDismissRequest = {},
confirmButton = { confirmButton = {
TextButton( TextButton(onClick = { onSubmit(managerEnabled, patchesEnabled) }) {
onClick = { onSubmit(managerEnabled, patchesEnabled) }
) {
Text(stringResource(R.string.save)) Text(stringResource(R.string.save))
} }
}, },
icon = { icon = { Icon(Icons.Outlined.Update, null) },
Icon(Icons.Outlined.Update, null) title = { Text(text = stringResource(R.string.auto_updates_dialog_title)) },
},
title = {
Text(
text = stringResource(R.string.auto_updates_dialog_title),
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurface,
)
},
text = { text = {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Text( Text(text = stringResource(R.string.auto_updates_dialog_description))
text = stringResource(R.string.auto_updates_dialog_description),
style = MaterialTheme.typography.bodyMedium,
)
Column {
AutoUpdatesItem( AutoUpdatesItem(
headline = R.string.auto_updates_dialog_manager, headline = R.string.auto_updates_dialog_manager,
icon = Icons.Outlined.Update, icon = Icons.Outlined.Update,
checked = managerEnabled, checked = managerEnabled,
onCheckedChange = { managerEnabled = it } onCheckedChange = { managerEnabled = it }
) )
Divider() HorizontalDivider()
AutoUpdatesItem( AutoUpdatesItem(
headline = R.string.auto_updates_dialog_patches, headline = R.string.auto_updates_dialog_patches,
icon = Icons.Outlined.Source, icon = Icons.Outlined.Source,
checked = patchesEnabled, checked = patchesEnabled,
onCheckedChange = { patchesEnabled = it } onCheckedChange = { patchesEnabled = it }
) )
}
Text( Text(text = stringResource(R.string.auto_updates_dialog_note))
text = stringResource(R.string.auto_updates_dialog_note),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
} }
} }
) )
@ -94,22 +73,9 @@ private fun AutoUpdatesItem(
icon: ImageVector, icon: ImageVector,
checked: Boolean, checked: Boolean,
onCheckedChange: (Boolean) -> Unit onCheckedChange: (Boolean) -> Unit
) { ) = ListItem(
ListItem( leadingContent = { Icon(icon, null) },
leadingContent = { Icon(icon, null, tint = MaterialTheme.colorScheme.onSurface) }, headlineContent = { Text(stringResource(headline)) },
headlineContent = { trailingContent = { Checkbox(checked = checked, onCheckedChange = null) },
Text(
text = stringResource(headline),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
trailingContent = {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
},
modifier = Modifier.clickable { onCheckedChange(!checked) } modifier = Modifier.clickable { onCheckedChange(!checked) }
) )
}

View File

@ -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)
)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -1,37 +1,37 @@
package app.revanced.manager.ui.component package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.Dp
@Composable @Composable
fun LoadingIndicator( fun LoadingIndicator(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
progress: Float? = null, progress: () -> Float? = { null },
text: String? = null color: Color = ProgressIndicatorDefaults.circularColor,
strokeWidth: Dp = ProgressIndicatorDefaults.CircularStrokeWidth,
trackColor: Color = ProgressIndicatorDefaults.circularTrackColor,
strokeCap: StrokeCap = ProgressIndicatorDefaults.CircularDeterminateStrokeCap
) { ) {
Column( progress()?.let {
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
text?.let { Text(text) }
progress?.let {
CircularProgressIndicator( CircularProgressIndicator(
progress = progress, progress = { it },
modifier = Modifier.padding(vertical = 16.dp).then(modifier) modifier = modifier,
color = color,
strokeWidth = strokeWidth,
trackColor = trackColor,
strokeCap = strokeCap
) )
} ?: } ?:
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.padding(vertical = 16.dp).then(modifier) modifier = modifier,
color = color,
strokeWidth = strokeWidth,
trackColor = trackColor,
strokeCap = strokeCap
) )
}
} }

View File

@ -3,10 +3,9 @@ package app.revanced.manager.ui.component
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.mikepenz.markdown.compose.Markdown import com.mikepenz.markdown.compose.Markdown
import com.mikepenz.markdown.model.markdownColor import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.model.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
@Composable @Composable
fun Markdown( fun Markdown(
@ -19,7 +18,8 @@ fun Markdown(
colors = markdownColor( colors = markdownColor(
text = MaterialTheme.colorScheme.onSurfaceVariant, text = MaterialTheme.colorScheme.onSurfaceVariant,
codeBackground = MaterialTheme.colorScheme.secondaryContainer, codeBackground = MaterialTheme.colorScheme.secondaryContainer,
codeText = MaterialTheme.colorScheme.onSecondaryContainer codeText = MaterialTheme.colorScheme.onSecondaryContainer,
linkText = MaterialTheme.colorScheme.primary
), ),
typography = markdownTypography( typography = markdownTypography(
h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), h1 = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold),

View File

@ -1,8 +1,10 @@
package app.revanced.manager.ui.component package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -11,7 +13,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -27,87 +28,91 @@ import app.revanced.manager.R
@Composable @Composable
fun NotificationCard( fun NotificationCard(
isWarning: Boolean = false,
title: String? = null,
text: String, text: String,
icon: ImageVector, icon: ImageVector,
actions: (@Composable () -> Unit)? modifier: Modifier = Modifier,
actions: (@Composable RowScope.() -> Unit)? = null,
title: String? = null,
isWarning: Boolean = false
) { ) {
val color = val color =
if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
NotificationCardInstance(isWarning = isWarning) { NotificationCardInstance(modifier = modifier, isWarning = isWarning) {
Column( Row(
modifier = Modifier.padding(if (title != null) 20.dp else 16.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
if (title != null) { Box(
Icon( modifier = Modifier.size(28.dp),
modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center
imageVector = icon,
contentDescription = null,
tint = color,
)
Column(
verticalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = color,
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = color,
)
}
} else {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Icon( Icon(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = color, tint = color,
) )
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
color = color,
)
}
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = color, color = color,
) )
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
actions?.invoke(this)
} }
} }
actions?.invoke()
} }
} }
} }
@Composable @Composable
fun NotificationCard( fun NotificationCard(
isWarning: Boolean = false,
title: String? = null,
text: String, text: String,
icon: ImageVector, icon: ImageVector,
modifier: Modifier = Modifier,
title: String? = null,
isWarning: Boolean = false,
onDismiss: (() -> Unit)? = null, onDismiss: (() -> Unit)? = null,
primaryAction: (() -> Unit)? = null onClick: (() -> Unit)? = null
) { ) {
val color = val color =
if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer if (isWarning) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimaryContainer
NotificationCardInstance(isWarning = isWarning, onClick = primaryAction) { NotificationCardInstance(modifier = modifier, isWarning = isWarning, onClick = onClick) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier.size(28.dp),
contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
modifier = Modifier.size(if (title != null) 36.dp else 24.dp), modifier = Modifier.size(24.dp),
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = color, tint = color,
) )
}
if (title != null) { if (title != null) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -145,32 +150,31 @@ fun NotificationCard(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun NotificationCardInstance( private fun NotificationCardInstance(
modifier: Modifier = Modifier,
isWarning: Boolean = false, isWarning: Boolean = false,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = val colors =
CardDefaults.cardColors(containerColor = if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer) CardDefaults.cardColors(containerColor = if (isWarning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primaryContainer)
val modifier = Modifier val defaultModifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp)
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
if (onClick != null) { if (onClick != null) {
Card( Card(
onClick = onClick, onClick = onClick,
colors = colors, colors = colors,
modifier = modifier modifier = modifier.then(defaultModifier)
) { ) {
content() content()
} }
} else { } else {
Card( Card(
colors = colors, colors = colors,
modifier = modifier, modifier = modifier.then(defaultModifier)
) { ) {
content() content()
} }

View File

@ -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)

View File

@ -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),
)
}

View File

@ -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
)
}

View File

@ -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()
}
}

View File

@ -2,39 +2,33 @@ package app.revanced.manager.ui.component.bundle
import android.webkit.URLUtil import android.webkit.URLUtil
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowRight import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material3.FilledTonalButton import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material3.Icon import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material3.IconButton import androidx.compose.material.icons.outlined.Sell
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.*
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.TextInputDialog import app.revanced.manager.ui.component.TextInputDialog
import app.revanced.manager.util.isDebuggable
@Composable @Composable
fun BaseBundleDialog( fun BaseBundleDialog(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDefault: Boolean, isDefault: Boolean,
name: String, name: String?,
onNameChange: ((String) -> Unit)? = null,
remoteUrl: String?, remoteUrl: String?,
onRemoteUrlChange: ((String) -> Unit)? = null, onRemoteUrlChange: ((String) -> Unit)? = null,
patchCount: Int, patchCount: Int,
@ -42,45 +36,69 @@ fun BaseBundleDialog(
autoUpdate: Boolean, autoUpdate: Boolean,
onAutoUpdateChange: (Boolean) -> Unit, onAutoUpdateChange: (Boolean) -> Unit,
onPatchesClick: () -> Unit, onPatchesClick: () -> Unit,
onBundleTypeClick: () -> Unit = {},
extraFields: @Composable ColumnScope.() -> Unit = {} extraFields: @Composable ColumnScope.() -> Unit = {}
) = Column( ) {
ColumnWithScrollbar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(rememberScrollState()) .then(modifier),
.padding( ) {
start = 8.dp, Column(
top = 8.dp, modifier = Modifier.padding(16.dp),
end = 4.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)
) )
.then(modifier) name?.let {
) { Text(
var showNameInputDialog by rememberSaveable { text = it,
mutableStateOf(false) style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)),
} color = MaterialTheme.colorScheme.primary,
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
}
) )
} }
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 2.dp)
) {
version?.let {
Tag(Icons.Outlined.Sell, it)
}
Tag(Icons.Outlined.Extension, patchCount.toString())
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
if (remoteUrl != null) {
BundleListItem( BundleListItem(
headlineText = stringResource(R.string.bundle_input_name), headlineText = stringResource(R.string.bundle_auto_update),
supportingText = name.ifEmpty { stringResource(R.string.field_not_set) }, supportingText = stringResource(R.string.bundle_auto_update_description),
modifier = Modifier.clickable(enabled = onNameChange != null) { trailingContent = {
showNameInputDialog = true Switch(
checked = autoUpdate,
onCheckedChange = onAutoUpdateChange
)
},
modifier = Modifier.clickable {
onAutoUpdateChange(!autoUpdate)
} }
) )
}
remoteUrl?.takeUnless { isDefault }?.let { url -> remoteUrl?.takeUnless { isDefault }?.let { url ->
var showUrlInputDialog by rememberSaveable { var showUrlInputDialog by rememberSaveable {
@ -104,81 +122,59 @@ fun BaseBundleDialog(
} }
BundleListItem( BundleListItem(
modifier = Modifier.clickable(enabled = onRemoteUrlChange != null) { modifier = Modifier.clickable(
enabled = onRemoteUrlChange != null,
onClick = {
showUrlInputDialog = true 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
)
},
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))
}
}
)
}
if (version != null || patchCount > 0) {
Text(
text = stringResource(R.string.information),
modifier = Modifier.padding(
horizontal = 16.dp,
vertical = 12.dp
), ),
style = MaterialTheme.typography.labelLarge, headlineText = stringResource(R.string.bundle_input_source_url),
color = MaterialTheme.colorScheme.primary, supportingText = url.ifEmpty {
stringResource(R.string.field_not_set)
}
) )
} }
val patchesClickable = LocalContext.current.isDebuggable && patchCount > 0 val patchesClickable = patchCount > 0
BundleListItem( BundleListItem(
headlineText = stringResource(R.string.patches), headlineText = stringResource(R.string.patches),
supportingText = if (patchCount == 0) stringResource(R.string.no_patches) supportingText = stringResource(R.string.bundle_view_patches),
else stringResource(R.string.patches_available, patchCount), modifier = Modifier.clickable(
modifier = Modifier.clickable(enabled = patchesClickable, onClick = onPatchesClick) enabled = patchesClickable,
onClick = onPatchesClick
)
) { ) {
if (patchesClickable) if (patchesClickable) {
Icon( Icon(
Icons.Outlined.ArrowRight, Icons.AutoMirrored.Outlined.ArrowRight,
stringResource(R.string.patches) stringResource(R.string.patches)
) )
} }
}
version?.let { extraFields()
BundleListItem( }
headlineText = stringResource(R.string.version), }
supportingText = it,
@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,
) )
} }
} }

View File

@ -1,33 +1,32 @@
package app.revanced.manager.ui.component.bundle package app.revanced.manager.ui.component.bundle
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.Icon import androidx.compose.material3.*
import androidx.compose.material3.IconButton import androidx.compose.runtime.*
import androidx.compose.material3.Scaffold import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.isDefault import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.propsOrNullFlow import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import kotlinx.coroutines.flow.map import app.revanced.manager.ui.component.ColumnWithScrollbar
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -36,17 +35,18 @@ fun BundleInformationDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onDeleteRequest: () -> Unit, onDeleteRequest: () -> Unit,
bundle: PatchBundleSource, bundle: PatchBundleSource,
onRefreshButton: () -> Unit, onUpdate: () -> Unit,
) { ) {
val composableScope = rememberCoroutineScope() val composableScope = rememberCoroutineScope()
var viewCurrentBundlePatches by remember { mutableStateOf(false) } var viewCurrentBundlePatches by remember { mutableStateOf(false) }
val isLocal = bundle is LocalPatchBundle val isLocal = bundle is LocalPatchBundle
val patchCount by remember(bundle) { val state by bundle.state.collectAsStateWithLifecycle()
bundle.state.map { it.patchBundleOrNull()?.patches?.size ?: 0 }
}.collectAsStateWithLifecycle(0)
val props by remember(bundle) { val props by remember(bundle) {
bundle.propsOrNullFlow() bundle.propsFlow()
}.collectAsStateWithLifecycle(null) }.collectAsStateWithLifecycle(null)
val patchCount = remember(state) {
state.patchBundleOrNull()?.patches?.size ?: 0
}
if (viewCurrentBundlePatches) { if (viewCurrentBundlePatches) {
BundlePatchesDialog( BundlePatchesDialog(
@ -64,14 +64,16 @@ fun BundleInformationDialog(
dismissOnBackPress = true dismissOnBackPress = true
) )
) { ) {
val bundleName by bundle.nameState
Scaffold( Scaffold(
topBar = { topBar = {
BundleTopBar( BundleTopBar(
title = bundle.name, title = stringResource(R.string.patch_bundle_field),
onBackClick = onDismissRequest, onBackClick = onDismissRequest,
onBackIcon = { backIcon = {
Icon( Icon(
imageVector = Icons.Default.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back) contentDescription = stringResource(R.string.back)
) )
}, },
@ -85,9 +87,9 @@ fun BundleInformationDialog(
} }
} }
if (!isLocal) { if (!isLocal) {
IconButton(onClick = onRefreshButton) { IconButton(onClick = onUpdate) {
Icon( Icon(
Icons.Outlined.Refresh, Icons.Outlined.Update,
stringResource(R.string.refresh) stringResource(R.string.refresh)
) )
} }
@ -99,7 +101,7 @@ fun BundleInformationDialog(
BaseBundleDialog( BaseBundleDialog(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
isDefault = bundle.isDefault, isDefault = bundle.isDefault,
name = bundle.name, name = bundleName,
remoteUrl = bundle.asRemoteOrNull?.endpoint, remoteUrl = bundle.asRemoteOrNull?.endpoint,
patchCount = patchCount, patchCount = patchCount,
version = props?.versionInfo?.patches, version = props?.versionInfo?.patches,
@ -112,7 +114,95 @@ fun BundleInformationDialog(
onPatchesClick = { onPatchesClick = {
viewCurrentBundlePatches = true viewCurrentBundlePatches = true
}, },
extraFields = {
(state as? PatchBundleSource.State.Failed)?.throwable?.let {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
if (showDialog) BundleErrorViewerDialog(
onDismiss = { showDialog = false },
text = remember(it) { it.stackTraceToString() }
)
BundleListItem(
headlineText = stringResource(R.string.bundle_error),
supportingText = stringResource(R.string.bundle_error_description),
trailingContent = {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
null
)
},
modifier = Modifier.clickable { showDialog = true }
)
}
if (state is PatchBundleSource.State.Missing && !isLocal) {
BundleListItem(
headlineText = stringResource(R.string.bundle_error),
supportingText = stringResource(R.string.bundle_not_downloaded),
modifier = Modifier.clickable(onClick = onUpdate)
)
}
}
) )
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BundleErrorViewerDialog(onDismiss: () -> Unit, text: String) {
val context = LocalContext.current
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true
)
) {
Scaffold(
topBar = {
BundleTopBar(
title = stringResource(R.string.bundle_error),
onBackClick = onDismiss,
backIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
},
actions = {
IconButton(
onClick = {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(
Intent.EXTRA_TEXT,
text
)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
}
) {
Icon(
Icons.Outlined.Share,
contentDescription = stringResource(R.string.share)
)
}
}
)
}
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier.padding(paddingValues)
) {
Text(text, modifier = Modifier.horizontalScroll(rememberScrollState()))
}
}
}
}

View File

@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.propsOrNullFlow import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@ -45,8 +45,9 @@ fun BundleItem(
val state by bundle.state.collectAsStateWithLifecycle() val state by bundle.state.collectAsStateWithLifecycle()
val version by remember(bundle) { val version by remember(bundle) {
bundle.propsOrNullFlow().map { props -> props?.versionInfo?.patches } bundle.propsFlow().map { props -> props?.versionInfo?.patches }
}.collectAsStateWithLifecycle(null) }.collectAsStateWithLifecycle(null)
val name by bundle.nameState
if (viewBundleDialogPage) { if (viewBundleDialogPage) {
BundleInformationDialog( BundleInformationDialog(
@ -56,7 +57,7 @@ fun BundleItem(
onDelete() onDelete()
}, },
bundle = bundle, bundle = bundle,
onRefreshButton = onUpdate, onUpdate = onUpdate,
) )
} }
@ -65,34 +66,22 @@ fun BundleItem(
.height(64.dp) .height(64.dp)
.fillMaxWidth() .fillMaxWidth()
.combinedClickable( .combinedClickable(
onClick = { onClick = { viewBundleDialogPage = true },
viewBundleDialogPage = true
},
onLongClick = onSelect, onLongClick = onSelect,
), ),
leadingContent = { leadingContent = if (selectable) {
if(selectable) { {
Checkbox( Checkbox(
checked = isBundleSelected, checked = isBundleSelected,
onCheckedChange = toggleSelection, onCheckedChange = toggleSelection,
) )
} }
}, } else null,
headlineContent = { headlineContent = { Text(name) },
Text(
text = bundle.name,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
},
supportingContent = { supportingContent = {
state.patchBundleOrNull()?.patches?.size?.let { patchCount -> state.patchBundleOrNull()?.patches?.size?.let { patchCount ->
Text( Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount))
text = pluralStringResource(R.plurals.patches_count, patchCount, patchCount),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
}, },
trailingContent = { trailingContent = {
@ -107,20 +96,14 @@ fun BundleItem(
icon?.let { (vector, description) -> icon?.let { (vector, description) ->
Icon( Icon(
imageVector = vector, vector,
contentDescription = stringResource(description), contentDescription = stringResource(description),
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
} }
version?.let { txt -> version?.let { Text(text = it) }
Text(
text = txt,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
}, },
) )

View File

@ -3,15 +3,12 @@ package app.revanced.manager.ui.component.bundle
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Lightbulb import androidx.compose.material.icons.outlined.Lightbulb
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -29,6 +26,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.NotificationCard import app.revanced.manager.ui.component.NotificationCard
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -52,16 +50,16 @@ fun BundlePatchesDialog(
BundleTopBar( BundleTopBar(
title = stringResource(R.string.bundle_patches), title = stringResource(R.string.bundle_patches),
onBackClick = onDismissRequest, onBackClick = onDismissRequest,
onBackIcon = { backIcon = {
Icon( Icon(
imageVector = Icons.Default.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back) contentDescription = stringResource(R.string.back)
) )
}, },
) )
}, },
) { paddingValues -> ) { paddingValues ->
LazyColumn( LazyColumnWithScrollbar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(paddingValues) .padding(paddingValues)
@ -98,7 +96,7 @@ fun BundlePatchesDialog(
} }
} }
) )
Divider() HorizontalDivider()
} }
} }
} }

View File

@ -12,10 +12,12 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -51,6 +53,7 @@ fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSourc
) )
} }
bundles.forEach { bundles.forEach {
val name by it.nameState
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
@ -62,7 +65,7 @@ fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSourc
} }
) { ) {
Text( Text(
text = it.name, name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )

View File

@ -19,7 +19,7 @@ fun BundleTopBar(
onBackClick: (() -> Unit)? = null, onBackClick: (() -> Unit)? = null,
actions: @Composable (RowScope.() -> Unit) = {}, actions: @Composable (RowScope.() -> Unit) = {},
scrollBehavior: TopAppBarScrollBehavior? = null, scrollBehavior: TopAppBarScrollBehavior? = null,
onBackIcon: @Composable () -> Unit, backIcon: @Composable () -> Unit,
) { ) {
val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) val containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
@ -34,7 +34,7 @@ fun BundleTopBar(
navigationIcon = { navigationIcon = {
if (onBackClick != null) { if (onBackClick != null) {
IconButton(onClick = onBackClick) { IconButton(onClick = onBackClick) {
onBackIcon() backIcon()
} }
} }
}, },

View File

@ -4,56 +4,61 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Topic import androidx.compose.material.icons.filled.Topic
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.TextHorizontalPadding
import app.revanced.manager.ui.model.BundleType
import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.JAR_MIMETYPE import app.revanced.manager.util.JAR_MIMETYPE
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ImportBundleDialog( fun ImportPatchBundleDialog(
onDismissRequest: () -> Unit, onDismiss: () -> Unit,
onRemoteSubmit: (String, String, Boolean) -> Unit, onRemoteSubmit: (String, Boolean) -> Unit,
onLocalSubmit: (String, Uri, Uri?) -> Unit onLocalSubmit: (Uri, Uri?) -> Unit
) { ) {
var name by rememberSaveable { mutableStateOf("") } var currentStep by rememberSaveable { mutableIntStateOf(0) }
var remoteUrl by rememberSaveable { mutableStateOf("") } var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) }
var autoUpdate by rememberSaveable { mutableStateOf(true) }
var isLocal by rememberSaveable { mutableStateOf(false) }
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) } var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
var integrations by rememberSaveable { mutableStateOf<Uri?>(null) } var integrations by rememberSaveable { mutableStateOf<Uri?>(null) }
var remoteUrl by rememberSaveable { mutableStateOf("") }
val inputsAreValid by remember { var autoUpdate by rememberSaveable { mutableStateOf(false) }
derivedStateOf {
name.isNotEmpty() && if (isLocal) patchBundle != null else remoteUrl.isNotEmpty()
}
}
val patchActivityLauncher = val patchActivityLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { patchBundle = it } uri?.let { patchBundle = it }
} }
fun launchPatchActivity() { fun launchPatchActivity() {
patchActivityLauncher.launch(JAR_MIMETYPE) patchActivityLauncher.launch(JAR_MIMETYPE)
} }
@ -62,102 +67,213 @@ fun ImportBundleDialog(
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { integrations = it } uri?.let { integrations = it }
} }
fun launchIntegrationsActivity() { fun launchIntegrationsActivity() {
integrationsActivityLauncher.launch(APK_MIMETYPE) integrationsActivityLauncher.launch(APK_MIMETYPE)
} }
Dialog( val steps = listOf<@Composable () -> Unit>(
onDismissRequest = onDismissRequest, {
properties = DialogProperties( SelectBundleTypeStep(bundleType) { selectedType ->
usePlatformDefaultWidth = false, bundleType = selectedType
dismissOnBackPress = true }
)
) {
Scaffold(
topBar = {
BundleTopBar(
title = stringResource(R.string.import_bundle),
onBackClick = onDismissRequest,
onBackIcon = {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close)
)
}, },
actions = { {
ImportBundleStep(
bundleType,
patchBundle,
integrations,
remoteUrl,
autoUpdate,
{ launchPatchActivity() },
{ launchIntegrationsActivity() },
{ remoteUrl = it },
{ autoUpdate = it }
)
}
)
val inputsAreValid by remember {
derivedStateOf {
(bundleType == BundleType.Local && patchBundle != null) ||
(bundleType == BundleType.Remote && remoteUrl.isNotEmpty())
}
}
AlertDialogExtended(
onDismissRequest = onDismiss,
title = {
Text(stringResource(if (currentStep == 0) R.string.select else R.string.add_patch_bundle))
},
text = {
steps[currentStep]()
},
confirmButton = {
if (currentStep == steps.lastIndex) {
TextButton( TextButton(
enabled = inputsAreValid, enabled = inputsAreValid,
onClick = { onClick = {
if (isLocal) { when (bundleType) {
onLocalSubmit(name, patchBundle!!, integrations) BundleType.Local -> patchBundle?.let {
onLocalSubmit(
it,
integrations
)
}
BundleType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate)
}
}
) {
Text(stringResource(R.string.add))
}
} else { } else {
onRemoteSubmit( TextButton(onClick = { currentStep++ }) {
name, Text(stringResource(R.string.next))
remoteUrl, }
autoUpdate
)
} }
}, },
modifier = Modifier.padding(end = 16.dp) dismissButton = {
) { if (currentStep > 0) {
Text(stringResource(R.string.import_)) TextButton(onClick = { currentStep-- }) {
Text(stringResource(R.string.back))
}
} else {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
} }
} }
)
}, },
) { paddingValues -> textHorizontalPadding = PaddingValues(0.dp)
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( @Composable
headlineText = stringResource(R.string.patch_bundle_field), fun SelectBundleTypeStep(
supportingText = stringResource(if (patchBundle != null) R.string.file_field_set else R.string.file_field_not_set), bundleType: BundleType,
trailingContent = { onBundleTypeSelected: (BundleType) -> Unit
IconButton( ) {
onClick = ::launchPatchActivity Column(
verticalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
Icon( Text(
imageVector = Icons.Default.Topic, modifier = Modifier.padding(horizontal = 24.dp),
contentDescription = null 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
) )
} }
}, )
modifier = Modifier.clickable { HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
launchPatchActivity() 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
)
} }
) )
}
}
}
BundleListItem( @OptIn(ExperimentalMaterial3Api::class)
headlineText = stringResource(R.string.integrations_field), @Composable
supportingText = stringResource(if (integrations != null) R.string.file_field_set else R.string.file_field_not_set), fun ImportBundleStep(
trailingContent = { bundleType: BundleType,
IconButton( patchBundle: Uri?,
onClick = ::launchIntegrationsActivity 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)
) { ) {
Icon( ListItem(
imageVector = Icons.Default.Topic, headlineContent = {
contentDescription = null 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)
}
) )
} }
}, },
modifier = Modifier.clickable {
launchIntegrationsActivity()
}
) )
} }
} }
} }
}
} }

View File

@ -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)) }
)
}
}
}
)
}

View File

@ -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
)
}
}

View File

@ -1,66 +1,197 @@
package app.revanced.manager.ui.component.patches package app.revanced.manager.ui.component.patches
import android.app.Application
import android.os.Parcelable
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog as ComposeDialog
import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.patcher.patch.Option import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.FloatInputDialog
import app.revanced.manager.ui.component.IntInputDialog
import app.revanced.manager.ui.component.LongInputDialog
import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.saver.snapshotStateListSaver
import app.revanced.manager.util.saver.snapshotStateSetSaver
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import org.koin.compose.rememberKoinInject import kotlinx.parcelize.Parcelize
import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyColumnState
import java.io.Serializable
import kotlin.random.Random
// Composable functions do not support function references, so we have to use composable lambdas instead. private class OptionEditorScope<T : Any>(
private typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit private val editor: OptionEditor<T>,
val option: Option<T>,
val openDialog: () -> Unit,
val dismissDialog: () -> Unit,
val value: T?,
val setValue: (T?) -> Unit,
) {
fun submitDialog(value: T?) {
setValue(value)
dismissDialog()
}
fun clickAction() = editor.clickAction(this)
@Composable
fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
@Composable
fun Dialog() = editor.Dialog(this)
}
private interface OptionEditor<T : Any> {
fun clickAction(scope: OptionEditorScope<T>) = scope.openDialog()
@Composable
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
IconButton(onClick = { clickAction(scope) }) {
Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
}
}
@Composable
fun Dialog(scope: OptionEditorScope<T>)
}
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 @Composable
private fun OptionListItem( private inline fun <T : Any> WithOptionEditor(
option: Option, editor: OptionEditor<T>,
onClick: () -> Unit, option: Option<T>,
trailingContent: @Composable () -> Unit value: T?,
noinline setValue: (T?) -> Unit,
crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {},
block: OptionEditorScope<T>.() -> Unit
) { ) {
ListItem( var showDialog by rememberSaveable { mutableStateOf(false) }
modifier = Modifier.clickable(onClick = onClick), val scope = remember(editor, option, value, setValue) {
headlineContent = { Text(option.title) }, OptionEditorScope(
supportingContent = { Text(option.description) }, editor,
trailingContent = trailingContent option,
openDialog = { showDialog = true },
dismissDialog = {
showDialog = false
onDismissDialog()
},
value,
setValue
) )
}
if (showDialog) scope.Dialog()
scope.block()
} }
@Composable @Composable
private fun StringOptionDialog( fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) {
name: String, val editor = remember(option.type, option.presets) {
value: String?, @Suppress("UNCHECKED_CAST")
onSubmit: (String) -> Unit, val baseOptionEditor =
onDismissRequest: () -> Unit optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor<T>
) {
var showFileDialog by rememberSaveable { mutableStateOf(false) } if (option.type != "Boolean" && option.presets != null) PresetOptionEditor(baseOptionEditor)
var fieldValue by rememberSaveable(value) { else baseOptionEditor
mutableStateOf(value.orEmpty())
} }
val fs: Filesystem = rememberKoinInject() 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 (contract, permissionName) = fs.permissionContract()
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) { val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
showFileDialog = it showFileDialog = it
@ -78,14 +209,24 @@ private fun StringOptionDialog(
} }
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = scope.dismissDialog,
title = { Text(name) }, title = { Text(scope.option.title) },
text = { text = {
OutlinedTextField( OutlinedTextField(
value = fieldValue, value = fieldValue,
onValueChange = { fieldValue = it }, onValueChange = { fieldValue = it },
placeholder = { placeholder = {
Text(stringResource(R.string.string_option_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 = { trailingIcon = {
var showDropdownMenu by rememberSaveable { mutableStateOf(false) } var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
@ -94,7 +235,7 @@ private fun StringOptionDialog(
) { ) {
Icon( Icon(
Icons.Outlined.MoreVert, Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.string_option_menu_description) stringResource(R.string.string_option_menu_description)
) )
} }
@ -123,82 +264,397 @@ private fun StringOptionDialog(
) )
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { onSubmit(fieldValue) }) { TextButton(
enabled = !validatorFailed,
onClick = { scope.submitDialog(fieldValue) }) {
Text(stringResource(R.string.save)) Text(stringResource(R.string.save))
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = onDismissRequest) { TextButton(onClick = scope.dismissDialog) {
Text(stringResource(R.string.cancel)) Text(stringResource(R.string.cancel))
} }
}, },
) )
}
} }
private val unknownOption: OptionImpl = { option, _, _ -> private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
val context = LocalContext.current @Composable
OptionListItem( protected abstract fun NumberDialog(
option = option, title: String,
onClick = { context.toast("Unknown type: ${option.type}") }, current: T?,
trailingContent = {}) 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 val optionImplementations = mapOf<String, OptionImpl>( private object IntOptionEditor : NumberOptionEditor<Int>() {
// These are the only two types that are currently used by the official patches @Composable
"Boolean" to { option, value, setValue -> override fun NumberDialog(
val current = (value as? Boolean) ?: false title: String,
current: Int?,
validator: (Int?) -> Boolean,
onSubmit: (Int?) -> Unit
) = IntInputDialog(current, title, validator, onSubmit)
}
OptionListItem( private object LongOptionEditor : NumberOptionEditor<Long>() {
option = option, @Composable
onClick = { setValue(!current) } 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
}
}
) { ) {
Switch(checked = current, onCheckedChange = setValue) Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
} }
}, },
"String" to { option, value, setValue -> dismissButton = {
var showInputDialog by rememberSaveable { mutableStateOf(false) } TextButton(onClick = scope.dismissDialog) {
fun showInputDialog() { Text(stringResource(R.string.cancel))
showInputDialog = true
} }
fun dismissInputDialog() {
showInputDialog = false
}
if (showInputDialog) {
StringOptionDialog(
name = option.title,
value = value as? String,
onSubmit = {
dismissInputDialog()
setValue(it)
}, },
onDismissRequest = ::dismissInputDialog 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 }
)
}
) )
} }
OptionListItem( items(presets, key = { it.key }) {
option = option, Item(it.key, it.value, it.key)
onClick = ::showInputDialog }
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 })
}
) { ) {
IconButton(onClick = ::showInputDialog) {
Icon( Icon(
Icons.Outlined.Edit, Icons.Outlined.SelectAll,
contentDescription = stringResource(R.string.string_option_icon_description) 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()
}
) )
} }
} }
} }
) }
}
@Composable }
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
val implementation = remember(option.type) {
optionImplementations.getOrDefault(
option.type,
unknownOption
)
} }
implementation(option, value, setValue) @Parcelize
private data class Item<T : Serializable>(val value: T?, val key: Int = Random.nextInt()) :
Parcelable
} }

View File

@ -3,19 +3,17 @@ package app.revanced.manager.ui.component.patches
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.InsertDriveFile
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.DocumentScanner
import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.InsertDriveFile
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ListItem
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -30,6 +28,7 @@ import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.GroupHeader import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.util.saver.PathSaver import app.revanced.manager.util.saver.PathSaver
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.absolutePathString import kotlin.io.path.absolutePathString
@ -71,7 +70,7 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
currentDirectory = currentDirectory.parent currentDirectory = currentDirectory.parent
} }
LazyColumn( LazyColumnWithScrollbar(
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues)
) { ) {
item(key = "current") { item(key = "current") {
@ -86,7 +85,7 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
item(key = "parent") { item(key = "parent") {
PathItem( PathItem(
onClick = { currentDirectory = currentDirectory.parent }, onClick = { currentDirectory = currentDirectory.parent },
icon = Icons.Outlined.ArrowBack, icon = Icons.AutoMirrored.Outlined.ArrowBack,
name = stringResource(R.string.path_selector_parent_dir) name = stringResource(R.string.path_selector_parent_dir)
) )
} }
@ -113,7 +112,7 @@ fun PathSelectorDialog(root: Path, onSelect: (Path?) -> Unit) {
items(files, key = { it.absolutePathString() }) { items(files, key = { it.absolutePathString() }) {
PathItem( PathItem(
onClick = { onSelect(it) }, onClick = { onSelect(it) },
icon = Icons.Outlined.InsertDriveFile, icon = Icons.AutoMirrored.Outlined.InsertDriveFile,
name = it.name name = it.name
) )
} }

View File

@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun BooleanItem( fun BooleanItem(
modifier: Modifier = Modifier,
preference: Preference<Boolean>, preference: Preference<Boolean>,
coroutineScope: CoroutineScope = rememberCoroutineScope(), coroutineScope: CoroutineScope = rememberCoroutineScope(),
@StringRes headline: Int, @StringRes headline: Int,
@ -22,6 +23,7 @@ fun BooleanItem(
val value by preference.getAsState() val value by preference.getAsState()
BooleanItem( BooleanItem(
modifier = modifier,
value = value, value = value,
onValueChange = { coroutineScope.launch { preference.update(it) } }, onValueChange = { coroutineScope.launch { preference.update(it) } },
headline = headline, headline = headline,
@ -31,12 +33,15 @@ fun BooleanItem(
@Composable @Composable
fun BooleanItem( fun BooleanItem(
modifier: Modifier = Modifier,
value: Boolean, value: Boolean,
onValueChange: (Boolean) -> Unit, onValueChange: (Boolean) -> Unit,
@StringRes headline: Int, @StringRes headline: Int,
@StringRes description: Int @StringRes description: Int
) = SettingsListItem( ) = SettingsListItem(
modifier = Modifier.clickable { onValueChange(!value) }, modifier = Modifier
.clickable { onValueChange(!value) }
.then(modifier),
headlineContent = stringResource(headline), headlineContent = stringResource(headline),
supportingContent = stringResource(description), supportingContent = stringResource(description),
trailingContent = { trailingContent = {

View File

@ -55,10 +55,6 @@ fun Changelog(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
) { ) {
Tag(
Icons.Outlined.Sell,
version
)
Tag( Tag(
Icons.Outlined.FileDownload, Icons.Outlined.FileDownload,
downloadCount downloadCount

View File

@ -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)
)
}
}
)
}

View File

@ -4,7 +4,7 @@ import android.os.Parcelable
import app.revanced.manager.data.room.apps.installed.InstalledApp import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchSelection
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue import kotlinx.parcelize.RawValue
@ -23,12 +23,12 @@ sealed interface Destination : Parcelable {
data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination
@Parcelize @Parcelize
data class VersionSelector(val packageName: String, val patchesSelection: PatchesSelection? = null) : Destination data class VersionSelector(val packageName: String, val patchSelection: PatchSelection? = null) : Destination
@Parcelize @Parcelize
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination
@Parcelize @Parcelize
data class Installer(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination data class Patcher(val selectedApp: SelectedApp, val selectedPatches: PatchSelection, val options: @RawValue Options) : Destination
} }

View File

@ -3,7 +3,7 @@ package app.revanced.manager.ui.destination
import android.os.Parcelable import android.os.Parcelable
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchSelection
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue import kotlinx.parcelize.RawValue
@ -12,7 +12,7 @@ sealed interface SelectedAppInfoDestination : Parcelable {
data object Main : SelectedAppInfoDestination data object Main : SelectedAppInfoDestination
@Parcelize @Parcelize
data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchesSelection?, val options: @RawValue Options) : SelectedAppInfoDestination data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchSelection?, val options: @RawValue Options) : SelectedAppInfoDestination
@Parcelize @Parcelize
data object VersionSelector: SelectedAppInfoDestination data object VersionSelector: SelectedAppInfoDestination

View File

@ -6,35 +6,38 @@ import kotlinx.parcelize.Parcelize
sealed interface SettingsDestination : Parcelable { sealed interface SettingsDestination : Parcelable {
@Parcelize @Parcelize
object Settings : SettingsDestination data object Settings : SettingsDestination
@Parcelize @Parcelize
object General : SettingsDestination data object General : SettingsDestination
@Parcelize @Parcelize
object Advanced : SettingsDestination data object Advanced : SettingsDestination
@Parcelize @Parcelize
object Updates : SettingsDestination data object Updates : SettingsDestination
@Parcelize @Parcelize
object Downloads : SettingsDestination data object Downloads : SettingsDestination
@Parcelize @Parcelize
object ImportExport : SettingsDestination data object ImportExport : SettingsDestination
@Parcelize @Parcelize
object About : SettingsDestination data object About : SettingsDestination
@Parcelize @Parcelize
data class Update(val downloadOnScreenEntry: Boolean) : SettingsDestination data class Update(val downloadOnScreenEntry: Boolean = false) : SettingsDestination
@Parcelize @Parcelize
object Changelogs : SettingsDestination data object Changelogs : SettingsDestination
@Parcelize @Parcelize
object Contributors: SettingsDestination data object Contributors: SettingsDestination
@Parcelize @Parcelize
object Licenses: SettingsDestination data object Licenses: SettingsDestination
@Parcelize
data object DeveloperOptions: SettingsDestination
} }

View File

@ -2,7 +2,7 @@ package app.revanced.manager.ui.model
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.flatMapLatestAndCombine import app.revanced.manager.util.flatMapLatestAndCombine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -34,7 +34,7 @@ data class BundleInfo(
} }
companion object Extensions { companion object Extensions {
inline fun Iterable<BundleInfo>.toPatchSelection(allowUnsupported: Boolean, condition: (Int, PatchInfo) -> Boolean): PatchesSelection = this.associate { bundle -> inline fun Iterable<BundleInfo>.toPatchSelection(allowUnsupported: Boolean, condition: (Int, PatchInfo) -> Boolean): PatchSelection = this.associate { bundle ->
val patches = val patches =
bundle.patchSequence(allowUnsupported) bundle.patchSequence(allowUnsupported)
.mapNotNullTo(mutableSetOf()) { patch -> .mapNotNullTo(mutableSetOf()) { patch ->
@ -75,8 +75,13 @@ data class BundleInfo(
targetList.add(it) targetList.add(it)
} }
BundleInfo(source.name, source.uid, supported, unsupported, universal) BundleInfo(source.getName(), source.uid, supported, unsupported, universal)
} }
} }
} }
} }
enum class BundleType {
Local,
Remote
}

View File

@ -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
)

View File

@ -13,7 +13,7 @@ sealed class SelectedApp : Parcelable {
data class Download(override val packageName: String, override val version: String, val app: AppDownloader.App) : SelectedApp() data class Download(override val packageName: String, override val version: String, val app: AppDownloader.App) : SelectedApp()
@Parcelize @Parcelize
data class Local(override val packageName: String, override val version: String, val file: File, val shouldDelete: Boolean) : SelectedApp() data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp()
@Parcelize @Parcelize
data class Installed(override val packageName: String, override val version: String) : SelectedApp() data class Installed(override val packageName: String, override val version: String) : SelectedApp()

View File

@ -4,14 +4,13 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -19,7 +18,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -28,12 +26,14 @@ import app.revanced.manager.R
import app.revanced.manager.ui.component.AppIcon import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.NonSuggestedVersionDialog
import app.revanced.manager.ui.component.SearchView
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.toast import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.compose.getViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -41,21 +41,19 @@ fun AppSelectorScreen(
onAppClick: (packageName: String) -> Unit, onAppClick: (packageName: String) -> Unit,
onStorageClick: (SelectedApp.Local) -> Unit, onStorageClick: (SelectedApp.Local) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: AppSelectorViewModel = getViewModel() vm: AppSelectorViewModel = koinViewModel()
) { ) {
val context = LocalContext.current SideEffect {
vm.onStorageClick = onStorageClick
}
val pickApkLauncher = val pickApkLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { apkUri -> uri?.let(vm::handleStorageResult)
vm.loadSelectedFile(apkUri)?.let(onStorageClick) ?: context.toast(
context.getString(
R.string.failed_to_load_apk
)
)
}
} }
val suggestedVersions by vm.suggestedAppVersions.collectAsStateWithLifecycle(emptyMap())
var filterText by rememberSaveable { mutableStateOf("") } var filterText by rememberSaveable { mutableStateOf("") }
var search by rememberSaveable { mutableStateOf(false) } var search by rememberSaveable { mutableStateOf(false) }
@ -69,32 +67,24 @@ fun AppSelectorScreen(
} }
} }
// TODO: find something better for this vm.nonSuggestedVersionDialogSubject?.let {
if (search) { NonSuggestedVersionDialog(
SearchBar( suggestedVersion = suggestedVersions[it.packageName].orEmpty(),
query = filterText, onDismiss = vm::dismissNonSuggestedVersionDialog
onQueryChange = { filterText = it },
onSearch = { },
active = true,
onActiveChange = { search = it },
modifier = Modifier.fillMaxSize(),
placeholder = { Text(stringResource(R.string.search_apps)) },
leadingIcon = {
IconButton({ search = false }) {
Icon(
Icons.Default.ArrowBack,
stringResource(R.string.back)
) )
} }
},
content = {
if (search) {
SearchView(
query = filterText,
onQueryChange = { filterText = it },
onActiveChange = { search = it },
placeholder = { Text(stringResource(R.string.search_apps)) }
) {
if (appList.isNotEmpty() && filterText.isNotEmpty()) { if (appList.isNotEmpty() && filterText.isNotEmpty()) {
LazyColumnWithScrollbar(
LazyColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
items( items(
items = filteredAppList, items = filteredAppList,
key = { it.packageName } key = { it.packageName }
@ -114,7 +104,7 @@ fun AppSelectorScreen(
{ {
Text( Text(
pluralStringResource( pluralStringResource(
R.plurals.patches_count, R.plurals.patch_count,
it, it,
it it
) )
@ -143,9 +133,7 @@ fun AppSelectorScreen(
) )
} }
} }
} }
)
} }
Scaffold( Scaffold(
@ -161,10 +149,11 @@ fun AppSelectorScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
LazyColumn( LazyColumnWithScrollbar(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
item { item {
ListItem( ListItem(
@ -185,7 +174,7 @@ fun AppSelectorScreen(
Text(stringResource(R.string.select_from_storage_description)) Text(stringResource(R.string.select_from_storage_description))
} }
) )
Divider() HorizontalDivider()
} }
if (appList.isNotEmpty()) { if (appList.isNotEmpty()) {
@ -193,17 +182,25 @@ fun AppSelectorScreen(
items = appList, items = appList,
key = { it.packageName } key = { it.packageName }
) { app -> ) { app ->
ListItem( ListItem(
modifier = Modifier.clickable { onAppClick(app.packageName) }, modifier = Modifier.clickable { onAppClick(app.packageName) },
leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) }, leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
headlineContent = { AppLabel(app.packageInfo) }, headlineContent = {
supportingContent = { Text(app.packageName) }, AppLabel(
app.packageInfo,
defaultText = app.packageName
)
},
supportingContent = {
suggestedVersions[app.packageName]?.let {
Text(stringResource(R.string.suggested_version_info, it))
}
},
trailingContent = app.patches?.let { trailingContent = app.patches?.let {
{ {
Text( Text(
pluralStringResource( pluralStringResource(
R.plurals.patches_count, R.plurals.patch_count,
it, it,
it it
) )

View File

@ -1,30 +1,22 @@
package app.revanced.manager.ui.screen package app.revanced.manager.ui.screen
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.BatteryAlert
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Source
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -32,16 +24,20 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource.Companion.isDefault
import app.revanced.manager.data.room.apps.installed.InstalledApp import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.component.AvailableUpdateDialog
import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.component.bundle.BundleItem import app.revanced.manager.ui.component.bundle.BundleItem
import app.revanced.manager.ui.component.bundle.BundleTopBar import app.revanced.manager.ui.component.bundle.BundleTopBar
import app.revanced.manager.ui.component.bundle.ImportBundleDialog import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog
import app.revanced.manager.ui.viewmodel.DashboardViewModel import app.revanced.manager.ui.viewmodel.DashboardViewModel
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.getViewModel import org.koin.androidx.compose.koinViewModel
enum class DashboardPage( enum class DashboardPage(
val titleResId: Int, val titleResId: Int,
@ -51,48 +47,58 @@ enum class DashboardPage(
BUNDLES(R.string.tab_bundles, Icons.Outlined.Source), BUNDLES(R.string.tab_bundles, Icons.Outlined.Source),
} }
@SuppressLint("BatteryLife")
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
vm: DashboardViewModel = getViewModel(), vm: DashboardViewModel = koinViewModel(),
onAppSelectorClick: () -> Unit, onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onUpdateClick: () -> Unit,
onAppClick: (InstalledApp) -> Unit onAppClick: (InstalledApp) -> Unit
) { ) {
var showImportBundleDialog by rememberSaveable { mutableStateOf(false) }
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } } val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } }
val pages: Array<DashboardPage> = DashboardPage.values()
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0) val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val androidContext = LocalContext.current val androidContext = LocalContext.current
val composableScope = rememberCoroutineScope()
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = DashboardPage.DASHBOARD.ordinal, initialPage = DashboardPage.DASHBOARD.ordinal,
initialPageOffsetFraction = 0f initialPageOffsetFraction = 0f
) { ) { DashboardPage.entries.size }
DashboardPage.values().size
}
val composableScope = rememberCoroutineScope()
LaunchedEffect(pagerState.currentPage) { LaunchedEffect(pagerState.currentPage) {
if (pagerState.currentPage != DashboardPage.BUNDLES.ordinal) vm.cancelSourceSelection() if (pagerState.currentPage != DashboardPage.BUNDLES.ordinal) vm.cancelSourceSelection()
} }
if (showImportBundleDialog) { val firstLaunch by vm.prefs.firstLaunch.getAsState()
fun dismiss() { if (firstLaunch) AutoUpdatesDialog(vm::applyAutoUpdatePrefs)
showImportBundleDialog = false
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)
}
)
} }
ImportBundleDialog( var showDialog by rememberSaveable { mutableStateOf(vm.prefs.showManagerUpdateDialogOnLaunch.getBlocking()) }
onDismissRequest = ::dismiss, val availableUpdate by remember {
onLocalSubmit = { name, patches, integrations -> derivedStateOf { vm.updatedManagerVersion.takeIf { showDialog } }
dismiss() }
vm.createLocalSource(name, patches, integrations)
}, availableUpdate?.let { version ->
onRemoteSubmit = { name, url, autoUpdate -> AvailableUpdateDialog(
dismiss() onDismiss = { showDialog = false },
vm.createRemoteSource(name, url, autoUpdate) setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch,
}, onConfirm = onUpdateClick,
newVersion = version
) )
} }
@ -102,7 +108,7 @@ fun DashboardScreen(
BundleTopBar( BundleTopBar(
title = stringResource(R.string.bundles_selected, vm.selectedSources.size), title = stringResource(R.string.bundles_selected, vm.selectedSources.size),
onBackClick = vm::cancelSourceSelection, onBackClick = vm::cancelSourceSelection,
onBackIcon = { backIcon = {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.back) contentDescription = stringResource(R.string.back)
@ -137,6 +143,23 @@ fun DashboardScreen(
AppTopBar( AppTopBar(
title = stringResource(R.string.app_name), title = stringResource(R.string.app_name),
actions = { actions = {
if (!vm.updatedManagerVersion.isNullOrEmpty()) {
IconButton(
onClick = onUpdateClick,
) {
BadgedBox(
badge = {
Badge(
// A size value above 6.dp forces the Badge icon to be closer to the center, fixing a clipping issue
modifier = Modifier.size(7.dp),
containerColor = MaterialTheme.colorScheme.primary,
)
}
) {
Icon(Icons.Outlined.Update, stringResource(R.string.update))
}
}
}
IconButton(onClick = onSettingsClick) { IconButton(onClick = onSettingsClick) {
Icon(Icons.Outlined.Settings, stringResource(R.string.settings)) Icon(Icons.Outlined.Settings, stringResource(R.string.settings))
} }
@ -165,13 +188,11 @@ fun DashboardScreen(
} }
DashboardPage.BUNDLES.ordinal -> { DashboardPage.BUNDLES.ordinal -> {
showImportBundleDialog = true showAddBundleDialog = true
} }
} }
} }
) { ) { Icon(Icons.Default.Add, stringResource(R.string.add)) }
Icon(Icons.Default.Add, stringResource(R.string.add))
}
} }
) { paddingValues -> ) { paddingValues ->
Column(Modifier.padding(paddingValues)) { Column(Modifier.padding(paddingValues)) {
@ -179,7 +200,7 @@ fun DashboardScreen(
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) { ) {
pages.forEachIndexed { index, page -> DashboardPage.entries.forEachIndexed { index, page ->
Tab( Tab(
selected = pagerState.currentPage == index, selected = pagerState.currentPage == index,
onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } }, onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } },
@ -191,12 +212,39 @@ fun DashboardScreen(
} }
} }
Notifications(
if (!Aapt.supportsDevice()) {
{
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(R.string.unsupported_architecture_warning),
onDismiss = null
)
}
} else null,
if (vm.showBatteryOptimizationsWarning) {
{
NotificationCard(
isWarning = true,
icon = Icons.Default.BatteryAlert,
text = stringResource(R.string.battery_optimization_notification),
onClick = {
androidContext.startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${androidContext.packageName}")
})
}
)
}
} else null
)
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
userScrollEnabled = true, userScrollEnabled = true,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
pageContent = { index -> pageContent = { index ->
when (pages[index]) { when (DashboardPage.entries[index]) {
DashboardPage.DASHBOARD -> { DashboardPage.DASHBOARD -> {
InstalledAppsScreen( InstalledAppsScreen(
onAppClick = onAppClick onAppClick = onAppClick
@ -215,11 +263,9 @@ fun DashboardScreen(
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize(),
) { ) {
sources.forEach { sources.forEach {
BundleItem( BundleItem(
bundle = it, bundle = it,
onDelete = { onDelete = {
@ -250,3 +296,21 @@ 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()
}
}
}
}

View File

@ -6,14 +6,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowRight import androidx.compose.material.icons.automirrored.filled.ArrowRight
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.Circle import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.SettingsBackupRestore import androidx.compose.material.icons.outlined.SettingsBackupRestore
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@ -31,6 +29,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -38,18 +37,22 @@ import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.AppInfo import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.SegmentedButton import app.revanced.manager.ui.component.SegmentedButton
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.toast
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun InstalledAppInfoScreen( fun InstalledAppInfoScreen(
onPatchClick: (packageName: String, patchesSelection: PatchesSelection) -> Unit, onPatchClick: (packageName: String, patchSelection: PatchSelection) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
viewModel: InstalledAppInfoViewModel viewModel: InstalledAppInfoViewModel
) { ) {
val context = LocalContext.current
SideEffect { SideEffect {
viewModel.onBackClick = onBackClick viewModel.onBackClick = onBackClick
} }
@ -70,18 +73,17 @@ fun InstalledAppInfoScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
Column( ColumnWithScrollbar(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.verticalScroll(rememberScrollState())
) { ) {
AppInfo(viewModel.appInfo) { AppInfo(viewModel.appInfo) {
Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
if (viewModel.installedApp.installType == InstallType.ROOT) { if (viewModel.installedApp.installType == InstallType.ROOT) {
Text( Text(
text = if (viewModel.rootInstaller.isAppMounted(viewModel.installedApp.currentPackageName)) { text = if (viewModel.isMounted) {
stringResource(R.string.mounted) stringResource(R.string.mounted)
} else { } else {
stringResource(R.string.not_mounted) stringResource(R.string.not_mounted)
@ -98,7 +100,7 @@ fun InstalledAppInfoScreen(
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
) { ) {
SegmentedButton( SegmentedButton(
icon = Icons.Outlined.OpenInNew, icon = Icons.AutoMirrored.Outlined.OpenInNew,
text = stringResource(R.string.open_app), text = stringResource(R.string.open_app),
onClick = viewModel::launch onClick = viewModel::launch
) )
@ -144,17 +146,17 @@ fun InstalledAppInfoScreen(
modifier = Modifier.padding(vertical = 16.dp) modifier = Modifier.padding(vertical = 16.dp)
) { ) {
SettingsListItem( SettingsListItem(
modifier = Modifier.clickable { }, modifier = Modifier.clickable { context.toast("Not implemented yet!") },
headlineContent = stringResource(R.string.applied_patches), headlineContent = stringResource(R.string.applied_patches),
supportingContent = supportingContent =
(viewModel.appliedPatches?.values?.sumOf { it.size } ?: 0).let { (viewModel.appliedPatches?.values?.sumOf { it.size } ?: 0).let {
pluralStringResource( pluralStringResource(
id = R.plurals.applied_patches, id = R.plurals.patch_count,
it, it,
it it
) )
}, },
trailingContent = { Icon(Icons.Filled.ArrowRight, contentDescription = stringResource(R.string.view_applied_patches)) } trailingContent = { Icon(Icons.AutoMirrored.Filled.ArrowRight, contentDescription = stringResource(R.string.view_applied_patches)) }
) )
SettingsListItem( SettingsListItem(
@ -174,7 +176,6 @@ fun InstalledAppInfoScreen(
supportingContent = stringResource(viewModel.installedApp.installType.stringResource) supportingContent = stringResource(viewModel.installedApp.installType.stringResource)
) )
} }
} }
} }
} }

View File

@ -5,10 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -21,39 +18,27 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstalledApp import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AppIcon import app.revanced.manager.ui.component.AppIcon
import app.revanced.manager.ui.component.AppLabel import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel import app.revanced.manager.ui.viewmodel.InstalledAppsViewModel
import org.koin.androidx.compose.getViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
fun InstalledAppsScreen( fun InstalledAppsScreen(
onAppClick: (InstalledApp) -> Unit, onAppClick: (InstalledApp) -> Unit,
viewModel: InstalledAppsViewModel = getViewModel() viewModel: InstalledAppsViewModel = koinViewModel()
) { ) {
val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null) val installedApps by viewModel.apps.collectAsStateWithLifecycle(initialValue = null)
Column { Column {
if (!Aapt.supportsDevice()) { LazyColumnWithScrollbar(
NotificationCard(
isWarning = true,
icon = Icons.Outlined.WarningAmber,
text = stringResource(
R.string.unsupported_architecture_warning
),
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top verticalArrangement = if (installedApps.isNullOrEmpty()) Arrangement.Center else Arrangement.Top,
) { ) {
installedApps?.let { installedApps -> installedApps?.let { installedApps ->
if (installedApps.isNotEmpty()) { if (installedApps.isNotEmpty()) {
items( items(
installedApps, installedApps,

View File

@ -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
}
}
)
}
}

View File

@ -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
)
}
}
}
}
}

View File

@ -2,45 +2,17 @@ package app.revanced.manager.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.outlined.HelpOutline import androidx.compose.material3.*
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.SearchBar
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -48,7 +20,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -58,24 +29,26 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.Countdown import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.SafeguardDialog
import app.revanced.manager.ui.component.SearchView
import app.revanced.manager.ui.component.patches.OptionItem import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.isScrollingUp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.rememberKoinInject
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun PatchesSelectorScreen( fun PatchesSelectorScreen(
onSave: (PatchesSelection?, Options) -> Unit, onSave: (PatchSelection?, Options) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: PatchesSelectorViewModel vm: PatchesSelectorViewModel
) { ) {
@ -95,6 +68,23 @@ fun PatchesSelectorScreen(
derivedStateOf { vm.selectionIsValid(bundles) } derivedStateOf { vm.selectionIsValid(bundles) }
} }
val availablePatchCount by remember {
derivedStateOf {
bundles.sumOf { it.patchCount }
}
}
val defaultPatchSelectionCount by vm.defaultSelectionCount
.collectAsStateWithLifecycle(initialValue = 0)
val selectedPatchCount by remember {
derivedStateOf {
vm.customPatchSelection?.values?.sumOf { it.size } ?: defaultPatchSelectionCount
}
}
val patchLazyListStates = remember(bundles) { List(bundles.size) { LazyListState() } }
if (showBottomSheet) { if (showBottomSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = {
@ -105,13 +95,13 @@ fun PatchesSelectorScreen(
modifier = Modifier.padding(horizontal = 24.dp) modifier = Modifier.padding(horizontal = 24.dp)
) { ) {
Text( Text(
text = stringResource(R.string.patches_selector_sheet_filter_title), text = stringResource(R.string.patch_selector_sheet_filter_title),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
Text( Text(
text = stringResource(R.string.patches_selector_sheet_filter_compat_title), text = stringResource(R.string.patch_selector_sheet_filter_compat_title),
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
@ -142,11 +132,19 @@ fun PatchesSelectorScreen(
} }
if (vm.compatibleVersions.isNotEmpty()) if (vm.compatibleVersions.isNotEmpty())
UnsupportedDialog( UnsupportedPatchDialog(
appVersion = vm.appVersion, appVersion = vm.appVersion,
supportedVersions = vm.compatibleVersions, supportedVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs onDismissRequest = vm::dismissDialogs
) )
var showUnsupportedPatchesDialog by rememberSaveable {
mutableStateOf(false)
}
if (showUnsupportedPatchesDialog)
UnsupportedPatchesDialog(
appVersion = vm.appVersion,
onDismissRequest = { showUnsupportedPatchesDialog = false }
)
vm.optionsDialog?.let { (bundle, patch) -> vm.optionsDialog?.let { (bundle, patch) ->
OptionsDialog( OptionsDialog(
@ -158,10 +156,16 @@ fun PatchesSelectorScreen(
) )
} }
vm.pendingSelectionAction?.let { var showSelectionWarning by rememberSaveable {
SelectionWarningDialog( mutableStateOf(false)
onCancel = vm::dismissSelectionWarning, }
onConfirm = vm::confirmSelectionWarning if (showSelectionWarning) {
SelectionWarningDialog(onDismiss = { showSelectionWarning = false })
}
vm.pendingUniversalPatchAction?.let {
UniversalPatchWarningDialog(
onCancel = vm::dismissUniversalPatchWarning,
onConfirm = vm::confirmUniversalPatchWarning
) )
} }
@ -193,12 +197,20 @@ fun PatchesSelectorScreen(
patch patch
), ),
onToggle = { onToggle = {
if (vm.selectionWarningEnabled) { when {
vm.pendingSelectionAction = { // Open unsupported dialog if the patch is not supported
vm.togglePatch(uid, patch) !supported -> vm.openUnsupportedDialog(patch)
// Show selection warning if enabled
vm.selectionWarningEnabled -> showSelectionWarning = true
// Set pending universal patch action if the universal patch warning is enabled and there are no compatible packages
vm.universalPatchWarningEnabled && patch.compatiblePackages == null -> {
vm.pendingUniversalPatchAction = { vm.togglePatch(uid, patch) }
} }
} else {
vm.togglePatch(uid, patch) // Toggle the patch otherwise
else -> vm.togglePatch(uid, patch)
} }
}, },
supported = supported supported = supported
@ -208,31 +220,17 @@ fun PatchesSelectorScreen(
} }
search?.let { query -> search?.let { query ->
SearchBar( SearchView(
query = query, query = query,
onQueryChange = { new -> onQueryChange = { search = it },
search = new onActiveChange = { if (!it) search = null },
}, placeholder = { Text(stringResource(R.string.search_patches)) }
onSearch = {},
active = true,
onActiveChange = { new ->
if (new) return@SearchBar
search = null
},
placeholder = {
Text(stringResource(R.string.search_patches))
},
leadingIcon = {
IconButton(onClick = { search = null }) {
Icon(
Icons.Default.ArrowBack,
stringResource(R.string.back)
)
}
}
) { ) {
val bundle = bundles[pagerState.currentPage] val bundle = bundles[pagerState.currentPage]
LazyColumn(modifier = Modifier.fillMaxSize()) {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize()
) {
fun List<PatchInfo>.searched() = filter { fun List<PatchInfo>.searched() = filter {
it.name.contains(query, true) it.name.contains(query, true)
} }
@ -254,7 +252,7 @@ fun PatchesSelectorScreen(
) )
} }
if (!vm.allowExperimental) return@LazyColumn if (!vm.allowIncompatiblePatches) return@LazyColumnWithScrollbar
patchList( patchList(
uid = bundle.uid, uid = bundle.uid,
patches = bundle.unsupported.searched(), patches = bundle.unsupported.searched(),
@ -263,18 +261,17 @@ fun PatchesSelectorScreen(
) { ) {
ListHeader( ListHeader(
title = stringResource(R.string.unsupported_patches), title = stringResource(R.string.unsupported_patches),
onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) } onHelpClick = { showUnsupportedPatchesDialog = true }
) )
} }
} }
} }
} }
Scaffold( Scaffold(
topBar = { topBar = {
AppTopBar( AppTopBar(
title = stringResource(R.string.select_patches), title = stringResource(R.string.patches_selected, selectedPatchCount, availablePatchCount),
onBackClick = onBackClick, onBackClick = onBackClick,
actions = { actions = {
IconButton(onClick = vm::reset) { IconButton(onClick = vm::reset) {
@ -298,7 +295,14 @@ fun PatchesSelectorScreen(
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save)) }, text = { Text(stringResource(R.string.save)) },
icon = { Icon(Icons.Outlined.Save, null) }, icon = {
Icon(
Icons.Outlined.Save,
stringResource(R.string.save)
)
},
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp
?: true,
onClick = { onClick = {
// TODO: only allow this if all required options have been set. // TODO: only allow this if all required options have been set.
onSave(vm.getCustomSelection(), vm.getOptions()) onSave(vm.getCustomSelection(), vm.getOptions())
@ -338,10 +342,13 @@ fun PatchesSelectorScreen(
state = pagerState, state = pagerState,
userScrollEnabled = true, userScrollEnabled = true,
pageContent = { index -> pageContent = { index ->
// Avoid crashing if the lists have not been fully initialized yet.
if (index > bundles.lastIndex || bundles.size != patchLazyListStates.size) return@HorizontalPager
val bundle = bundles[index] val bundle = bundles[index]
LazyColumn( LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize(),
state = patchLazyListStates[index]
) { ) {
patchList( patchList(
uid = bundle.uid, uid = bundle.uid,
@ -363,11 +370,11 @@ fun PatchesSelectorScreen(
uid = bundle.uid, uid = bundle.uid,
patches = bundle.unsupported, patches = bundle.unsupported,
filterFlag = SHOW_UNSUPPORTED, filterFlag = SHOW_UNSUPPORTED,
supported = vm.allowExperimental supported = vm.allowIncompatiblePatches
) { ) {
ListHeader( ListHeader(
title = stringResource(R.string.unsupported_patches), title = stringResource(R.string.unsupported_patches),
onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) } onHelpClick = { showUnsupportedPatchesDialog = true }
) )
} }
} }
@ -378,35 +385,24 @@ fun PatchesSelectorScreen(
} }
@Composable @Composable
fun SelectionWarningDialog( private fun SelectionWarningDialog(onDismiss: () -> Unit) {
onCancel: () -> Unit, SafeguardDialog(
onConfirm: (Boolean) -> Unit onDismiss = onDismiss,
) { title = R.string.warning,
val prefs: PreferencesManager = rememberKoinInject() body = stringResource(R.string.selection_warning_description),
var dismissPermanently by rememberSaveable { )
mutableStateOf(false) }
}
@Composable
private fun UniversalPatchWarningDialog(
onCancel: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog( AlertDialog(
onDismissRequest = onCancel, onDismissRequest = onCancel,
confirmButton = { confirmButton = {
val enableCountdown by prefs.enableSelectionWarningCountdown.getAsState() TextButton(onClick = onConfirm) {
Text(stringResource(R.string.continue_))
Countdown(start = if (enableCountdown) 3 else 0) { timer ->
LaunchedEffect(timer) {
if (timer == 0) prefs.enableSelectionWarningCountdown.update(false)
}
TextButton(
onClick = { onConfirm(dismissPermanently) },
enabled = timer == 0
) {
val text =
if (timer == 0) stringResource(R.string.continue_) else stringResource(
R.string.selection_warning_continue_countdown, timer
)
Text(text, color = MaterialTheme.colorScheme.error)
}
} }
}, },
dismissButton = { dismissButton = {
@ -419,44 +415,18 @@ fun SelectionWarningDialog(
}, },
title = { title = {
Text( Text(
text = stringResource(R.string.selection_warning_title), text = stringResource(R.string.warning),
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center), style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center)
color = MaterialTheme.colorScheme.onSurface,
) )
}, },
text = { text = {
Column( Text(stringResource(R.string.universal_patch_warning_description))
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = stringResource(R.string.selection_warning_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier.clickable {
dismissPermanently = !dismissPermanently
}
) {
Checkbox(
checked = dismissPermanently,
onCheckedChange = {
dismissPermanently = it
}
)
Text(stringResource(R.string.permanent_dismiss))
}
}
} }
) )
} }
@Composable @Composable
fun PatchItem( private fun PatchItem(
patch: PatchInfo, patch: PatchInfo,
onOptionsDialog: () -> Unit, onOptionsDialog: () -> Unit,
selected: Boolean, selected: Boolean,
@ -465,7 +435,7 @@ fun PatchItem(
) = ListItem( ) = ListItem(
modifier = Modifier modifier = Modifier
.let { if (!supported) it.alpha(0.5f) else it } .let { if (!supported) it.alpha(0.5f) else it }
.clickable(enabled = supported, onClick = onToggle) .clickable(onClick = onToggle)
.fillMaxSize(), .fillMaxSize(),
leadingContent = { leadingContent = {
Checkbox( Checkbox(
@ -486,7 +456,7 @@ fun PatchItem(
) )
@Composable @Composable
fun ListHeader( private fun ListHeader(
title: String, title: String,
onHelpClick: (() -> Unit)? = null onHelpClick: (() -> Unit)? = null
) { ) {
@ -502,7 +472,7 @@ fun ListHeader(
{ {
IconButton(onClick = it) { IconButton(onClick = it) {
Icon( Icon(
Icons.Outlined.HelpOutline, Icons.AutoMirrored.Outlined.HelpOutline,
stringResource(R.string.help) stringResource(R.string.help)
) )
} }
@ -512,18 +482,46 @@ fun ListHeader(
} }
@Composable @Composable
fun UnsupportedDialog( private fun UnsupportedPatchesDialog(
appVersion: String, appVersion: String,
supportedVersions: List<String>,
onDismissRequest: () -> Unit onDismissRequest: () -> Unit
) = AlertDialog( ) = AlertDialog(
icon = {
Icon(Icons.Outlined.WarningAmber, null)
},
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
confirmButton = { confirmButton = {
TextButton(onClick = onDismissRequest) { TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.ok)) Text(stringResource(R.string.ok))
} }
}, },
title = { Text(stringResource(R.string.unsupported_app)) }, title = { Text(stringResource(R.string.unsupported_patches)) },
text = {
Text(
stringResource(
R.string.unsupported_patches_dialog,
appVersion
)
)
}
)
@Composable
private fun UnsupportedPatchDialog(
appVersion: String,
supportedVersions: List<String>,
onDismissRequest: () -> Unit
) = AlertDialog(
icon = {
Icon(Icons.Outlined.WarningAmber, null)
},
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.ok))
}
},
title = { Text(stringResource(R.string.unsupported_patch)) },
text = { text = {
Text( Text(
stringResource( stringResource(
@ -537,7 +535,7 @@ fun UnsupportedDialog(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun OptionsDialog( private fun OptionsDialog(
patch: PatchInfo, patch: PatchInfo,
values: Map<String, Any?>?, values: Map<String, Any?>?,
reset: () -> Unit, reset: () -> Unit,
@ -563,17 +561,24 @@ fun OptionsDialog(
) )
} }
) { paddingValues -> ) { paddingValues ->
LazyColumn( LazyColumnWithScrollbar(
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues)
) { ) {
if (patch.options == null) return@LazyColumn if (patch.options == null) return@LazyColumnWithScrollbar
items(patch.options, key = { it.key }) { option -> items(patch.options, key = { it.key }) { option ->
val key = option.key val key = option.key
val value = val value =
if (values == null || !values.contains(key)) option.default else values[key] if (values == null || !values.contains(key)) option.default else values[key]
OptionItem(option = option, value = value, setValue = { set(key, it) }) @Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = {
set(key, it)
}
)
} }
} }
} }

View File

@ -3,12 +3,13 @@ package app.revanced.manager.ui.screen
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowRight import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -26,25 +27,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AppInfo import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.destination.SelectedAppInfoDestination import app.revanced.manager.ui.destination.SelectedAppInfoDestination
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import dev.olshevski.navigation.reimagined.AnimatedNavHost import dev.olshevski.navigation.reimagined.AnimatedNavHost
import dev.olshevski.navigation.reimagined.NavBackHandler import dev.olshevski.navigation.reimagined.NavBackHandler
import dev.olshevski.navigation.reimagined.navigate import dev.olshevski.navigation.reimagined.navigate
import dev.olshevski.navigation.reimagined.pop import dev.olshevski.navigation.reimagined.pop
import dev.olshevski.navigation.reimagined.rememberNavController import dev.olshevski.navigation.reimagined.rememberNavController
import org.koin.androidx.compose.getViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@Composable @Composable
fun SelectedAppInfoScreen( fun SelectedAppInfoScreen(
onPatchClick: (SelectedApp, PatchesSelection, Options) -> Unit, onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: SelectedAppInfoViewModel vm: SelectedAppInfoViewModel
) { ) {
@ -56,10 +58,10 @@ fun SelectedAppInfoScreen(
vm.bundlesRepo.bundleInfoFlow(packageName, version) vm.bundlesRepo.bundleInfoFlow(packageName, version)
}.collectAsStateWithLifecycle(initialValue = emptyList()) }.collectAsStateWithLifecycle(initialValue = emptyList())
val allowExperimental by vm.prefs.allowExperimental.getAsState() val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
val patches by remember { val patches by remember {
derivedStateOf { derivedStateOf {
vm.getPatches(bundles, allowExperimental) vm.getPatches(bundles, allowIncompatiblePatches)
} }
} }
val selectedPatchCount by remember { val selectedPatchCount by remember {
@ -67,11 +69,6 @@ fun SelectedAppInfoScreen(
patches.values.sumOf { it.size } patches.values.sumOf { it.size }
} }
} }
val availablePatchCount by remember {
derivedStateOf {
bundles.sumOf { it.patchCount }
}
}
val navController = val navController =
rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main) rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main)
@ -99,7 +96,7 @@ fun SelectedAppInfoScreen(
vm.selectedApp, vm.selectedApp,
vm.getCustomPatches( vm.getCustomPatches(
bundles, bundles,
allowExperimental allowIncompatiblePatches
), ),
vm.options vm.options
) )
@ -109,7 +106,6 @@ fun SelectedAppInfoScreen(
navController.navigate(SelectedAppInfoDestination.VersionSelector) navController.navigate(SelectedAppInfoDestination.VersionSelector)
}, },
onBackClick = onBackClick, onBackClick = onBackClick,
availablePatchCount = availablePatchCount,
selectedPatchCount = selectedPatchCount, selectedPatchCount = selectedPatchCount,
packageName = packageName, packageName = packageName,
version = version, version = version,
@ -122,7 +118,7 @@ fun SelectedAppInfoScreen(
vm.selectedApp = it vm.selectedApp = it
navController.pop() navController.pop()
}, },
viewModel = getViewModel { parametersOf(packageName) } viewModel = koinViewModel { parametersOf(packageName) }
) )
is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen( is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
@ -131,7 +127,7 @@ fun SelectedAppInfoScreen(
navController.pop() navController.pop()
}, },
onBackClick = navController::pop, onBackClick = navController::pop,
vm = getViewModel { vm = koinViewModel {
parametersOf( parametersOf(
PatchesSelectorViewModel.Params( PatchesSelectorViewModel.Params(
destination.app, destination.app,
@ -152,7 +148,6 @@ private fun SelectedAppInfoScreen(
onPatchSelectorClick: () -> Unit, onPatchSelectorClick: () -> Unit,
onVersionSelectorClick: () -> Unit, onVersionSelectorClick: () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
availablePatchCount: Int,
selectedPatchCount: Int, selectedPatchCount: Int,
packageName: String, packageName: String,
version: String, version: String,
@ -164,30 +159,33 @@ private fun SelectedAppInfoScreen(
title = stringResource(R.string.app_info), title = stringResource(R.string.app_info),
onBackClick = onBackClick onBackClick = onBackClick
) )
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch)) },
icon = {
Icon(
Icons.Default.AutoFixHigh,
stringResource(R.string.patch)
)
},
onClick = onPatchClick
)
} }
) { paddingValues -> ) { paddingValues ->
Column( ColumnWithScrollbar(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
AppInfo(packageInfo, placeholderLabel = packageName) { AppInfo(packageInfo, placeholderLabel = packageName) {
Text( Text(
stringResource(R.string.selected_app_meta, version, availablePatchCount), stringResource(R.string.selected_app_meta, version),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
PageItem(R.string.patch, stringResource(R.string.patch_item_description), onPatchClick)
Text(
stringResource(R.string.advanced),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
PageItem( PageItem(
R.string.patch_selector_item, R.string.patch_selector_item,
stringResource(R.string.patch_selector_item_description, selectedPatchCount), stringResource(R.string.patch_selector_item_description, selectedPatchCount),
@ -223,7 +221,7 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
) )
}, },
trailingContent = { trailingContent = {
Icon(Icons.Outlined.ArrowRight, null) Icon(Icons.AutoMirrored.Outlined.ArrowRight, null)
} }
) )
} }

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