🔮 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'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build with Gradle
env:
@ -38,7 +38,7 @@ jobs:
run: mv app/build/outputs/apk/release/app-release.apk revanced-manager-${{ env.COMMIT_HASH }}.apk
- name: Upload build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: revanced-manager
path: revanced-manager-${{ env.COMMIT_HASH }}.apk

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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(
val metadata: ReVancedReleaseMeta,
val assets: List<Asset>
)
) {
val version get() = metadata.tag
}
@Serializable
data class ReVancedReleaseMeta(

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import app.revanced.manager.R
import com.google.accompanist.placeholder.placeholder
import io.github.fornewid.placeholder.material3.placeholder
import kotlinx.coroutines.Dispatchers
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.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -38,7 +38,7 @@ fun AppTopBar(
onBackClick: (() -> Unit)? = null,
backIcon: @Composable (() -> Unit) = @Composable {
Icon(
imageVector = Icons.Default.ArrowBack, contentDescription = stringResource(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(
R.string.back
)
)

View File

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

View File

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

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

View File

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

View File

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

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 androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowRight
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.Sell
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.TextInputDialog
import app.revanced.manager.util.isDebuggable
@Composable
fun BaseBundleDialog(
modifier: Modifier = Modifier,
isDefault: Boolean,
name: String,
onNameChange: ((String) -> Unit)? = null,
name: String?,
remoteUrl: String?,
onRemoteUrlChange: ((String) -> Unit)? = null,
patchCount: Int,
@ -42,143 +36,145 @@ fun BaseBundleDialog(
autoUpdate: Boolean,
onAutoUpdateChange: (Boolean) -> Unit,
onPatchesClick: () -> Unit,
onBundleTypeClick: () -> Unit = {},
extraFields: @Composable ColumnScope.() -> Unit = {}
) = Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(
start = 8.dp,
top = 8.dp,
end = 4.dp,
)
.then(modifier)
) {
var showNameInputDialog by rememberSaveable {
mutableStateOf(false)
}
if (showNameInputDialog) {
TextInputDialog(
initial = name,
title = stringResource(R.string.bundle_input_name),
onDismissRequest = {
showNameInputDialog = false
},
onConfirm = {
showNameInputDialog = false
onNameChange?.invoke(it)
},
validator = {
it.length in 1..19
}
)
}
BundleListItem(
headlineText = stringResource(R.string.bundle_input_name),
supportingText = name.ifEmpty { stringResource(R.string.field_not_set) },
modifier = Modifier.clickable(enabled = onNameChange != null) {
showNameInputDialog = true
}
)
remoteUrl?.takeUnless { isDefault }?.let { url ->
var showUrlInputDialog by rememberSaveable {
mutableStateOf(false)
}
if (showUrlInputDialog) {
TextInputDialog(
initial = url,
title = stringResource(R.string.bundle_input_source_url),
onDismissRequest = { showUrlInputDialog = false },
onConfirm = {
showUrlInputDialog = false
onRemoteUrlChange?.invoke(it)
},
validator = {
if (it.isEmpty()) return@TextInputDialog false
URLUtil.isValidUrl(it)
}
)
}
BundleListItem(
modifier = Modifier.clickable(enabled = onRemoteUrlChange != null) {
showUrlInputDialog = true
},
headlineText = stringResource(R.string.bundle_input_source_url),
supportingText = url.ifEmpty { stringResource(R.string.field_not_set) }
)
}
extraFields()
if (remoteUrl != null) {
BundleListItem(
headlineText = stringResource(R.string.automatically_update),
supportingText = stringResource(R.string.automatically_update_description),
trailingContent = {
Switch(
checked = autoUpdate,
onCheckedChange = onAutoUpdateChange
ColumnWithScrollbar(
modifier = Modifier
.fillMaxWidth()
.then(modifier),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Inventory2,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
},
modifier = Modifier.clickable {
onAutoUpdateChange(!autoUpdate)
}
)
}
BundleListItem(
headlineText = stringResource(R.string.bundle_type),
supportingText = stringResource(R.string.bundle_type_description),
modifier = Modifier.clickable {
onBundleTypeClick()
}
) {
FilledTonalButton(
onClick = onBundleTypeClick,
content = {
if (remoteUrl == null) {
Text(stringResource(R.string.local))
} else {
Text(stringResource(R.string.remote))
name?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)),
color = MaterialTheme.colorScheme.primary,
)
}
}
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 2.dp)
) {
version?.let {
Tag(Icons.Outlined.Sell, it)
}
Tag(Icons.Outlined.Extension, patchCount.toString())
}
}
if (version != null || patchCount > 0) {
Text(
text = stringResource(R.string.information),
modifier = Modifier.padding(
horizontal = 16.dp,
vertical = 12.dp
),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
}
val patchesClickable = LocalContext.current.isDebuggable && patchCount > 0
BundleListItem(
headlineText = stringResource(R.string.patches),
supportingText = if (patchCount == 0) stringResource(R.string.no_patches)
else stringResource(R.string.patches_available, patchCount),
modifier = Modifier.clickable(enabled = patchesClickable, onClick = onPatchesClick)
) {
if (patchesClickable)
Icon(
Icons.Outlined.ArrowRight,
stringResource(R.string.patches)
if (remoteUrl != null) {
BundleListItem(
headlineText = stringResource(R.string.bundle_auto_update),
supportingText = stringResource(R.string.bundle_auto_update_description),
trailingContent = {
Switch(
checked = autoUpdate,
onCheckedChange = onAutoUpdateChange
)
},
modifier = Modifier.clickable {
onAutoUpdateChange(!autoUpdate)
}
)
}
}
version?.let {
remoteUrl?.takeUnless { isDefault }?.let { url ->
var showUrlInputDialog by rememberSaveable {
mutableStateOf(false)
}
if (showUrlInputDialog) {
TextInputDialog(
initial = url,
title = stringResource(R.string.bundle_input_source_url),
onDismissRequest = { showUrlInputDialog = false },
onConfirm = {
showUrlInputDialog = false
onRemoteUrlChange?.invoke(it)
},
validator = {
if (it.isEmpty()) return@TextInputDialog false
URLUtil.isValidUrl(it)
}
)
}
BundleListItem(
modifier = Modifier.clickable(
enabled = onRemoteUrlChange != null,
onClick = {
showUrlInputDialog = true
}
),
headlineText = stringResource(R.string.bundle_input_source_url),
supportingText = url.ifEmpty {
stringResource(R.string.field_not_set)
}
)
}
val patchesClickable = patchCount > 0
BundleListItem(
headlineText = stringResource(R.string.version),
supportingText = it,
headlineText = stringResource(R.string.patches),
supportingText = stringResource(R.string.bundle_view_patches),
modifier = Modifier.clickable(
enabled = patchesClickable,
onClick = onPatchesClick
)
) {
if (patchesClickable) {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
stringResource(R.string.patches)
)
}
}
extraFields()
}
}
@Composable
private fun Tag(
icon: ImageVector,
text: String
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.outline,
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,204 +1,660 @@
package app.revanced.manager.ui.component.patches
import android.app.Application
import android.os.Parcelable
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog as ComposeDialog
import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.FloatInputDialog
import app.revanced.manager.ui.component.IntInputDialog
import app.revanced.manager.ui.component.LongInputDialog
import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.saver.snapshotStateListSaver
import app.revanced.manager.util.saver.snapshotStateSetSaver
import app.revanced.manager.util.toast
import org.koin.compose.rememberKoinInject
import kotlinx.parcelize.Parcelize
import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyColumnState
import java.io.Serializable
import kotlin.random.Random
// Composable functions do not support function references, so we have to use composable lambdas instead.
private typealias OptionImpl = @Composable (Option, Any?, (Any?) -> Unit) -> Unit
@Composable
private fun OptionListItem(
option: Option,
onClick: () -> Unit,
trailingContent: @Composable () -> Unit
private class OptionEditorScope<T : Any>(
private val editor: OptionEditor<T>,
val option: Option<T>,
val openDialog: () -> Unit,
val dismissDialog: () -> Unit,
val value: T?,
val setValue: (T?) -> Unit,
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = { Text(option.title) },
supportingContent = { Text(option.description) },
trailingContent = trailingContent
)
fun submitDialog(value: T?) {
setValue(value)
dismissDialog()
}
fun clickAction() = editor.clickAction(this)
@Composable
fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
@Composable
fun Dialog() = editor.Dialog(this)
}
@Composable
private fun StringOptionDialog(
name: String,
value: String?,
onSubmit: (String) -> Unit,
onDismissRequest: () -> Unit
) {
var showFileDialog by rememberSaveable { mutableStateOf(false) }
var fieldValue by rememberSaveable(value) {
mutableStateOf(value.orEmpty())
}
private interface OptionEditor<T : Any> {
fun clickAction(scope: OptionEditorScope<T>) = scope.openDialog()
val fs: Filesystem = rememberKoinInject()
val (contract, permissionName) = fs.permissionContract()
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
showFileDialog = it
}
if (showFileDialog) {
PathSelectorDialog(
root = fs.externalFilesDir()
) {
showFileDialog = false
it?.let { path ->
fieldValue = path.toString()
}
@Composable
fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
IconButton(onClick = { clickAction(scope) }) {
Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
}
}
AlertDialog(
onDismissRequest = onDismissRequest,
title = { Text(name) },
text = {
OutlinedTextField(
value = fieldValue,
onValueChange = { fieldValue = it },
placeholder = {
Text(stringResource(R.string.string_option_placeholder))
},
trailingIcon = {
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
IconButton(
onClick = { showDropdownMenu = true }
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.string_option_menu_description)
)
}
DropdownMenu(
expanded = showDropdownMenu,
onDismissRequest = { showDropdownMenu = false }
) {
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Outlined.Folder, null)
},
text = {
Text(stringResource(R.string.path_selector))
},
onClick = {
showDropdownMenu = false
if (fs.hasStoragePermission()) {
showFileDialog = true
} else {
permissionLauncher.launch(permissionName)
}
}
)
}
}
)
},
confirmButton = {
TextButton(onClick = { onSubmit(fieldValue) }) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
},
)
@Composable
fun Dialog(scope: OptionEditorScope<T>)
}
private val unknownOption: OptionImpl = { option, _, _ ->
val context = LocalContext.current
OptionListItem(
option = option,
onClick = { context.toast("Unknown type: ${option.type}") },
trailingContent = {})
}
private val optionImplementations = mapOf<String, OptionImpl>(
// These are the only two types that are currently used by the official patches
"Boolean" to { option, value, setValue ->
val current = (value as? Boolean) ?: false
OptionListItem(
option = option,
onClick = { setValue(!current) }
) {
Switch(checked = current, onCheckedChange = setValue)
}
},
"String" to { option, value, setValue ->
var showInputDialog by rememberSaveable { mutableStateOf(false) }
fun showInputDialog() {
showInputDialog = true
}
fun dismissInputDialog() {
showInputDialog = false
}
if (showInputDialog) {
StringOptionDialog(
name = option.title,
value = value as? String,
onSubmit = {
dismissInputDialog()
setValue(it)
},
onDismissRequest = ::dismissInputDialog
)
}
OptionListItem(
option = option,
onClick = ::showInputDialog
) {
IconButton(onClick = ::showInputDialog) {
Icon(
Icons.Outlined.Edit,
contentDescription = stringResource(R.string.string_option_icon_description)
)
}
}
}
private val optionEditors = mapOf(
"Boolean" to BooleanOptionEditor,
"String" to StringOptionEditor,
"Int" to IntOptionEditor,
"Long" to LongOptionEditor,
"Float" to FloatOptionEditor,
"BooleanArray" to ListOptionEditor(BooleanOptionEditor),
"StringArray" to ListOptionEditor(StringOptionEditor),
"IntArray" to ListOptionEditor(IntOptionEditor),
"LongArray" to ListOptionEditor(LongOptionEditor),
"FloatArray" to ListOptionEditor(FloatOptionEditor),
)
@Composable
fun OptionItem(option: Option, value: Any?, setValue: (Any?) -> Unit) {
val implementation = remember(option.type) {
optionImplementations.getOrDefault(
option.type,
unknownOption
private inline fun <T : Any> WithOptionEditor(
editor: OptionEditor<T>,
option: Option<T>,
value: T?,
noinline setValue: (T?) -> Unit,
crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {},
block: OptionEditorScope<T>.() -> Unit
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
val scope = remember(editor, option, value, setValue) {
OptionEditorScope(
editor,
option,
openDialog = { showDialog = true },
dismissDialog = {
showDialog = false
onDismissDialog()
},
value,
setValue
)
}
implementation(option, value, setValue)
if (showDialog) scope.Dialog()
scope.block()
}
@Composable
fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) {
val editor = remember(option.type, option.presets) {
@Suppress("UNCHECKED_CAST")
val baseOptionEditor =
optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor<T>
if (option.type != "Boolean" && option.presets != null) PresetOptionEditor(baseOptionEditor)
else baseOptionEditor
}
WithOptionEditor(editor, option, value, setValue) {
ListItem(
modifier = Modifier.clickable(onClick = ::clickAction),
headlineContent = { Text(option.title) },
supportingContent = { Text(option.description) },
trailingContent = { ListItemTrailingContent() }
)
}
}
private object StringOptionEditor : OptionEditor<String> {
@Composable
override fun Dialog(scope: OptionEditorScope<String>) {
var showFileDialog by rememberSaveable { mutableStateOf(false) }
var fieldValue by rememberSaveable(scope.value) {
mutableStateOf(scope.value.orEmpty())
}
val validatorFailed by remember {
derivedStateOf { !scope.option.validator(fieldValue) }
}
val fs: Filesystem = koinInject()
val (contract, permissionName) = fs.permissionContract()
val permissionLauncher = rememberLauncherForActivityResult(contract = contract) {
showFileDialog = it
}
if (showFileDialog) {
PathSelectorDialog(
root = fs.externalFilesDir()
) {
showFileDialog = false
it?.let { path ->
fieldValue = path.toString()
}
}
}
AlertDialog(
onDismissRequest = scope.dismissDialog,
title = { Text(scope.option.title) },
text = {
OutlinedTextField(
value = fieldValue,
onValueChange = { fieldValue = it },
placeholder = {
Text(stringResource(R.string.dialog_input_placeholder))
},
isError = validatorFailed,
supportingText = {
if (validatorFailed) {
Text(
stringResource(R.string.input_dialog_value_invalid),
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.error
)
}
},
trailingIcon = {
var showDropdownMenu by rememberSaveable { mutableStateOf(false) }
IconButton(
onClick = { showDropdownMenu = true }
) {
Icon(
Icons.Outlined.MoreVert,
stringResource(R.string.string_option_menu_description)
)
}
DropdownMenu(
expanded = showDropdownMenu,
onDismissRequest = { showDropdownMenu = false }
) {
DropdownMenuItem(
leadingIcon = {
Icon(Icons.Outlined.Folder, null)
},
text = {
Text(stringResource(R.string.path_selector))
},
onClick = {
showDropdownMenu = false
if (fs.hasStoragePermission()) {
showFileDialog = true
} else {
permissionLauncher.launch(permissionName)
}
}
)
}
}
)
},
confirmButton = {
TextButton(
enabled = !validatorFailed,
onClick = { scope.submitDialog(fieldValue) }) {
Text(stringResource(R.string.save))
}
},
dismissButton = {
TextButton(onClick = scope.dismissDialog) {
Text(stringResource(R.string.cancel))
}
},
)
}
}
private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
@Composable
protected abstract fun NumberDialog(
title: String,
current: T?,
validator: (T?) -> Boolean,
onSubmit: (T?) -> Unit
)
@Composable
override fun Dialog(scope: OptionEditorScope<T>) {
NumberDialog(scope.option.title, scope.value, scope.option.validator) {
if (it == null) return@NumberDialog scope.dismissDialog()
scope.submitDialog(it)
}
}
}
private object IntOptionEditor : NumberOptionEditor<Int>() {
@Composable
override fun NumberDialog(
title: String,
current: Int?,
validator: (Int?) -> Boolean,
onSubmit: (Int?) -> Unit
) = IntInputDialog(current, title, validator, onSubmit)
}
private object LongOptionEditor : NumberOptionEditor<Long>() {
@Composable
override fun NumberDialog(
title: String,
current: Long?,
validator: (Long?) -> Boolean,
onSubmit: (Long?) -> Unit
) = LongInputDialog(current, title, validator, onSubmit)
}
private object FloatOptionEditor : NumberOptionEditor<Float>() {
@Composable
override fun NumberDialog(
title: String,
current: Float?,
validator: (Float?) -> Boolean,
onSubmit: (Float?) -> Unit
) = FloatInputDialog(current, title, validator, onSubmit)
}
private object BooleanOptionEditor : OptionEditor<Boolean> {
override fun clickAction(scope: OptionEditorScope<Boolean>) {
scope.setValue(!scope.current)
}
@Composable
override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) {
Switch(checked = scope.current, onCheckedChange = scope.setValue)
}
@Composable
override fun Dialog(scope: OptionEditorScope<Boolean>) {
}
private val OptionEditorScope<Boolean>.current get() = value ?: false
}
private object UnknownTypeEditor : OptionEditor<Any>, KoinComponent {
override fun clickAction(scope: OptionEditorScope<Any>) =
get<Application>().toast("Unknown type: ${scope.option.type}")
@Composable
override fun Dialog(scope: OptionEditorScope<Any>) {
}
}
/**
* A wrapper for [OptionEditor]s that shows selectable presets.
*
* @param innerEditor The [OptionEditor] for [T].
*/
private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<T>) :
OptionEditor<T> {
@Composable
override fun Dialog(scope: OptionEditorScope<T>) {
var selectedPreset by rememberSaveable(scope.value, scope.option.presets) {
val presets = scope.option.presets!!
mutableStateOf(presets.entries.find { it.value == scope.value }?.key)
}
WithOptionEditor(
innerEditor,
scope.option,
scope.value,
scope.setValue,
onDismissDialog = scope.dismissDialog
) inner@{
var hidePresetsDialog by rememberSaveable {
mutableStateOf(false)
}
if (hidePresetsDialog) return@inner
// TODO: add a divider for scrollable content
AlertDialogExtended(
onDismissRequest = scope.dismissDialog,
confirmButton = {
TextButton(
onClick = {
if (selectedPreset != null) scope.submitDialog(
scope.option.presets?.get(
selectedPreset
)
)
else {
this@inner.openDialog()
// Hide the presets dialog so it doesn't show up in the background.
hidePresetsDialog = true
}
}
) {
Text(stringResource(if (selectedPreset != null) R.string.save else R.string.continue_))
}
},
dismissButton = {
TextButton(onClick = scope.dismissDialog) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(scope.option.title) },
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
text = {
val presets = remember(scope.option.presets) {
scope.option.presets?.entries?.toList().orEmpty()
}
LazyColumn {
@Composable
fun Item(title: String, value: Any?, presetKey: String?) {
ListItem(
modifier = Modifier.clickable { selectedPreset = presetKey },
headlineContent = { Text(title) },
supportingContent = value?.toString()?.let { { Text(it) } },
leadingContent = {
RadioButton(
selected = selectedPreset == presetKey,
onClick = { selectedPreset = presetKey }
)
}
)
}
items(presets, key = { it.key }) {
Item(it.key, it.value, it.key)
}
item(key = null) {
Item(stringResource(R.string.option_preset_custom_value), null, null)
}
}
}
)
}
}
}
private class ListOptionEditor<T : Serializable>(private val elementEditor: OptionEditor<T>) :
OptionEditor<List<T>> {
private fun createElementOption(option: Option<List<T>>) = Option<T>(
option.title,
option.key,
option.description,
option.required,
option.type.removeSuffix("Array"),
null,
null
) { true }
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
override fun Dialog(scope: OptionEditorScope<List<T>>) {
val items =
rememberSaveable(scope.value, saver = snapshotStateListSaver()) {
// We need a key for each element in order to support dragging.
scope.value?.map(::Item)?.toMutableStateList() ?: mutableStateListOf()
}
val listIsDirty by remember {
derivedStateOf {
val current = scope.value.orEmpty()
if (current.size != items.size) return@derivedStateOf true
current.forEachIndexed { index, value ->
if (value != items[index].value) return@derivedStateOf true
}
false
}
}
val lazyListState = rememberLazyListState()
val reorderableLazyColumnState =
rememberReorderableLazyColumnState(lazyListState) { from, to ->
// Update the list
items.add(to.index, items.removeAt(from.index))
}
var deleteMode by rememberSaveable {
mutableStateOf(false)
}
val deletionTargets = rememberSaveable(saver = snapshotStateSetSaver()) {
mutableStateSetOf<Int>()
}
val back = back@{
if (deleteMode) {
deletionTargets.clear()
deleteMode = false
return@back
}
if (!listIsDirty) {
scope.dismissDialog()
return@back
}
scope.submitDialog(items.mapNotNull { it.value })
}
ComposeDialog(
onDismissRequest = back,
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true
),
) {
Scaffold(
topBar = {
AppTopBar(
title = if (deleteMode) pluralStringResource(
R.plurals.selected_count,
deletionTargets.size,
deletionTargets.size
) else scope.option.title,
onBackClick = back,
backIcon = {
if (deleteMode) {
return@AppTopBar Icon(
Icons.Filled.Close,
stringResource(R.string.cancel)
)
}
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back))
},
actions = {
if (deleteMode) {
IconButton(
onClick = {
if (items.size == deletionTargets.size) deletionTargets.clear()
else deletionTargets.addAll(items.map { it.key })
}
) {
Icon(
Icons.Outlined.SelectAll,
stringResource(R.string.select_deselect_all)
)
}
IconButton(
onClick = {
items.removeIf { it.key in deletionTargets }
deletionTargets.clear()
deleteMode = false
}
) {
Icon(
Icons.Outlined.Delete,
stringResource(R.string.delete)
)
}
} else {
IconButton(onClick = items::clear) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
}
}
)
},
floatingActionButton = {
if (deleteMode) return@Scaffold
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.add)) },
icon = {
Icon(
Icons.Outlined.Add,
stringResource(R.string.add)
)
},
expanded = lazyListState.isScrollingUp,
onClick = { items.add(Item(null)) }
)
}
) { paddingValues ->
val elementOption = remember(scope.option) { createElementOption(scope.option) }
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxHeight()
.padding(paddingValues),
) {
itemsIndexed(items, key = { _, item -> item.key }) { index, item ->
val interactionSource = remember { MutableInteractionSource() }
ReorderableItem(reorderableLazyColumnState, key = item.key) {
WithOptionEditor(
elementEditor,
elementOption,
value = item.value,
setValue = { items[index] = item.copy(value = it) }
) {
ListItem(
modifier = Modifier.combinedClickable(
indication = LocalIndication.current,
interactionSource = interactionSource,
onLongClickLabel = stringResource(R.string.select),
onLongClick = {
deletionTargets.add(item.key)
deleteMode = true
},
onClick = {
if (!deleteMode) {
clickAction()
return@combinedClickable
}
if (item.key in deletionTargets) {
deletionTargets.remove(
item.key
)
deleteMode = deletionTargets.isNotEmpty()
} else deletionTargets.add(item.key)
},
),
tonalElevation = if (deleteMode && item.key in deletionTargets) 8.dp else 0.dp,
leadingContent = {
IconButton(
modifier = Modifier.draggableHandle(interactionSource = interactionSource),
onClick = {},
) {
Icon(
Icons.Filled.DragHandle,
stringResource(R.string.drag_handle)
)
}
},
headlineContent = {
if (item.value == null) return@ListItem Text(
stringResource(R.string.empty),
fontStyle = FontStyle.Italic
)
Text(item.value.toString())
},
trailingContent = {
ListItemTrailingContent()
}
)
}
}
}
}
}
}
}
@Parcelize
private data class Item<T : Serializable>(val value: T?, val key: Int = Random.nextInt()) :
Parcelable
}

View File

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

View File

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

View File

@ -55,10 +55,6 @@ fun Changelog(
modifier = Modifier
.fillMaxWidth()
) {
Tag(
Icons.Outlined.Sell,
version
)
Tag(
Icons.Outlined.FileDownload,
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.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.PatchSelection
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
@ -23,12 +23,12 @@ sealed interface Destination : Parcelable {
data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination
@Parcelize
data class VersionSelector(val packageName: String, val patchesSelection: PatchesSelection? = null) : Destination
data class VersionSelector(val packageName: String, val patchSelection: PatchSelection? = null) : Destination
@Parcelize
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchesSelection: PatchesSelection? = null) : Destination
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination
@Parcelize
data class Installer(val selectedApp: SelectedApp, val selectedPatches: PatchesSelection, val options: @RawValue Options) : Destination
data class Patcher(val selectedApp: SelectedApp, val selectedPatches: PatchSelection, val options: @RawValue Options) : Destination
}

View File

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

View File

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

View File

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

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()
@Parcelize
data class Local(override val packageName: String, override val version: String, val file: File, val shouldDelete: Boolean) : SelectedApp()
data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp()
@Parcelize
data class Installed(override val packageName: String, override val version: String) : SelectedApp()

View File

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

View File

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

View File

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

View File

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

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

View File

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

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