🔮 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 2025-01-19 18:45:06 +07:00
commit 177b716fd0
No known key found for this signature in database
GPG Key ID: DBA94253E1D3F267
157 changed files with 5252 additions and 2601 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@
.cxx .cxx
local.properties local.properties
.kotlin/

View File

@ -74,7 +74,7 @@ special contributor role.
### ⏳ Supported Versions ### ⏳ Supported Versions
| Version | Branch | Supported | | Version | Branch | Supported |
| ---------------------------------------- | ------------- | ------------------ | |------------------------------------------|---------------|--------------------|
| ![Latest stable release][LatestRelBadge] | `main` | :white_check_mark: | | ![Latest stable release][LatestRelBadge] | `main` | :white_check_mark: |
| ![Latest version][LatestVerBadge] | `dev` | :white_check_mark: | | ![Latest version][LatestVerBadge] | `dev` | :white_check_mark: |
| ![Latest version][LatestVerBadge] | `compose-dev` | :white_check_mark: | | ![Latest version][LatestVerBadge] | `compose-dev` | :white_check_mark: |

View File

@ -3,21 +3,22 @@ import kotlin.random.Random
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.devtools) alias(libs.plugins.devtools)
alias(libs.plugins.about.libraries) alias(libs.plugins.about.libraries)
id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.9.23"
} }
android { android {
namespace = "app.revanced.manager" namespace = "app.revanced.manager"
compileSdk = 34 compileSdk = 35
buildToolsVersion = "34.0.0" buildToolsVersion = "35.0.0"
defaultConfig { defaultConfig {
applicationId = "app.revanced.manager" applicationId = "app.revanced.manager"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.0.1" versionName = "0.0.1"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@ -81,9 +82,11 @@ android {
jvmTarget = "17" jvmTarget = "17"
} }
buildFeatures.compose = true buildFeatures {
buildFeatures.aidl = true compose = true
buildFeatures.buildConfig=true aidl = true
buildConfig = true
}
android { android {
androidResources { androidResources {
@ -91,7 +94,6 @@ android {
} }
} }
composeOptions.kotlinCompilerExtensionVersion = "1.5.10"
externalNativeBuild { externalNativeBuild {
cmake { cmake {
path = file("src/main/cpp/CMakeLists.txt") path = file("src/main/cpp/CMakeLists.txt")
@ -111,10 +113,10 @@ dependencies {
implementation(libs.runtime.ktx) implementation(libs.runtime.ktx)
implementation(libs.runtime.compose) implementation(libs.runtime.compose)
implementation(libs.splash.screen) implementation(libs.splash.screen)
implementation(libs.compose.activity) implementation(libs.activity.compose)
implementation(libs.paging.common.ktx)
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
implementation(libs.preferences.datastore) implementation(libs.preferences.datastore)
implementation(libs.appcompat)
// Compose // Compose
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
@ -124,6 +126,7 @@ dependencies {
implementation(libs.compose.livedata) implementation(libs.compose.livedata)
implementation(libs.compose.material.icons.extended) implementation(libs.compose.material.icons.extended)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.navigation.compose)
// Accompanist // Accompanist
implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.drawablepainter)
@ -142,6 +145,7 @@ dependencies {
// KotlinX // KotlinX
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.collection.immutable) implementation(libs.kotlinx.collection.immutable)
implementation(libs.kotlinx.datetime)
// Room // Room
implementation(libs.room.runtime) implementation(libs.room.runtime)
@ -153,6 +157,9 @@ dependencies {
implementation(libs.revanced.patcher) implementation(libs.revanced.patcher)
implementation(libs.revanced.library) implementation(libs.revanced.library)
// Downloader plugins
implementation(project(":downloader-plugin"))
// Native processes // Native processes
implementation(libs.kotlin.process) implementation(libs.kotlin.process)
@ -167,11 +174,9 @@ dependencies {
// Koin // Koin
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(libs.koin.compose) implementation(libs.koin.compose)
implementation(libs.koin.compose.navigation)
implementation(libs.koin.workmanager) implementation(libs.koin.workmanager)
// Compose Navigation
implementation(libs.reimagined.navigation)
// Licenses // Licenses
implementation(libs.about.libraries) implementation(libs.about.libraries)

View File

@ -49,6 +49,10 @@
-keep class com.android.** { -keep class com.android.** {
*; *;
} }
-keep class app.revanced.manager.plugin.** {
*;
}
-dontwarn com.google.auto.value.** -dontwarn com.google.auto.value.**
-dontwarn java.awt.** -dontwarn java.awt.**
-dontwarn javax.** -dontwarn javax.**

View File

@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "1dd9d5c0201fdf3cfef3ae669fd65e46", "identityHash": "d0119047505da435972c5247181de675",
"entities": [ "entities": [
{ {
"tableName": "patch_bundles", "tableName": "patch_bundles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, `version` TEXT, `integrations_version` TEXT, PRIMARY KEY(`uid`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `name` TEXT NOT NULL, `version` TEXT, `source` TEXT NOT NULL, `auto_update` INTEGER NOT NULL, PRIMARY KEY(`uid`))",
"fields": [ "fields": [
{ {
"fieldPath": "uid", "fieldPath": "uid",
@ -20,6 +20,12 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
},
{ {
"fieldPath": "source", "fieldPath": "source",
"columnName": "source", "columnName": "source",
@ -31,18 +37,6 @@
"columnName": "auto_update", "columnName": "auto_update",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
},
{
"fieldPath": "versionInfo.patches",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "versionInfo.integrations",
"columnName": "integrations_version",
"affinity": "TEXT",
"notNull": false
} }
], ],
"primaryKey": { "primaryKey": {
@ -150,7 +144,7 @@
}, },
{ {
"tableName": "downloaded_app", "tableName": "downloaded_app",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, PRIMARY KEY(`package_name`, `version`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, `last_used` INTEGER NOT NULL, PRIMARY KEY(`package_name`, `version`))",
"fields": [ "fields": [
{ {
"fieldPath": "packageName", "fieldPath": "packageName",
@ -169,6 +163,12 @@
"columnName": "directory", "columnName": "directory",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
},
{
"fieldPath": "lastUsed",
"columnName": "last_used",
"affinity": "INTEGER",
"notNull": true
} }
], ],
"primaryKey": { "primaryKey": {
@ -392,12 +392,38 @@
] ]
} }
] ]
},
{
"tableName": "trusted_downloader_plugins",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `signature` BLOB NOT NULL, PRIMARY KEY(`package_name`))",
"fields": [
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "signature",
"columnName": "signature",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"package_name"
]
},
"indices": [],
"foreignKeys": []
} }
], ],
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1dd9d5c0201fdf3cfef3ae669fd65e46')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd0119047505da435972c5247181de675')"
] ]
} }
} }

View File

@ -2,9 +2,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<permission android:name="android.permission.QUERY_ALL_PACKAGES" <permission
tools:ignore="ReservedSystemPermission" /> android:name="app.revanced.manager.permission.PLUGIN_HOST"
android:protectionLevel="signature"
android:label="@string/plugin_host_permission_label"
android:description="@string/plugin_host_permission_description"
/>
<uses-permission android:name="app.revanced.manager.permission.PLUGIN_HOST" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@ -17,12 +24,6 @@
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
</queries>
<application <application
android:name=".ManagerApplication" android:name=".ManagerApplication"
android:allowBackup="true" android:allowBackup="true"
@ -47,6 +48,8 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".plugin.downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
<service android:name=".service.InstallService" /> <service android:name=".service.InstallService" />
<service android:name=".service.UninstallService" /> <service android:name=".service.UninstallService" />

View File

@ -1,150 +1,307 @@
package app.revanced.manager package app.revanced.manager
import android.content.ActivityNotFoundException
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import app.revanced.manager.ui.destination.Destination import androidx.core.view.WindowCompat
import app.revanced.manager.ui.destination.SettingsDestination import androidx.lifecycle.lifecycleScope
import app.revanced.manager.ui.screen.AppSelectorScreen import androidx.navigation.NavBackStackEntry
import app.revanced.manager.ui.screen.DashboardScreen import androidx.navigation.NavController
import app.revanced.manager.ui.screen.InstalledAppInfoScreen import androidx.navigation.compose.NavHost
import app.revanced.manager.ui.screen.PatcherScreen import androidx.navigation.compose.composable
import app.revanced.manager.ui.screen.SelectedAppInfoScreen import androidx.navigation.compose.navigation
import app.revanced.manager.ui.screen.SettingsScreen import androidx.navigation.compose.rememberNavController
import app.revanced.manager.ui.screen.VersionSelectorScreen import androidx.navigation.toRoute
import app.revanced.manager.ui.model.navigation.*
import app.revanced.manager.ui.screen.*
import app.revanced.manager.ui.screen.settings.*
import app.revanced.manager.ui.screen.settings.update.ChangelogsScreen
import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen
import app.revanced.manager.ui.theme.ReVancedManagerTheme import app.revanced.manager.ui.theme.ReVancedManagerTheme
import app.revanced.manager.ui.theme.Theme import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.ui.viewmodel.MainViewModel import app.revanced.manager.ui.viewmodel.MainViewModel
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import dev.olshevski.navigation.reimagined.AnimatedNavHost import app.revanced.manager.util.EventEffect
import dev.olshevski.navigation.reimagined.NavBackHandler import kotlinx.coroutines.launch
import dev.olshevski.navigation.reimagined.navigate import org.koin.androidx.compose.koinViewModel
import dev.olshevski.navigation.reimagined.pop import org.koin.androidx.compose.navigation.koinNavViewModel
import dev.olshevski.navigation.reimagined.popUpTo
import dev.olshevski.navigation.reimagined.rememberNavController
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koin.androidx.compose.koinViewModel as getComposeViewModel import org.koin.androidx.viewmodel.ext.android.getViewModel as getActivityViewModel
import org.koin.androidx.viewmodel.ext.android.getViewModel as getAndroidViewModel
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ExperimentalAnimationApi @ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
enableEdgeToEdge()
installSplashScreen() installSplashScreen()
val vm: MainViewModel = getAndroidViewModel() val vm: MainViewModel = getActivityViewModel()
vm.importLegacySettings(this)
setContent { setContent {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = vm::applyLegacySettings
)
val theme by vm.prefs.theme.getAsState() val theme by vm.prefs.theme.getAsState()
val dynamicColor by vm.prefs.dynamicColor.getAsState() val dynamicColor by vm.prefs.dynamicColor.getAsState()
EventEffect(vm.legacyImportActivityFlow) {
try {
launcher.launch(it)
} catch (_: ActivityNotFoundException) {
}
}
ReVancedManagerTheme( ReVancedManagerTheme(
darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK, darkTheme = theme == Theme.SYSTEM && isSystemInDarkTheme() || theme == Theme.DARK,
dynamicColor = dynamicColor dynamicColor = dynamicColor
) { ) {
val navController = ReVancedManager(vm)
rememberNavController<Destination>(startDestination = Destination.Dashboard) }
NavBackHandler(navController) }
}
}
AnimatedNavHost( @Composable
controller = navController private fun ReVancedManager(vm: MainViewModel) {
) { destination -> val navController = rememberNavController()
when (destination) {
is Destination.Dashboard -> DashboardScreen( EventEffect(vm.appSelectFlow) { app ->
onSettingsClick = { navController.navigate(Destination.Settings()) }, navController.navigateComplex(
onAppSelectorClick = { navController.navigate(Destination.AppSelector) }, SelectedApplicationInfo,
onUpdateClick = { navController.navigate( SelectedApplicationInfo.ViewModelParams(app)
Destination.Settings(SettingsDestination.Update())
) },
onAppClick = { installedApp ->
navController.navigate(
Destination.InstalledApplicationInfo(
installedApp
)
) )
} }
)
is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen( NavHost(
onPatchClick = { packageName, patchSelection -> navController = navController,
navController.navigate( startDestination = Dashboard,
Destination.VersionSelector( ) {
packageName, composable<Dashboard> {
patchSelection DashboardScreen(
onSettingsClick = { navController.navigate(Settings) },
onAppSelectorClick = {
navController.navigate(AppSelector)
},
onUpdateClick = {
navController.navigate(Update())
},
onDownloaderPluginClick = {
navController.navigate(Settings.Downloads)
},
onAppClick = { packageName ->
navController.navigate(InstalledApplicationInfo(packageName))
}
)
}
composable<InstalledApplicationInfo> {
val data = it.toRoute<InstalledApplicationInfo>()
InstalledAppInfoScreen(
onPatchClick = vm::selectApp,
onBackClick = navController::popBackStack,
viewModel = koinViewModel { parametersOf(data.packageName) }
)
}
composable<AppSelector> {
AppSelectorScreen(
onSelect = vm::selectApp,
onStorageSelect = vm::selectApp,
onBackClick = navController::popBackStack
)
}
composable<Patcher> {
PatcherScreen(
onBackClick = {
navController.navigate(route = Dashboard) {
launchSingleTop = true
popUpTo<Dashboard> {
inclusive = false
}
}
},
vm = koinViewModel { parametersOf(it.getComplexArg<Patcher.ViewModelParams>()) }
)
}
composable<Update> {
val data = it.toRoute<Update>()
UpdateScreen(
onBackClick = navController::popBackStack,
vm = koinViewModel { parametersOf(data.downloadOnScreenEntry) }
)
}
navigation<SelectedApplicationInfo>(startDestination = SelectedApplicationInfo.Main) {
composable<SelectedApplicationInfo.Main> {
val parentBackStackEntry = navController.navGraphEntry(it)
val data =
parentBackStackEntry.getComplexArg<SelectedApplicationInfo.ViewModelParams>()
val viewModel =
koinNavViewModel<SelectedAppInfoViewModel>(viewModelStoreOwner = parentBackStackEntry) {
parametersOf(data)
}
SelectedAppInfoScreen(
onBackClick = navController::popBackStack,
onPatchClick = {
it.lifecycleScope.launch {
navController.navigateComplex(
Patcher,
viewModel.getPatcherParams()
)
}
},
onPatchSelectorClick = { app, patches, options ->
navController.navigateComplex(
SelectedApplicationInfo.PatchesSelector,
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
app,
patches,
options
) )
) )
}, },
onBackClick = { navController.pop() }, onRequiredOptions = { app, patches, options ->
viewModel = getComposeViewModel { parametersOf(destination.installedApp) } navController.navigateComplex(
) SelectedApplicationInfo.RequiredOptions,
SelectedApplicationInfo.PatchesSelector.ViewModelParams(
is Destination.Settings -> SettingsScreen( app,
onBackClick = { navController.pop() }, patches,
startDestination = destination.startDestination options
)
is Destination.AppSelector -> AppSelectorScreen(
onAppClick = { navController.navigate(Destination.VersionSelector(it)) },
onStorageClick = {
navController.navigate(
Destination.SelectedApplicationInfo(
it
) )
) )
}, },
onBackClick = { navController.pop() } vm = viewModel
)
}
composable<SelectedApplicationInfo.PatchesSelector> {
val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
) )
is Destination.VersionSelector -> VersionSelectorScreen( PatchesSelectorScreen(
onBackClick = { navController.pop() }, onBackClick = navController::popBackStack,
onAppClick = { selectedApp -> onSave = { patches, options ->
navController.navigate( selectedAppInfoVm.updateConfiguration(patches, options)
Destination.SelectedApplicationInfo( navController.popBackStack()
selectedApp,
destination.patchSelection,
)
)
}, },
viewModel = getComposeViewModel { vm = koinViewModel { parametersOf(data) }
parametersOf(
destination.packageName,
destination.patchSelection
) )
} }
composable<SelectedApplicationInfo.RequiredOptions> {
val data =
it.getComplexArg<SelectedApplicationInfo.PatchesSelector.ViewModelParams>()
val selectedAppInfoVm = koinNavViewModel<SelectedAppInfoViewModel>(
viewModelStoreOwner = navController.navGraphEntry(it)
) )
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen( RequiredOptionsScreen(
onPatchClick = { app, patches, options -> onBackClick = navController::popBackStack,
navController.navigate( onContinue = { patches, options ->
Destination.Patcher( selectedAppInfoVm.updateConfiguration(patches, options)
app, patches, options it.lifecycleScope.launch {
) navController.navigateComplex(
Patcher,
selectedAppInfoVm.getPatcherParams()
) )
}
}, },
onBackClick = navController::pop, vm = koinViewModel { parametersOf(data) }
vm = getComposeViewModel {
parametersOf(
SelectedAppInfoViewModel.Params(
destination.selectedApp,
destination.patchSelection
)
) )
} }
) }
is Destination.Patcher -> PatcherScreen( navigation<Settings>(startDestination = Settings.Main) {
onBackClick = { navController.popUpTo { it is Destination.Dashboard } }, composable<Settings.Main> {
vm = getComposeViewModel { parametersOf(destination) } SettingsScreen(
onBackClick = navController::popBackStack,
navigate = navController::navigate
) )
} }
composable<Settings.General> {
GeneralSettingsScreen(onBackClick = navController::popBackStack)
}
composable<Settings.Advanced> {
AdvancedSettingsScreen(onBackClick = navController::popBackStack)
}
composable<Settings.Updates> {
UpdatesSettingsScreen(
onBackClick = navController::popBackStack,
onChangelogClick = { navController.navigate(Settings.Changelogs) },
onUpdateClick = { navController.navigate(Update()) }
)
}
composable<Settings.Downloads> {
DownloadsSettingsScreen(onBackClick = navController::popBackStack)
}
composable<Settings.ImportExport> {
ImportExportSettingsScreen(onBackClick = navController::popBackStack)
}
composable<Settings.About> {
AboutSettingsScreen(
onBackClick = navController::popBackStack,
navigate = navController::navigate
)
}
composable<Settings.Changelogs> {
ChangelogsScreen(onBackClick = navController::popBackStack)
}
composable<Settings.Contributors> {
ContributorScreen(onBackClick = navController::popBackStack)
}
composable<Settings.Licenses> {
LicensesScreen(onBackClick = navController::popBackStack)
}
composable<Settings.DeveloperOptions> {
DeveloperOptionsScreen(onBackClick = navController::popBackStack)
} }
} }
} }
} }
@Composable
private fun NavController.navGraphEntry(entry: NavBackStackEntry) =
remember(entry) { getBackStackEntry(entry.destination.parent!!.id) }
// Androidx Navigation does not support storing complex types in route objects, so we have to store them inside the saved state handle of the back stack entry instead.
private fun <T : Parcelable, R : ComplexParameter<T>> NavController.navigateComplex(
route: R,
data: T
) {
navigate(route)
getBackStackEntry(route).savedStateHandle["args"] = data
} }
private fun <T : Parcelable> NavBackStackEntry.getComplexArg() = savedStateHandle.get<T>("args")!!

View File

@ -1,9 +1,15 @@
package app.revanced.manager package app.revanced.manager
import android.app.Activity
import android.app.Application import android.app.Application
import android.os.Bundle
import android.util.Log
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.di.* import app.revanced.manager.di.*
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import coil.Coil import coil.Coil
import coil.ImageLoader import coil.ImageLoader
@ -23,6 +29,9 @@ class ManagerApplication : Application() {
private val scope = MainScope() private val scope = MainScope()
private val prefs: PreferencesManager by inject() private val prefs: PreferencesManager by inject()
private val patchBundleRepository: PatchBundleRepository by inject() private val patchBundleRepository: PatchBundleRepository by inject()
private val downloaderPluginRepository: DownloaderPluginRepository by inject()
private val fs: Filesystem by inject()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -59,11 +68,43 @@ class ManagerApplication : Application() {
scope.launch { scope.launch {
prefs.preload() prefs.preload()
} }
scope.launch(Dispatchers.Default) {
downloaderPluginRepository.reload()
}
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
with(patchBundleRepository) { with(patchBundleRepository) {
reload() reload()
updateCheck() updateCheck()
} }
} }
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
private var firstActivityCreated = false
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (firstActivityCreated) return
firstActivityCreated = true
// We do not want to call onFreshProcessStart() if there is state to restore.
// This can happen on system-initiated process death.
if (savedInstanceState == null) {
Log.d(tag, "Fresh process created")
onFreshProcessStart()
} else Log.d(tag, "System-initiated process death detected")
}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {}
})
}
private fun onFreshProcessStart() {
fs.uiTempDir.apply {
deleteRecursively()
mkdirs()
}
} }
} }

View File

@ -9,6 +9,8 @@ import android.os.Environment
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import app.revanced.manager.util.RequestManageStorageContract import app.revanced.manager.util.RequestManageStorageContract
import java.io.File
import java.nio.file.Path
class Filesystem(private val app: Application) { class Filesystem(private val app: Application) {
val contentResolver = app.contentResolver // TODO: move Content Resolver operations to here. val contentResolver = app.contentResolver // TODO: move Content Resolver operations to here.
@ -17,21 +19,33 @@ class Filesystem(private val app: Application) {
* A directory that gets cleared when the app restarts. * A directory that gets cleared when the app restarts.
* Do not store paths to this directory in a parcel. * Do not store paths to this directory in a parcel.
*/ */
val tempDir = app.getDir("ephemeral", Context.MODE_PRIVATE).apply { val tempDir: File = app.getDir("ephemeral", Context.MODE_PRIVATE).apply {
deleteRecursively() deleteRecursively()
mkdirs() mkdirs()
} }
fun externalFilesDir() = Environment.getExternalStorageDirectory().toPath() /**
* A directory for storing temporary files related to UI.
* This is the same as [tempDir], but does not get cleared on system-initiated process death.
* Paths to this directory can be safely stored in parcels.
*/
val uiTempDir: File = app.getDir("ui_ephemeral", Context.MODE_PRIVATE)
fun externalFilesDir(): Path = Environment.getExternalStorageDirectory().toPath()
private fun usesManagePermission() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R private fun usesManagePermission() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
private val storagePermissionName = if (usesManagePermission()) Manifest.permission.MANAGE_EXTERNAL_STORAGE else Manifest.permission.READ_EXTERNAL_STORAGE private val storagePermissionName =
if (usesManagePermission()) Manifest.permission.MANAGE_EXTERNAL_STORAGE else Manifest.permission.READ_EXTERNAL_STORAGE
fun permissionContract(): Pair<ActivityResultContract<String, Boolean>, String> { fun permissionContract(): Pair<ActivityResultContract<String, Boolean>, String> {
val contract = if (usesManagePermission()) RequestManageStorageContract() else ActivityResultContracts.RequestPermission() val contract =
if (usesManagePermission()) RequestManageStorageContract() else ActivityResultContracts.RequestPermission()
return contract to storagePermissionName return contract to storagePermissionName
} }
fun hasStoragePermission() = if (usesManagePermission()) Environment.isExternalStorageManager() else app.checkSelfPermission(storagePermissionName) == PackageManager.PERMISSION_GRANTED fun hasStoragePermission() =
if (usesManagePermission()) Environment.isExternalStorageManager() else app.checkSelfPermission(
storagePermissionName
) == PackageManager.PERMISSION_GRANTED
} }

View File

@ -16,9 +16,14 @@ import app.revanced.manager.data.room.bundles.PatchBundleEntity
import app.revanced.manager.data.room.options.Option import app.revanced.manager.data.room.options.Option
import app.revanced.manager.data.room.options.OptionDao import app.revanced.manager.data.room.options.OptionDao
import app.revanced.manager.data.room.options.OptionGroup import app.revanced.manager.data.room.options.OptionGroup
import app.revanced.manager.data.room.plugins.TrustedDownloaderPlugin
import app.revanced.manager.data.room.plugins.TrustedDownloaderPluginDao
import kotlin.random.Random import kotlin.random.Random
@Database(entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class], version = 1) @Database(
entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, OptionGroup::class, Option::class, TrustedDownloaderPlugin::class],
version = 1
)
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun patchBundleDao(): PatchBundleDao abstract fun patchBundleDao(): PatchBundleDao
@ -26,6 +31,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun downloadedAppDao(): DownloadedAppDao abstract fun downloadedAppDao(): DownloadedAppDao
abstract fun installedAppDao(): InstalledAppDao abstract fun installedAppDao(): InstalledAppDao
abstract fun optionDao(): OptionDao abstract fun optionDao(): OptionDao
abstract fun trustedDownloaderPluginDao(): TrustedDownloaderPluginDao
companion object { companion object {
fun generateUid() = Random.Default.nextInt() fun generateUid() = Random.Default.nextInt()

View File

@ -12,4 +12,5 @@ data class DownloadedApp(
@ColumnInfo(name = "package_name") val packageName: String, @ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "version") val version: String, @ColumnInfo(name = "version") val version: String,
@ColumnInfo(name = "directory") val directory: File, @ColumnInfo(name = "directory") val directory: File,
@ColumnInfo(name = "last_used") val lastUsed: Long = System.currentTimeMillis()
) )

View File

@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
@ -14,8 +15,11 @@ interface DownloadedAppDao {
@Query("SELECT * FROM downloaded_app WHERE package_name = :packageName AND version = :version") @Query("SELECT * FROM downloaded_app WHERE package_name = :packageName AND version = :version")
suspend fun get(packageName: String, version: String): DownloadedApp? suspend fun get(packageName: String, version: String): DownloadedApp?
@Insert @Upsert
suspend fun insert(downloadedApp: DownloadedApp) suspend fun upsert(downloadedApp: DownloadedApp)
@Query("UPDATE downloaded_app SET last_used = :newValue WHERE package_name = :packageName AND version = :version")
suspend fun markUsed(packageName: String, version: String, newValue: Long = System.currentTimeMillis())
@Delete @Delete
suspend fun delete(downloadedApps: Collection<DownloadedApp>) suspend fun delete(downloadedApps: Collection<DownloadedApp>)

View File

@ -1,18 +1,15 @@
package app.revanced.manager.data.room.apps.installed package app.revanced.manager.data.room.apps.installed
import android.os.Parcelable
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import app.revanced.manager.R import app.revanced.manager.R
import kotlinx.parcelize.Parcelize
enum class InstallType(val stringResource: Int) { enum class InstallType(val stringResource: Int) {
DEFAULT(R.string.default_install), DEFAULT(R.string.default_install),
MOUNT(R.string.mount_install) MOUNT(R.string.mount_install)
} }
@Parcelize
@Entity(tableName = "installed_app") @Entity(tableName = "installed_app")
data class InstalledApp( data class InstalledApp(
@PrimaryKey @PrimaryKey
@ -20,4 +17,4 @@ data class InstalledApp(
@ColumnInfo(name = "original_package_name") val originalPackageName: String, @ColumnInfo(name = "original_package_name") val originalPackageName: String,
@ColumnInfo(name = "version") val version: String, @ColumnInfo(name = "version") val version: String,
@ColumnInfo(name = "install_type") val installType: InstallType @ColumnInfo(name = "install_type") val installType: InstallType
) : Parcelable )

View File

@ -8,11 +8,11 @@ interface PatchBundleDao {
@Query("SELECT * FROM patch_bundles") @Query("SELECT * FROM patch_bundles")
suspend fun all(): List<PatchBundleEntity> suspend fun all(): List<PatchBundleEntity>
@Query("SELECT version, integrations_version, auto_update FROM patch_bundles WHERE uid = :uid") @Query("SELECT version, auto_update FROM patch_bundles WHERE uid = :uid")
fun getPropsById(uid: Int): Flow<BundleProperties?> fun getPropsById(uid: Int): Flow<BundleProperties?>
@Query("UPDATE patch_bundles SET version = :patches, integrations_version = :integrations WHERE uid = :uid") @Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid")
suspend fun updateVersion(uid: Int, patches: String?, integrations: String?) suspend fun updateVersion(uid: Int, patches: String?)
@Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid") @Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid")
suspend fun setAutoUpdate(uid: Int, value: Boolean) suspend fun setAutoUpdate(uid: Int, value: Boolean)
@ -26,7 +26,7 @@ interface PatchBundleDao {
@Transaction @Transaction
suspend fun reset() { suspend fun reset() {
purgeCustomBundles() purgeCustomBundles()
updateVersion(0, null, null) // Reset the main source updateVersion(0, null) // Reset the main source
} }
@Query("DELETE FROM patch_bundles WHERE uid = :uid") @Query("DELETE FROM patch_bundles WHERE uid = :uid")

View File

@ -29,21 +29,16 @@ sealed class Source {
} }
} }
data class VersionInfo(
@ColumnInfo(name = "version") val patches: String? = null,
@ColumnInfo(name = "integrations_version") val integrations: String? = null,
)
@Entity(tableName = "patch_bundles") @Entity(tableName = "patch_bundles")
data class PatchBundleEntity( data class PatchBundleEntity(
@PrimaryKey val uid: Int, @PrimaryKey val uid: Int,
@ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "name") val name: String,
@Embedded val versionInfo: VersionInfo, @ColumnInfo(name = "version") val version: String? = null,
@ColumnInfo(name = "source") val source: Source, @ColumnInfo(name = "source") val source: Source,
@ColumnInfo(name = "auto_update") val autoUpdate: Boolean @ColumnInfo(name = "auto_update") val autoUpdate: Boolean
) )
data class BundleProperties( data class BundleProperties(
@Embedded val versionInfo: VersionInfo, @ColumnInfo(name = "version") val version: String? = null,
@ColumnInfo(name = "auto_update") val autoUpdate: Boolean @ColumnInfo(name = "auto_update") val autoUpdate: Boolean
) )

View File

@ -20,6 +20,8 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long import kotlinx.serialization.json.long
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.typeOf
@Entity( @Entity(
tableName = "options", tableName = "options",
@ -46,8 +48,8 @@ data class Option(
val errorMessage = "Cannot deserialize value as ${option.type}" val errorMessage = "Cannot deserialize value as ${option.type}"
try { try {
if (option.type.endsWith("Array")) { if (option.type.classifier == List::class) {
val elementType = option.type.removeSuffix("Array") val elementType = option.type.arguments.first().type!!
return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) } return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) }
} }
@ -67,12 +69,17 @@ data class Option(
allowSpecialFloatingPointValues = true allowSpecialFloatingPointValues = true
} }
private fun deserializeBasicType(type: String, value: JsonPrimitive) = when (type) { private fun deserializeBasicType(type: KType, value: JsonPrimitive) = when (type) {
"Boolean" -> value.boolean typeOf<Boolean>() -> value.boolean
"Int" -> value.int typeOf<Int>() -> value.int
"Long" -> value.long typeOf<Long>() -> value.long
"Float" -> value.float typeOf<Float>() -> value.float
"String" -> value.content.also { if (!value.isString) throw SerializationException("Expected value to be a string: $value") } typeOf<String>() -> value.content.also {
if (!value.isString) throw SerializationException(
"Expected value to be a string: $value"
)
}
else -> throw SerializationException("Unknown type: $type") else -> throw SerializationException("Unknown type: $type")
} }

View File

@ -0,0 +1,11 @@
package app.revanced.manager.data.room.plugins
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "trusted_downloader_plugins")
class TrustedDownloaderPlugin(
@PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "signature") val signature: ByteArray
)

View File

@ -0,0 +1,22 @@
package app.revanced.manager.data.room.plugins
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
@Dao
interface TrustedDownloaderPluginDao {
@Query("SELECT signature FROM trusted_downloader_plugins WHERE package_name = :packageName")
suspend fun getTrustedSignature(packageName: String): ByteArray?
@Upsert
suspend fun upsertTrust(plugin: TrustedDownloaderPlugin)
@Query("DELETE FROM trusted_downloader_plugins WHERE package_name = :packageName")
suspend fun remove(packageName: String)
@Transaction
@Query("DELETE FROM trusted_downloader_plugins WHERE package_name IN (:packageNames)")
suspend fun removeAll(packageNames: Set<String>)
}

View File

@ -22,6 +22,7 @@ val repositoryModule = module {
// It is best to load patch bundles ASAP // It is best to load patch bundles ASAP
createdAtStart() createdAtStart()
} }
singleOf(::DownloaderPluginRepository)
singleOf(::WorkerRepository) singleOf(::WorkerRepository)
singleOf(::DownloadedAppRepository) singleOf(::DownloadedAppRepository)
singleOf(::InstalledAppRepository) singleOf(::InstalledAppRepository)

View File

@ -1,11 +1,9 @@
package app.revanced.manager.di package app.revanced.manager.di
import app.revanced.manager.network.service.HttpService import app.revanced.manager.network.service.HttpService
import app.revanced.manager.network.service.ReVancedService
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
val serviceModule = module { val serviceModule = module {
singleOf(::ReVancedService)
singleOf(::HttpService) singleOf(::HttpService)
} }

View File

@ -9,10 +9,9 @@ val viewModelModule = module {
viewModelOf(::DashboardViewModel) viewModelOf(::DashboardViewModel)
viewModelOf(::SelectedAppInfoViewModel) viewModelOf(::SelectedAppInfoViewModel)
viewModelOf(::PatchesSelectorViewModel) viewModelOf(::PatchesSelectorViewModel)
viewModelOf(::SettingsViewModel) viewModelOf(::GeneralSettingsViewModel)
viewModelOf(::AdvancedSettingsViewModel) viewModelOf(::AdvancedSettingsViewModel)
viewModelOf(::AppSelectorViewModel) viewModelOf(::AppSelectorViewModel)
viewModelOf(::VersionSelectorViewModel)
viewModelOf(::PatcherViewModel) viewModelOf(::PatcherViewModel)
viewModelOf(::UpdateViewModel) viewModelOf(::UpdateViewModel)
viewModelOf(::ChangelogsViewModel) viewModelOf(::ChangelogsViewModel)

View File

@ -4,29 +4,18 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
class LocalPatchBundle(name: String, id: Int, directory: File) : class LocalPatchBundle(name: String, id: Int, directory: File) :
PatchBundleSource(name, id, directory) { PatchBundleSource(name, id, directory) {
suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) { suspend fun replace(patches: InputStream) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
patches?.let { inputStream ->
patchBundleOutputStream().use { outputStream -> patchBundleOutputStream().use { outputStream ->
inputStream.copyTo(outputStream) patches.copyTo(outputStream)
}
}
integrations?.let {
Files.copy(
it,
this@LocalPatchBundle.integrationsFile.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
} }
} }
reload()?.also { reload()?.also {
saveVersion(it.readManifestAttribute("Version"), null) saveVersion(it.readManifestAttribute("Version"))
} }
} }
} }

View File

@ -28,7 +28,6 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil
protected val configRepository: PatchBundlePersistenceRepository by inject() protected val configRepository: PatchBundlePersistenceRepository by inject()
private val app: Application by inject() private val app: Application by inject()
protected val patchesFile = directory.resolve("patches.jar") protected val patchesFile = directory.resolve("patches.jar")
protected val integrationsFile = directory.resolve("integrations.apk")
private val _state = MutableStateFlow(load()) private val _state = MutableStateFlow(load())
val state = _state.asStateFlow() val state = _state.asStateFlow()
@ -58,7 +57,7 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil
if (!hasInstalled()) return State.Missing if (!hasInstalled()) return State.Missing
return try { return try {
State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists))) State.Loaded(PatchBundle(patchesFile))
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(tag, "Failed to load patch bundle with UID $uid", t) Log.e(tag, "Failed to load patch bundle with UID $uid", t)
State.Failed(t) State.Failed(t)
@ -85,9 +84,9 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil
fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default) fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default)
suspend fun getProps() = propsFlow().first()!! suspend fun getProps() = propsFlow().first()!!
suspend fun currentVersion() = getProps().versionInfo suspend fun currentVersion() = getProps().version
protected suspend fun saveVersion(patches: String?, integrations: String?) = protected suspend fun saveVersion(version: String?) =
configRepository.updateVersion(uid, patches, integrations) configRepository.updateVersion(uid, version)
suspend fun setName(name: String) { suspend fun setName(name: String) {
configRepository.setName(uid, name) configRepository.setName(uid, name)

View File

@ -1,20 +1,12 @@
package app.revanced.manager.domain.bundles package app.revanced.manager.domain.bundles
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import app.revanced.manager.data.room.bundles.VersionInfo
import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.dto.BundleAsset
import app.revanced.manager.network.dto.BundleInfo
import app.revanced.manager.network.service.HttpService import app.revanced.manager.network.service.HttpService
import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.JAR_MIMETYPE
import io.ktor.client.request.url import io.ktor.client.request.url
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.inject import org.koin.core.component.inject
import java.io.File import java.io.File
@ -24,27 +16,16 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo
PatchBundleSource(name, id, directory) { PatchBundleSource(name, id, directory) {
protected val http: HttpService by inject() protected val http: HttpService by inject()
protected abstract suspend fun getLatestInfo(): BundleInfo protected abstract suspend fun getLatestInfo(): ReVancedAsset
private suspend fun download(info: BundleInfo) = withContext(Dispatchers.IO) { private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) {
val (patches, integrations) = info
coroutineScope {
launch {
patchBundleOutputStream().use { patchBundleOutputStream().use {
http.streamTo(it) { http.streamTo(it) {
url(patches.url) url(info.downloadUrl)
}
} }
} }
launch { saveVersion(info.version)
http.download(integrationsFile) {
url(integrations.url)
}
}
}
saveVersion(patches.version, integrations.version)
reload() reload()
} }
@ -54,20 +35,15 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo
suspend fun update(): Boolean = withContext(Dispatchers.IO) { suspend fun update(): Boolean = withContext(Dispatchers.IO) {
val info = getLatestInfo() val info = getLatestInfo()
if (hasInstalled() && VersionInfo( if (hasInstalled() && info.version == currentVersion())
info.patches.version,
info.integrations.version
) == currentVersion()
) {
return@withContext false return@withContext false
}
download(info) download(info)
true true
} }
suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) { suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) {
arrayOf(patchesFile, integrationsFile).forEach(File::delete) patchesFile.delete()
reload() reload()
} }
@ -81,7 +57,7 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo
class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) : class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
RemotePatchBundle(name, id, directory, endpoint) { RemotePatchBundle(name, id, directory, endpoint) {
override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { override suspend fun getLatestInfo() = withContext(Dispatchers.IO) {
http.request<BundleInfo> { http.request<ReVancedAsset> {
url(endpoint) url(endpoint)
}.getOrThrow() }.getOrThrow()
} }
@ -91,22 +67,5 @@ class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
RemotePatchBundle(name, id, directory, endpoint) { RemotePatchBundle(name, id, directory, endpoint) {
private val api: ReVancedAPI by inject() private val api: ReVancedAPI by inject()
override suspend fun getLatestInfo() = coroutineScope { override suspend fun getLatestInfo() = api.getPatchesUpdate().getOrThrow()
fun getAssetAsync(repo: String, mime: String) = async(Dispatchers.IO) {
api
.getLatestRelease(repo)
.getOrThrow()
.let {
BundleAsset(it.version, it.findAssetByType(mime).downloadUrl)
}
}
val patches = getAssetAsync("revanced-patches", JAR_MIMETYPE)
val integrations = getAssetAsync("revanced-integrations", APK_MIMETYPE)
BundleInfo(
patches.await(),
integrations.await()
)
}
} }

View File

@ -109,7 +109,12 @@ class RootInstaller(
stockAPK?.let { stockApp -> stockAPK?.let { stockApp ->
pm.getPackageInfo(packageName)?.let { packageInfo -> pm.getPackageInfo(packageName)?.let { packageInfo ->
if (packageInfo.versionName <= version) // TODO: get user id programmatically
if (pm.getVersionCode(packageInfo) <= pm.getVersionCode(
pm.getPackageInfo(patchedAPK)
?: error("Failed to get package info for patched app")
)
)
execute("pm uninstall -k --user 0 $packageName").assertSuccess("Failed to uninstall stock app") execute("pm uninstall -k --user 0 $packageName").assertSuccess("Failed to uninstall stock app")
} }

View File

@ -12,6 +12,8 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.nio.file.Files import java.nio.file.Files
import java.security.UnrecoverableKeyException import java.security.UnrecoverableKeyException
import java.util.Date
import kotlin.time.Duration.Companion.days
class KeystoreManager(app: Application, private val prefs: PreferencesManager) { class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
companion object Constants { companion object Constants {
@ -19,6 +21,7 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
* Default alias and password for the keystore. * Default alias and password for the keystore.
*/ */
const val DEFAULT = "ReVanced" const val DEFAULT = "ReVanced"
private val eightYearsFromNow get() = Date(System.currentTimeMillis() + (365.days * 8).inWholeMilliseconds * 24)
} }
private val keystorePath = private val keystorePath =
@ -29,23 +32,26 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
prefs.keystorePass.value = pass prefs.keystorePass.value = pass
} }
private suspend fun signingOptions(path: File = keystorePath) = ApkUtils.SigningOptions( private suspend fun signingDetails(path: File = keystorePath) = ApkUtils.KeyStoreDetails(
keyStore = path, keyStore = path,
keyStorePassword = null, keyStorePassword = null,
alias = prefs.keystoreCommonName.get(), alias = prefs.keystoreCommonName.get(),
signer = prefs.keystoreCommonName.get(),
password = prefs.keystorePass.get() password = prefs.keystorePass.get()
) )
suspend fun sign(input: File, output: File) = withContext(Dispatchers.Default) { suspend fun sign(input: File, output: File) = withContext(Dispatchers.Default) {
ApkUtils.sign(input, output, signingOptions()) ApkUtils.signApk(input, output, prefs.keystoreCommonName.get(), signingDetails())
} }
suspend fun regenerate() = withContext(Dispatchers.Default) { suspend fun regenerate() = withContext(Dispatchers.Default) {
val keyCertPair = ApkSigner.newPrivateKeyCertificatePair(
prefs.keystoreCommonName.get(),
eightYearsFromNow
)
val ks = ApkSigner.newKeyStore( val ks = ApkSigner.newKeyStore(
setOf( setOf(
ApkSigner.KeyStoreEntry( ApkSigner.KeyStoreEntry(
DEFAULT, DEFAULT DEFAULT, DEFAULT, keyCertPair
) )
) )
) )
@ -64,7 +70,7 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) {
try { try {
val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null) val ks = ApkSigner.readKeyStore(ByteArrayInputStream(keystoreData), null)
ApkSigner.readKeyCertificatePair(ks, cn, pass) ApkSigner.readPrivateKeyCertificatePair(ks, cn, pass)
} catch (_: UnrecoverableKeyException) { } catch (_: UnrecoverableKeyException) {
return false return false
} catch (_: IllegalArgumentException) { } catch (_: IllegalArgumentException) {

View File

@ -12,15 +12,12 @@ class PreferencesManager(
val api = stringPreference("api_url", "https://api.revanced.app") val api = stringPreference("api_url", "https://api.revanced.app")
val multithreadingDexFileWriter = booleanPreference("multithreading_dex_file_writer", true)
val useProcessRuntime = booleanPreference("use_process_runtime", false) val useProcessRuntime = booleanPreference("use_process_runtime", false)
val patcherProcessMemoryLimit = intPreference("process_runtime_memory_limit", 700) val patcherProcessMemoryLimit = intPreference("process_runtime_memory_limit", 700)
val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT) val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT) val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
val preferSplits = booleanPreference("prefer_splits", false)
val firstLaunch = booleanPreference("first_launch", true) val firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false) val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true) val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
@ -29,4 +26,6 @@ class PreferencesManager(
val disableSelectionWarning = booleanPreference("disable_selection_warning", false) val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false) val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false)
val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true) val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true)
val acknowledgedDownloaderPlugins = stringSetPreference("acknowledged_downloader_plugins", emptySet())
} }

View File

@ -26,6 +26,9 @@ abstract class BasePreferencesManager(private val context: Context, name: String
protected fun stringPreference(key: String, default: String) = protected fun stringPreference(key: String, default: String) =
StringPreference(dataStore, key, default) StringPreference(dataStore, key, default)
protected fun stringSetPreference(key: String, default: Set<String>) =
StringSetPreference(dataStore, key, default)
protected fun booleanPreference(key: String, default: Boolean) = protected fun booleanPreference(key: String, default: Boolean) =
BooleanPreference(dataStore, key, default) BooleanPreference(dataStore, key, default)
@ -52,6 +55,10 @@ class EditorContext(private val prefs: MutablePreferences) {
var <T> Preference<T>.value var <T> Preference<T>.value
get() = prefs.run { read() } get() = prefs.run { read() }
set(value) = prefs.run { write(value) } set(value) = prefs.run { write(value) }
operator fun Preference<Set<String>>.plusAssign(value: String) = prefs.run {
write(read() + value)
}
} }
abstract class Preference<T>( abstract class Preference<T>(
@ -65,10 +72,12 @@ abstract class Preference<T>(
suspend fun get() = flow.first() suspend fun get() = flow.first()
fun getBlocking() = runBlocking { get() } fun getBlocking() = runBlocking { get() }
@Composable @Composable
fun getAsState() = flow.collectAsStateWithLifecycle(initialValue = remember { fun getAsState() = flow.collectAsStateWithLifecycle(initialValue = remember {
getBlocking() getBlocking()
}) })
suspend fun update(value: T) = dataStore.editor { suspend fun update(value: T) = dataStore.editor {
this@Preference.value = value this@Preference.value = value
} }
@ -108,6 +117,14 @@ class StringPreference(
override val key = stringPreferencesKey(key) override val key = stringPreferencesKey(key)
} }
class StringSetPreference(
dataStore: DataStore<Preferences>,
key: String,
default: Set<String>
) : BasePreference<Set<String>>(dataStore, default) {
override val key = stringSetPreferencesKey(key)
}
class BooleanPreference( class BooleanPreference(
dataStore: DataStore<Preferences>, dataStore: DataStore<Preferences>,
key: String, key: String,

View File

@ -2,56 +2,126 @@ package app.revanced.manager.domain.repository
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.os.Parcelable
import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.apps.downloaded.DownloadedApp import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
import app.revanced.manager.network.downloader.AppDownloader import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.plugin.downloader.OutputDownloadScope
import app.revanced.manager.util.PM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import java.io.File import java.io.File
import java.io.FilterOutputStream
import java.nio.file.StandardOpenOption
import java.util.concurrent.atomic.AtomicLong
import kotlin.io.path.outputStream
class DownloadedAppRepository( class DownloadedAppRepository(
app: Application, private val app: Application,
db: AppDatabase db: AppDatabase,
private val pm: PM
) { ) {
private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE) private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE)
private val dao = db.downloadedAppDao() private val dao = db.downloadedAppDao()
fun getAll() = dao.getAllApps().distinctUntilChanged() fun getAll() = dao.getAllApps().distinctUntilChanged()
fun getApkFileForApp(app: DownloadedApp): File = getApkFileForDir(dir.resolve(app.directory)) fun getApkFileForApp(app: DownloadedApp): File =
getApkFileForDir(dir.resolve(app.directory))
private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first() private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first()
suspend fun download( suspend fun download(
app: AppDownloader.App, plugin: LoadedDownloaderPlugin,
preferSplits: Boolean, data: Parcelable,
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit = {}, expectedPackageName: String,
expectedVersion: String?,
onDownload: suspend (downloadProgress: Pair<Long, Long?>) -> Unit,
): File { ): File {
this.get(app.packageName, app.version)?.let { downloaded ->
return getApkFileForApp(downloaded)
}
// Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here. // Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here.
val relativePath = File(generateUid().toString()) val relativePath = File(generateUid().toString())
val savePath = dir.resolve(relativePath).also { it.mkdirs() } val saveDir = dir.resolve(relativePath).also { it.mkdirs() }
val targetFile = saveDir.resolve("base.apk").toPath()
try { try {
app.download(savePath, preferSplits, onDownload) val downloadSize = AtomicLong(0)
val downloadedBytes = AtomicLong(0)
dao.insert(DownloadedApp( channelFlow {
packageName = app.packageName, val scope = object : OutputDownloadScope {
version = app.version, override val pluginPackageName = plugin.packageName
override val hostPackageName = app.packageName
override suspend fun reportSize(size: Long) {
require(size > 0) { "Size must be greater than zero" }
require(
downloadSize.compareAndSet(
0,
size
)
) { "Download size has already been set" }
send(downloadedBytes.get() to size)
}
}
fun emitProgress(bytes: Long) {
val newValue = downloadedBytes.addAndGet(bytes)
val totalSize = downloadSize.get()
if (totalSize < 1) return
trySend(newValue to totalSize).getOrThrow()
}
targetFile.outputStream(StandardOpenOption.CREATE_NEW).buffered().use {
val stream = object : FilterOutputStream(it) {
override fun write(b: Int) = out.write(b).also { emitProgress(1) }
override fun write(b: ByteArray?, off: Int, len: Int) =
out.write(b, off, len).also {
emitProgress(
(len - off).toLong()
)
}
}
plugin.download(scope, data, stream)
}
}
.conflate()
.flowOn(Dispatchers.IO)
.collect { (downloaded, size) -> onDownload(downloaded to size) }
if (downloadedBytes.get() < 1) error("Downloader did not download anything.")
val pkgInfo =
pm.getPackageInfo(targetFile.toFile()) ?: error("Downloaded APK file is invalid")
if (pkgInfo.packageName != expectedPackageName) error("Downloaded APK has the wrong package name. Expected: $expectedPackageName, Actual: ${pkgInfo.packageName}")
if (expectedVersion != null && pkgInfo.versionName != expectedVersion) error("Downloaded APK has the wrong version. Expected: $expectedVersion, Actual: ${pkgInfo.versionName}")
// Delete the previous copy (if present).
dao.get(pkgInfo.packageName, pkgInfo.versionName!!)?.directory?.let {
if (!dir.resolve(it).deleteRecursively()) throw Exception("Failed to delete existing directory")
}
dao.upsert(
DownloadedApp(
packageName = pkgInfo.packageName,
version = pkgInfo.versionName!!,
directory = relativePath, directory = relativePath,
)) )
)
} catch (e: Exception) { } catch (e: Exception) {
savePath.deleteRecursively() saveDir.deleteRecursively()
throw e throw e
} }
// Return the Apk file. // Return the Apk file.
return getApkFileForDir(savePath) return getApkFileForDir(saveDir)
} }
suspend fun get(packageName: String, version: String) = dao.get(packageName, version) suspend fun get(packageName: String, version: String, markUsed: Boolean = false) =
dao.get(packageName, version)?.also {
if (markUsed) dao.markUsed(packageName, version)
}
suspend fun delete(downloadedApps: Collection<DownloadedApp>) { suspend fun delete(downloadedApps: Collection<DownloadedApp>) {
downloadedApps.forEach { downloadedApps.forEach {

View File

@ -0,0 +1,168 @@
package app.revanced.manager.domain.repository
import android.app.Application
import android.content.pm.PackageManager
import android.os.Parcelable
import android.util.Log
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.plugins.TrustedDownloaderPlugin
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.network.downloader.DownloaderPluginState
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.network.downloader.ParceledDownloaderData
import app.revanced.manager.plugin.downloader.DownloaderBuilder
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.Scope
import app.revanced.manager.util.PM
import app.revanced.manager.util.tag
import dalvik.system.PathClassLoader
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.lang.reflect.Modifier
@OptIn(PluginHostApi::class)
class DownloaderPluginRepository(
private val pm: PM,
private val prefs: PreferencesManager,
private val app: Application,
db: AppDatabase
) {
private val trustDao = db.trustedDownloaderPluginDao()
private val _pluginStates = MutableStateFlow(emptyMap<String, DownloaderPluginState>())
val pluginStates = _pluginStates.asStateFlow()
val loadedPluginsFlow = pluginStates.map { states ->
states.values.filterIsInstance<DownloaderPluginState.Loaded>().map { it.plugin }
}
private val acknowledgedDownloaderPlugins = prefs.acknowledgedDownloaderPlugins
private val installedPluginPackageNames = MutableStateFlow(emptySet<String>())
val newPluginPackageNames = combine(
installedPluginPackageNames,
acknowledgedDownloaderPlugins.flow
) { installed, acknowledged ->
installed subtract acknowledged
}
suspend fun reload() {
val plugins =
withContext(Dispatchers.IO) {
pm.getPackagesWithFeature(PLUGIN_FEATURE)
.associate { it.packageName to loadPlugin(it.packageName) }
}
_pluginStates.value = plugins
installedPluginPackageNames.value = plugins.keys
val acknowledgedPlugins = acknowledgedDownloaderPlugins.get()
val uninstalledPlugins = acknowledgedPlugins subtract installedPluginPackageNames.value
if (uninstalledPlugins.isNotEmpty()) {
Log.d(tag, "Uninstalled plugins: ${uninstalledPlugins.joinToString(", ")}")
acknowledgedDownloaderPlugins.update(acknowledgedPlugins subtract uninstalledPlugins)
trustDao.removeAll(uninstalledPlugins)
}
}
fun unwrapParceledData(data: ParceledDownloaderData): Pair<LoadedDownloaderPlugin, Parcelable> {
val plugin =
(_pluginStates.value[data.pluginPackageName] as? DownloaderPluginState.Loaded)?.plugin
?: throw Exception("Downloader plugin with name ${data.pluginPackageName} is not available")
return plugin to data.unwrapWith(plugin)
}
private suspend fun loadPlugin(packageName: String): DownloaderPluginState {
try {
if (!verify(packageName)) return DownloaderPluginState.Untrusted
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(tag, "Got exception while verifying plugin $packageName", e)
return DownloaderPluginState.Failed(e)
}
return try {
val packageInfo = pm.getPackageInfo(packageName, flags = PackageManager.GET_META_DATA)!!
val className = packageInfo.applicationInfo!!.metaData.getString(METADATA_PLUGIN_CLASS)
?: throw Exception("Missing metadata attribute $METADATA_PLUGIN_CLASS")
val classLoader =
PathClassLoader(packageInfo.applicationInfo!!.sourceDir, app.classLoader)
val pluginContext = app.createPackageContext(packageName, 0)
val downloader = classLoader
.loadClass(className)
.getDownloaderBuilder()
.build(
scopeImpl = object : Scope {
override val hostPackageName = app.packageName
override val pluginPackageName = pluginContext.packageName
},
context = pluginContext
)
DownloaderPluginState.Loaded(
LoadedDownloaderPlugin(
packageName,
with(pm) { packageInfo.label() },
packageInfo.versionName!!,
downloader.get,
downloader.download,
classLoader
)
)
} catch (e: CancellationException) {
throw e
} catch (t: Throwable) {
Log.e(tag, "Failed to load plugin $packageName", t)
DownloaderPluginState.Failed(t)
}
}
suspend fun trustPackage(packageName: String) {
trustDao.upsertTrust(
TrustedDownloaderPlugin(
packageName,
pm.getSignature(packageName).toByteArray()
)
)
reload()
prefs.edit {
acknowledgedDownloaderPlugins += packageName
}
}
suspend fun revokeTrustForPackage(packageName: String) =
trustDao.remove(packageName).also { reload() }
suspend fun acknowledgeAllNewPlugins() =
acknowledgedDownloaderPlugins.update(installedPluginPackageNames.value)
private suspend fun verify(packageName: String): Boolean {
val expectedSignature =
trustDao.getTrustedSignature(packageName) ?: return false
return pm.hasSignature(packageName, expectedSignature)
}
private companion object {
const val PLUGIN_FEATURE = "app.revanced.manager.plugin.downloader"
const val METADATA_PLUGIN_CLASS = "app.revanced.manager.plugin.downloader.class"
const val PUBLIC_STATIC = Modifier.PUBLIC or Modifier.STATIC
val Int.isPublicStatic get() = (this and PUBLIC_STATIC) == PUBLIC_STATIC
val Class<*>.isDownloaderBuilder get() = DownloaderBuilder::class.java.isAssignableFrom(this)
@Suppress("UNCHECKED_CAST")
fun Class<*>.getDownloaderBuilder() =
declaredMethods
.firstOrNull { it.modifiers.isPublicStatic && it.returnType.isDownloaderBuilder && it.parameterTypes.isEmpty() }
?.let { it(null) as DownloaderBuilder<Parcelable> }
?: throw Exception("Could not find a valid downloader implementation in class $canonicalName")
}
}

View File

@ -4,7 +4,6 @@ import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.bundles.PatchBundleEntity
import app.revanced.manager.data.room.bundles.Source import app.revanced.manager.data.room.bundles.Source
import app.revanced.manager.data.room.bundles.VersionInfo
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
class PatchBundlePersistenceRepository(db: AppDatabase) { class PatchBundlePersistenceRepository(db: AppDatabase) {
@ -26,7 +25,7 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
PatchBundleEntity( PatchBundleEntity(
uid = generateUid(), uid = generateUid(),
name = name, name = name,
versionInfo = VersionInfo(), version = null,
source = source, source = source,
autoUpdate = autoUpdate autoUpdate = autoUpdate
).also { ).also {
@ -35,8 +34,8 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
suspend fun delete(uid: Int) = dao.remove(uid) suspend fun delete(uid: Int) = dao.remove(uid)
suspend fun updateVersion(uid: Int, patches: String?, integrations: String?) = suspend fun updateVersion(uid: Int, version: String?) =
dao.updateVersion(uid, patches, integrations) dao.updateVersion(uid, version)
suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value) suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value)
@ -48,7 +47,7 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
val defaultSource = PatchBundleEntity( val defaultSource = PatchBundleEntity(
uid = 0, uid = 0,
name = "", name = "",
versionInfo = VersionInfo(), version = null,
source = Source.API, source = Source.API,
autoUpdate = false autoUpdate = false
) )

View File

@ -3,7 +3,7 @@ package app.revanced.manager.domain.repository
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import app.revanced.library.PatchUtils import app.revanced.library.mostCommonCompatibleVersions
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.bundles.PatchBundleEntity
@ -55,7 +55,7 @@ class PatchBundleRepository(
val allPatches = val allPatches =
it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet() it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()
PatchUtils.getMostCommonCompatibleVersions(allPatches, countUnusedPatches = true) allPatches.mostCommonCompatibleVersions(countUnusedPatches = true)
.mapValues { (_, versions) -> .mapValues { (_, versions) ->
if (versions.keys.size < 2) if (versions.keys.size < 2)
return@mapValues versions.keys.firstOrNull() return@mapValues versions.keys.firstOrNull()
@ -137,11 +137,11 @@ class PatchBundleRepository(
private fun addBundle(patchBundle: PatchBundleSource) = private fun addBundle(patchBundle: PatchBundleSource) =
_sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } } _sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
suspend fun createLocal(patches: InputStream, integrations: InputStream?) = withContext(Dispatchers.Default) { suspend fun createLocal(patches: InputStream) = withContext(Dispatchers.Default) {
val uid = persistenceRepo.create("", SourceInfo.Local).uid val uid = persistenceRepo.create("", SourceInfo.Local).uid
val bundle = LocalPatchBundle("", uid, directoryOf(uid)) val bundle = LocalPatchBundle("", uid, directoryOf(uid))
bundle.replace(patches, integrations) bundle.replace(patches)
addBundle(bundle) addBundle(bundle)
} }

View File

@ -2,37 +2,41 @@ package app.revanced.manager.network.api
import android.os.Build import android.os.Build
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.network.dto.ReVancedRelease import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.service.ReVancedService import app.revanced.manager.network.dto.ReVancedGitRepository
import app.revanced.manager.network.dto.ReVancedInfo
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.network.utils.APIResponse
import app.revanced.manager.network.utils.getOrThrow import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.network.utils.transform import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import io.ktor.client.request.url
class ReVancedAPI( class ReVancedAPI(
private val service: ReVancedService, private val client: HttpService,
private val prefs: PreferencesManager private val prefs: PreferencesManager
) { ) {
private suspend fun apiUrl() = prefs.api.get() private suspend fun apiUrl() = prefs.api.get()
suspend fun getContributors() = service.getContributors(apiUrl()).transform { it.repositories } private suspend inline fun <reified T> request(api: String, route: String): APIResponse<T> =
withContext(
Dispatchers.IO
) {
client.request {
url("$api/v4/$route")
}
}
suspend fun getLatestRelease(name: String) = private suspend inline fun <reified T> request(route: String) = request<T>(apiUrl(), route)
service.getLatestRelease(apiUrl(), name).transform { it.release }
suspend fun getReleases(name: String) =
service.getReleases(apiUrl(), name).transform { it.releases }
suspend fun getAppUpdate() = suspend fun getAppUpdate() =
getLatestRelease("revanced-manager") getLatestAppInfo().getOrThrow().takeIf { it.version != Build.VERSION.RELEASE }
.getOrThrow()
.takeIf { it.version != Build.VERSION.RELEASE }
suspend fun getInfo(api: String? = null) = service.getInfo(api ?: apiUrl()).transform { it.info } suspend fun getLatestAppInfo() = request<ReVancedAsset>("manager")
suspend fun getPatchesUpdate() = request<ReVancedAsset>("patches")
companion object Extensions { suspend fun getContributors() = request<List<ReVancedGitRepository>>("contributors")
fun ReVancedRelease.findAssetByType(mime: String) =
assets.singleOrNull { it.contentType == mime } ?: throw MissingAssetException(mime) suspend fun getInfo(api: String? = null) = request<ReVancedInfo>(api ?: apiUrl(), "about")
} }
}
class MissingAssetException(type: String) : Exception("No asset with type $type")

View File

@ -1,277 +0,0 @@
package app.revanced.manager.network.downloader
import android.os.Build.SUPPORTED_ABIS
import app.revanced.manager.network.service.HttpService
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.parameter
import io.ktor.client.request.url
import it.skrape.selects.html5.a
import it.skrape.selects.html5.div
import it.skrape.selects.html5.form
import it.skrape.selects.html5.h5
import it.skrape.selects.html5.input
import it.skrape.selects.html5.p
import it.skrape.selects.html5.span
import kotlinx.coroutines.flow.flow
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.component.inject
import java.io.File
class APKMirror : AppDownloader, KoinComponent {
private val httpClient: HttpService = get()
enum class APKType {
APK,
BUNDLE
}
data class Variant(
val apkType: APKType,
val arch: String,
val link: String
)
private suspend fun getAppLink(packageName: String): String {
val searchResults = httpClient.getHtml { url("$APK_MIRROR/?post_type=app_release&searchtype=app&s=$packageName") }
.div {
withId = "content"
findFirst {
div {
withClass = "listWidget"
findAll {
find {
it.children.first().text.contains(packageName)
}!!.children.mapNotNull {
if (it.classNames.isEmpty()) {
it.h5 {
withClass = "appRowTitle"
findFirst {
a {
findFirst {
attribute("href")
}
}
}
}
} else null
}
}
}
}
}
return searchResults.find { url ->
httpClient.getHtml { url(APK_MIRROR + url) }
.div {
withId = "primary"
findFirst {
div {
withClass = "tab-buttons"
findFirst {
div {
withClass = "tab-button-positioning"
findFirst {
children.any {
it.attribute("href") == "https://play.google.com/store/apps/details?id=$packageName"
}
}
}
}
}
}
}
} ?: throw Exception("App isn't available for download")
}
override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow<AppDownloader.App> {
// 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
val versions = mutableListOf<String>()
while (
if (versionFilter.isNotEmpty())
versions.size < versionFilter.size && page <= 7
else
page <= 1
) {
httpClient.getHtml {
url("$APK_MIRROR/uploads/page/$page/")
parameter("appcategory", appCategory)
}.div {
withClass = "widget_appmanager_recentpostswidget"
findFirst {
div {
withClass = "listWidget"
findFirst {
children.mapNotNull { element ->
if (element.className.isEmpty()) {
APKMirrorApp(
packageName = packageName,
version = element.div {
withClass = "infoSlide"
findFirst {
p {
findFirst {
span {
withClass = "infoSlide-value"
findFirst {
text
}
}
}
}
}
}.also {
if (it in versionFilter)
versions.add(it)
},
downloadLink = element.findFirst {
a {
withClass = "downloadLink"
findFirst {
attribute("href")
}
}
}
)
} else null
}
}
}
}
}.onEach { version -> emit(version) }
page++
}
}
@Parcelize
private class APKMirrorApp(
override val packageName: String,
override val version: String,
private val downloadLink: String,
) : AppDownloader.App, KoinComponent {
@IgnoredOnParcel private val httpClient: HttpService by inject()
override suspend fun download(
saveDirectory: File,
preferSplit: Boolean,
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit
) {
val variants = httpClient.getHtml { url(APK_MIRROR + downloadLink) }
.div {
withClass = "variants-table"
findFirst { // list of variants
children.drop(1).map {
Variant(
apkType = it.div {
findFirst {
span {
findFirst {
enumValueOf(text)
}
}
}
},
arch = it.div {
findSecond {
text
}
},
link = it.div {
findFirst {
a {
findFirst {
attribute("href")
}
}
}
}
)
}
}
}
val orderedAPKTypes = mutableListOf(APKType.APK, APKType.BUNDLE)
.also { if (preferSplit) it.reverse() }
val variant = orderedAPKTypes.firstNotNullOfOrNull { apkType ->
supportedArches.firstNotNullOfOrNull { arch ->
variants.find { it.arch == arch && it.apkType == apkType }
}
} ?: throw Exception("No compatible variant found")
if (variant.apkType == APKType.BUNDLE) throw Exception("Split apks are not supported yet") // TODO
val downloadPage = httpClient.getHtml { url(APK_MIRROR + variant.link) }
.a {
withClass = "downloadButton"
findFirst {
attribute("href")
}
}
val downloadLink = httpClient.getHtml { url(APK_MIRROR + downloadPage) }
.form {
withId = "filedownload"
findFirst {
val apkLink = attribute("action")
val id = input {
withAttribute = "name" to "id"
findFirst {
attribute("value")
}
}
val key = input {
withAttribute = "name" to "key"
findFirst {
attribute("value")
}
}
"$apkLink?id=$id&key=$key"
}
}
val targetFile = saveDirectory.resolve("base.apk")
try {
httpClient.download(targetFile) {
url(APK_MIRROR + downloadLink)
onDownload { bytesSentTotal, contentLength ->
onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
}
}
if (variant.apkType == APKType.BUNDLE) {
// TODO: Extract temp.zip
targetFile.delete()
}
} finally {
onDownload(null)
}
}
}
companion object {
const val APK_MIRROR = "https://www.apkmirror.com"
val supportedArches = listOf("universal", "noarch") + SUPPORTED_ABIS
}
}

View File

@ -1,27 +0,0 @@
package app.revanced.manager.network.downloader
import android.os.Parcelable
import kotlinx.coroutines.flow.Flow
import java.io.File
interface AppDownloader {
/**
* Returns all downloadable apps.
*
* @param packageName The package name of the app.
* @param versionFilter A set of versions to filter.
*/
fun getAvailableVersions(packageName: String, versionFilter: Set<String>): Flow<App>
interface App : Parcelable {
val packageName: String
val version: String
suspend fun download(
saveDirectory: File,
preferSplit: Boolean,
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit = {}
)
}
}

View File

@ -0,0 +1,9 @@
package app.revanced.manager.network.downloader
sealed interface DownloaderPluginState {
data object Untrusted : DownloaderPluginState
data class Loaded(val plugin: LoadedDownloaderPlugin) : DownloaderPluginState
data class Failed(val throwable: Throwable) : DownloaderPluginState
}

View File

@ -0,0 +1,15 @@
package app.revanced.manager.network.downloader
import android.os.Parcelable
import app.revanced.manager.plugin.downloader.OutputDownloadScope
import app.revanced.manager.plugin.downloader.GetScope
import java.io.OutputStream
class LoadedDownloaderPlugin(
val packageName: String,
val name: String,
val version: String,
val get: suspend GetScope.(packageName: String, version: String?) -> Pair<Parcelable, String?>?,
val download: suspend OutputDownloadScope.(data: Parcelable, outputStream: OutputStream) -> Unit,
val classLoader: ClassLoader
)

View File

@ -0,0 +1,45 @@
package app.revanced.manager.network.downloader
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
/**
* A container for [Parcelable] data returned from downloaders. Instances of this class can be safely stored in a bundle without needing to set the [ClassLoader].
*/
class ParceledDownloaderData private constructor(
val pluginPackageName: String,
private val bundle: Bundle
) : Parcelable {
constructor(plugin: LoadedDownloaderPlugin, data: Parcelable) : this(
plugin.packageName,
createBundle(data)
)
fun unwrapWith(plugin: LoadedDownloaderPlugin): Parcelable {
bundle.classLoader = plugin.classLoader
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val className = bundle.getString(CLASS_NAME_KEY)!!
val clazz = plugin.classLoader.loadClass(className)
bundle.getParcelable(DATA_KEY, clazz)!! as Parcelable
} else @Suppress("Deprecation") bundle.getParcelable(DATA_KEY)!!
}
private companion object {
const val CLASS_NAME_KEY = "class"
const val DATA_KEY = "data"
fun createBundle(data: Parcelable) = Bundle().apply {
putParcelable(DATA_KEY, data)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) putString(
CLASS_NAME_KEY,
data::class.java.canonicalName
)
}
}
}

View File

@ -1,9 +0,0 @@
package app.revanced.manager.network.dto
import kotlinx.serialization.Serializable
@Serializable
data class BundleInfo(val patches: BundleAsset, val integrations: BundleAsset)
@Serializable
data class BundleAsset(val version: String, val url: String)

View File

@ -1,16 +0,0 @@
package app.revanced.manager.network.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class GithubChangelog(
@SerialName("tag_name") val version: String,
@SerialName("body") val body: String,
@SerialName("assets") val assets: List<GithubAsset>
)
@Serializable
data class GithubAsset(
@SerialName("download_count") val downloadCount: Int,
)

View File

@ -0,0 +1,18 @@
package app.revanced.manager.network.dto
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ReVancedAsset (
@SerialName("download_url")
val downloadUrl: String,
@SerialName("created_at")
val createdAt: LocalDateTime,
@SerialName("signature_download_url")
val signatureDownloadUrl: String? = null,
val description: String,
val version: String,
)

View File

@ -3,19 +3,15 @@ package app.revanced.manager.network.dto
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable
data class ReVancedGitRepositories(
val repositories: List<ReVancedGitRepository>,
)
@Serializable @Serializable
data class ReVancedGitRepository( data class ReVancedGitRepository(
val name: String, val name: String,
val url: String,
val contributors: List<ReVancedContributor>, val contributors: List<ReVancedContributor>,
) )
@Serializable @Serializable
data class ReVancedContributor( data class ReVancedContributor(
@SerialName("login") val username: String, @SerialName("name") val username: String,
@SerialName("avatar_url") val avatarUrl: String, @SerialName("avatar_url") val avatarUrl: String,
) )

View File

@ -1,12 +1,8 @@
package app.revanced.manager.network.dto package app.revanced.manager.network.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable
data class ReVancedInfoParent(
val info: ReVancedInfo,
)
@Serializable @Serializable
data class ReVancedInfo( data class ReVancedInfo(
val name: String, val name: String,
@ -43,7 +39,8 @@ data class ReVancedDonation(
@Serializable @Serializable
data class ReVancedWallet( data class ReVancedWallet(
val network: String, val network: String,
val currency_code: String, @SerialName("currency_code")
val currencyCode: String,
val address: String, val address: String,
val preferred: Boolean val preferred: Boolean
) )

View File

@ -1,41 +0,0 @@
package app.revanced.manager.network.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ReVancedLatestRelease(
val release: ReVancedRelease,
)
@Serializable
data class ReVancedReleases(
val releases: List<ReVancedRelease>
)
@Serializable
data class ReVancedRelease(
val metadata: ReVancedReleaseMeta,
val assets: List<Asset>
) {
val version get() = metadata.tag
}
@Serializable
data class ReVancedReleaseMeta(
@SerialName("tag_name") val tag: String,
val name: String,
val draft: Boolean,
val prerelease: Boolean,
@SerialName("created_at") val createdAt: String,
@SerialName("published_at") val publishedAt: String,
val body: String,
)
@Serializable
data class Asset(
val name: String,
@SerialName("download_count") val downloadCount: Int,
@SerialName("browser_download_url") val downloadUrl: String,
@SerialName("content_type") val contentType: String
)

View File

@ -1,43 +0,0 @@
package app.revanced.manager.network.service
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.url
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ReVancedService(
private val client: HttpService,
) {
suspend fun getLatestRelease(api: String, repo: String): APIResponse<ReVancedLatestRelease> =
withContext(Dispatchers.IO) {
client.request {
url("$api/v2/$repo/releases/latest")
}
}
suspend fun getReleases(api: String, repo: String): APIResponse<ReVancedReleases> =
withContext(Dispatchers.IO) {
client.request {
url("$api/v2/$repo/releases")
}
}
suspend fun getContributors(api: String): APIResponse<ReVancedGitRepositories> =
withContext(Dispatchers.IO) {
client.request {
url("$api/contributors")
}
}
suspend fun getInfo(api: String): APIResponse<ReVancedInfoParent> =
withContext(Dispatchers.IO) {
client.request {
url("$api/v2/info")
}
}
}

View File

@ -22,11 +22,10 @@ class Session(
cacheDir: String, cacheDir: String,
frameworkDir: String, frameworkDir: String,
aaptPath: String, aaptPath: String,
multithreadingDexFileWriter: Boolean,
private val androidContext: Context, private val androidContext: Context,
private val logger: Logger, private val logger: Logger,
private val input: File, private val input: File,
private val onPatchCompleted: () -> Unit, private val onPatchCompleted: suspend () -> Unit,
private val onProgress: (name: String?, state: State?, message: String?) -> Unit private val onProgress: (name: String?, state: State?, message: String?) -> Unit
) : Closeable { ) : Closeable {
private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) = private fun updateProgress(name: String? = null, state: State? = null, message: String? = null) =
@ -38,8 +37,7 @@ class Session(
apkFile = input, apkFile = input,
temporaryFilesPath = tempDir, temporaryFilesPath = tempDir,
frameworkFileDirectory = frameworkDir, frameworkFileDirectory = frameworkDir,
aaptBinaryPath = aaptPath, aaptBinaryPath = aaptPath
multithreadingDexFileWriter = multithreadingDexFileWriter,
) )
) )
@ -51,7 +49,7 @@ class Session(
state = State.RUNNING state = State.RUNNING
) )
this.apply(true).collect { (patch, exception) -> this().collect { (patch, exception) ->
if (patch !in selectedPatches) return@collect if (patch !in selectedPatches) return@collect
if (exception != null) { if (exception != null) {
@ -89,7 +87,7 @@ class Session(
) )
} }
suspend fun run(output: File, selectedPatches: PatchList, integrations: List<File>) { suspend fun run(output: File, selectedPatches: PatchList) {
updateProgress(state = State.COMPLETED) // Unpacking updateProgress(state = State.COMPLETED) // Unpacking
java.util.logging.Logger.getLogger("").apply { java.util.logging.Logger.getLogger("").apply {
@ -103,8 +101,7 @@ class Session(
with(patcher) { with(patcher) {
logger.info("Merging integrations") logger.info("Merging integrations")
acceptIntegrations(integrations.toSet()) this += selectedPatches.toSet()
acceptPatches(selectedPatches.toSet())
logger.info("Applying patches...") logger.info("Applying patches...")
applyPatchesVerbose(selectedPatches.sortedBy { it.name }) applyPatchesVerbose(selectedPatches.sortedBy { it.name })

View File

@ -2,17 +2,17 @@ package app.revanced.manager.patcher.patch
import android.util.Log import android.util.Log
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import app.revanced.patcher.PatchBundleLoader
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchLoader
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.jar.JarFile import java.util.jar.JarFile
class PatchBundle(val patchesJar: File, val integrations: File?) { class PatchBundle(val patchesJar: File) {
private val loader = object : Iterable<Patch<*>> { private val loader = object : Iterable<Patch<*>> {
private fun load(): Iterable<Patch<*>> { private fun load(): Iterable<Patch<*>> {
patchesJar.setReadOnly() patchesJar.setReadOnly()
return PatchBundleLoader.Dex(patchesJar, optimizedDexDirectory = null) return PatchLoader.Dex(setOf(patchesJar))
} }
override fun iterator(): Iterator<Patch<*>> = load().iterator() override fun iterator(): Iterator<Patch<*>> = load().iterator()
@ -41,12 +41,12 @@ class PatchBundle(val patchesJar: File, val integrations: File?) {
/** /**
* Load all patches compatible with the specified package. * Load all patches compatible with the specified package.
*/ */
fun patchClasses(packageName: String) = loader.filter { patch -> fun patches(packageName: String) = loader.filter { patch ->
val compatiblePackages = patch.compatiblePackages val compatiblePackages = patch.compatiblePackages
?: // The patch has no compatibility constraints, which means it is universal. ?: // The patch has no compatibility constraints, which means it is universal.
return@filter true return@filter true
if (!compatiblePackages.any { it.name == packageName }) { if (!compatiblePackages.any { (name, _) -> name == packageName }) {
// Patch is not compatible with this package. // Patch is not compatible with this package.
return@filter false return@filter false
} }

View File

@ -1,14 +1,14 @@
package app.revanced.manager.patcher.patch package app.revanced.manager.patcher.patch
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import app.revanced.patcher.data.ResourceContext
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.ResourcePatch import app.revanced.patcher.patch.Option as PatchOption
import app.revanced.patcher.patch.options.PatchOption import app.revanced.patcher.patch.resourcePatch
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlin.reflect.KType
data class PatchInfo( data class PatchInfo(
val name: String, val name: String,
@ -21,22 +21,26 @@ data class PatchInfo(
patch.name.orEmpty(), patch.name.orEmpty(),
patch.description, patch.description,
patch.use, patch.use,
patch.compatiblePackages?.map { CompatiblePackage(it) }?.toImmutableList(), patch.compatiblePackages?.map { (pkgName, versions) ->
CompatiblePackage(
pkgName,
versions?.toImmutableSet()
)
}?.toImmutableList(),
patch.options.map { (_, option) -> Option(option) }.ifEmpty { null }?.toImmutableList() patch.options.map { (_, option) -> Option(option) }.ifEmpty { null }?.toImmutableList()
) )
fun compatibleWith(packageName: String) = fun compatibleWith(packageName: String) =
compatiblePackages?.any { it.packageName == packageName } ?: true compatiblePackages?.any { it.packageName == packageName } ?: true
fun supportsVersion(packageName: String, versionName: String): Boolean { fun supports(packageName: String, versionName: String?): Boolean {
val packages = compatiblePackages ?: return true // Universal patch val packages = compatiblePackages ?: return true // Universal patch
return packages.any { pkg -> return packages.any { pkg ->
if (pkg.packageName != packageName) { if (pkg.packageName != packageName) return@any false
return@any false if (pkg.versions == null) return@any true
}
pkg.versions == null || pkg.versions.contains(versionName) versionName != null && versionName in pkg.versions
} }
} }
@ -45,16 +49,11 @@ data class PatchInfo(
* The resulting patch cannot be executed. * The resulting patch cannot be executed.
* This is necessary because some functions in ReVanced Library only accept full [Patch] objects. * This is necessary because some functions in ReVanced Library only accept full [Patch] objects.
*/ */
fun toPatcherPatch(): Patch<*> = object : ResourcePatch( fun toPatcherPatch(): Patch<*> =
name = name, resourcePatch(name = name, description = description, use = include) {
description = description, compatiblePackages?.let { pkgs ->
compatiblePackages = compatiblePackages compatibleWith(*pkgs.map { it.packageName to it.versions }.toTypedArray())
?.map(app.revanced.manager.patcher.patch.CompatiblePackage::toPatcherCompatiblePackage) }
?.toSet(),
use = include,
) {
override fun execute(context: ResourceContext) =
throw Exception("Metadata patches cannot be executed")
} }
} }
@ -62,28 +61,15 @@ data class PatchInfo(
data class CompatiblePackage( data class CompatiblePackage(
val packageName: String, val packageName: String,
val versions: ImmutableSet<String>? val versions: ImmutableSet<String>?
) {
constructor(pkg: Patch.CompatiblePackage) : this(
pkg.name,
pkg.versions?.toImmutableSet()
) )
/**
* Converts this [CompatiblePackage] into a [Patch.CompatiblePackage] from patcher.
*/
fun toPatcherCompatiblePackage() = Patch.CompatiblePackage(
name = packageName,
versions = versions,
)
}
@Immutable @Immutable
data class Option<T>( data class Option<T>(
val title: String, val title: String,
val key: String, val key: String,
val description: String, val description: String,
val required: Boolean, val required: Boolean,
val type: String, val type: KType,
val default: T?, val default: T?,
val presets: Map<String, T?>?, val presets: Map<String, T?>?,
val validator: (T?) -> Boolean, val validator: (T?) -> Boolean,
@ -93,7 +79,7 @@ data class Option<T>(
option.key, option.key,
option.description.orEmpty(), option.description.orEmpty(),
option.required, option.required,
option.valueType, option.type,
option.default, option.default,
option.values, option.values,
{ option.validator(option, it) }, { option.validator(option, it) },

View File

@ -20,22 +20,20 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection, selectedPatches: PatchSelection,
options: Options, options: Options,
logger: Logger, logger: Logger,
onPatchCompleted: () -> Unit, onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler, onProgress: ProgressEventHandler,
) { ) {
val bundles = bundles() val bundles = bundles()
val selectedBundles = selectedPatches.keys val selectedBundles = selectedPatches.keys
val allPatches = bundles.filterKeys { selectedBundles.contains(it) } val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
.mapValues { (_, bundle) -> bundle.patchClasses(packageName) } .mapValues { (_, bundle) -> bundle.patches(packageName) }
val patchList = selectedPatches.flatMap { (bundle, selected) -> val patchList = selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { selected.contains(it.name) } allPatches[bundle]?.filter { selected.contains(it.name) }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist") ?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
} }
val integrations = bundles.mapNotNull { (_, bundle) -> bundle.integrations }
// Set all patch options. // Set all patch options.
options.forEach { (bundle, bundlePatchOptions) -> options.forEach { (bundle, bundlePatchOptions) ->
val patches = allPatches[bundle] ?: return@forEach val patches = allPatches[bundle] ?: return@forEach
@ -53,7 +51,6 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
cacheDir, cacheDir,
frameworkPath, frameworkPath,
aaptPath, aaptPath,
enableMultithreadedDexWriter(),
context, context,
logger, logger,
File(inputFile), File(inputFile),
@ -62,8 +59,7 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
).use { session -> ).use { session ->
session.run( session.run(
File(outputFile), File(outputFile),
patchList, patchList
integrations
) )
} }
} }

View File

@ -66,11 +66,11 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
selectedPatches: PatchSelection, selectedPatches: PatchSelection,
options: Options, options: Options,
logger: Logger, logger: Logger,
onPatchCompleted: () -> Unit, onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler, onProgress: ProgressEventHandler,
) = coroutineScope { ) = coroutineScope {
// Get the location of our own Apk. // Get the location of our own Apk.
val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo.sourceDir val managerBaseApk = pm.getPackageInfo(context.packageName)!!.applicationInfo!!.sourceDir
val limit = "${prefs.patcherProcessMemoryLimit.get()}M" val limit = "${prefs.patcherProcessMemoryLimit.get()}M"
val propOverride = resolvePropOverride(context)?.absolutePath val propOverride = resolvePropOverride(context)?.absolutePath
@ -123,7 +123,9 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
val eventHandler = object : IPatcherEvents.Stub() { val eventHandler = object : IPatcherEvents.Stub() {
override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg) override fun log(level: String, msg: String) = logger.log(enumValueOf(level), msg)
override fun patchSucceeded() = onPatchCompleted() override fun patchSucceeded() {
launch { onPatchCompleted() }
}
override fun progress(name: String?, state: String?, msg: String?) = override fun progress(name: String?, state: String?, msg: String?) =
onProgress(name, state?.let { enumValueOf<State>(it) }, msg) onProgress(name, state?.let { enumValueOf<State>(it) }, msg)
@ -148,13 +150,11 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
packageName = packageName, packageName = packageName,
inputFile = inputFile, inputFile = inputFile,
outputFile = outputFile, outputFile = outputFile,
enableMultithrededDexWriter = enableMultithreadedDexWriter(),
configurations = selectedPatches.map { (id, patches) -> configurations = selectedPatches.map { (id, patches) ->
val bundle = bundles[id]!! val bundle = bundles[id]!!
PatchConfiguration( PatchConfiguration(
bundle.patchesJar.absolutePath, bundle.patchesJar.absolutePath,
bundle.integrations?.absolutePath,
patches, patches,
options[id].orEmpty() options[id].orEmpty()
) )

View File

@ -26,7 +26,6 @@ sealed class Runtime(context: Context) : KoinComponent {
context.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath context.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
protected suspend fun bundles() = patchBundlesRepo.bundles.first() protected suspend fun bundles() = patchBundlesRepo.bundles.first()
protected suspend fun enableMultithreadedDexWriter() = prefs.multithreadingDexFileWriter.get()
abstract suspend fun execute( abstract suspend fun execute(
inputFile: String, inputFile: String,
@ -35,7 +34,7 @@ sealed class Runtime(context: Context) : KoinComponent {
selectedPatches: PatchSelection, selectedPatches: PatchSelection,
options: Options, options: Options,
logger: Logger, logger: Logger,
onPatchCompleted: () -> Unit, onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler, onProgress: ProgressEventHandler,
) )
} }

View File

@ -12,14 +12,12 @@ data class Parameters(
val packageName: String, val packageName: String,
val inputFile: String, val inputFile: String,
val outputFile: String, val outputFile: String,
val enableMultithrededDexWriter: Boolean,
val configurations: List<PatchConfiguration>, val configurations: List<PatchConfiguration>,
) : Parcelable ) : Parcelable
@Parcelize @Parcelize
data class PatchConfiguration( data class PatchConfiguration(
val bundlePath: String, val bundlePath: String,
val integrationsPath: String?,
val patches: Set<String>, val patches: Set<String>,
val options: @RawValue Map<String, Map<String, Any?>> val options: @RawValue Map<String, Map<String, Any?>>
) : Parcelable ) : Parcelable

View File

@ -54,13 +54,11 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB") 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 patchList = parameters.configurations.flatMap { config ->
val bundle = PatchBundle(File(config.bundlePath), null) val bundle = PatchBundle(File(config.bundlePath))
val patches = val patches =
bundle.patchClasses(parameters.packageName).filter { it.name in config.patches } bundle.patches(parameters.packageName).filter { it.name in config.patches }
.associateBy { it.name } .associateBy { it.name }
config.options.forEach { (patchName, opts) -> config.options.forEach { (patchName, opts) ->
@ -81,7 +79,6 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
cacheDir = parameters.cacheDir, cacheDir = parameters.cacheDir,
aaptPath = parameters.aaptPath, aaptPath = parameters.aaptPath,
frameworkDir = parameters.frameworkDir, frameworkDir = parameters.frameworkDir,
multithreadingDexFileWriter = parameters.enableMultithrededDexWriter,
androidContext = context, androidContext = context,
logger = logger, logger = logger,
input = File(parameters.inputFile), input = File(parameters.inputFile),
@ -90,7 +87,7 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
events.progress(name, state?.name, message) events.progress(name, state?.name, message)
} }
).use { ).use {
it.run(File(parameters.outputFile), patchList, integrations) it.run(File(parameters.outputFile), patchList)
} }
events.finished(null) events.finished(null)

View File

@ -1,5 +1,6 @@
package app.revanced.manager.patcher.worker package app.revanced.manager.patcher.worker
import android.app.Activity
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@ -9,9 +10,10 @@ import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.os.Build import android.os.Build
import android.os.Parcelable
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import android.view.WindowManager import androidx.activity.result.ActivityResult
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
@ -22,26 +24,33 @@ import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.Worker import app.revanced.manager.domain.worker.Worker
import app.revanced.manager.domain.worker.WorkerRepository import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.runtime.CoroutineRuntime import app.revanced.manager.patcher.runtime.CoroutineRuntime
import app.revanced.manager.patcher.runtime.ProcessRuntime import app.revanced.manager.patcher.runtime.ProcessRuntime
import app.revanced.manager.plugin.downloader.GetScope
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.State import app.revanced.manager.ui.model.State
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import java.io.File import java.io.File
typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit
@OptIn(PluginHostApi::class)
class PatcherWorker( class PatcherWorker(
context: Context, context: Context,
parameters: WorkerParameters parameters: WorkerParameters
@ -49,21 +58,23 @@ class PatcherWorker(
private val workerRepository: WorkerRepository by inject() private val workerRepository: WorkerRepository by inject()
private val prefs: PreferencesManager by inject() private val prefs: PreferencesManager by inject()
private val keystoreManager: KeystoreManager by inject() private val keystoreManager: KeystoreManager by inject()
private val downloaderPluginRepository: DownloaderPluginRepository by inject()
private val downloadedAppRepository: DownloadedAppRepository by inject() private val downloadedAppRepository: DownloadedAppRepository by inject()
private val pm: PM by inject() private val pm: PM by inject()
private val fs: Filesystem by inject() private val fs: Filesystem by inject()
private val installedAppRepository: InstalledAppRepository by inject() private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject() private val rootInstaller: RootInstaller by inject()
data class Args( class Args(
val input: SelectedApp, val input: SelectedApp,
val output: String, val output: String,
val selectedPatches: PatchSelection, val selectedPatches: PatchSelection,
val options: Options, val options: Options,
val logger: Logger, val logger: Logger,
val downloadProgress: MutableStateFlow<Pair<Float, Float>?>, val onDownloadProgress: suspend (Pair<Long, Long?>?) -> Unit,
val patchesProgress: MutableStateFlow<Pair<Int, Int>>, val onPatchCompleted: suspend () -> Unit,
val setInputFile: (File) -> Unit, val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
val setInputFile: suspend (File) -> Unit,
val onProgress: ProgressEventHandler val onProgress: ProgressEventHandler
) { ) {
val packageName get() = input.packageName val packageName get() = input.packageName
@ -110,7 +121,7 @@ class PatcherWorker(
val wakeLock: PowerManager.WakeLock = val wakeLock: PowerManager.WakeLock =
(applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager) (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, "$tag::Patcher") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::Patcher")
.apply { .apply {
acquire(10 * 60 * 1000L) acquire(10 * 60 * 1000L)
Log.d(tag, "Acquired wakelock.") Log.d(tag, "Acquired wakelock.")
@ -141,20 +152,61 @@ class PatcherWorker(
} }
} }
val inputFile = when (val selectedApp = args.input) { suspend fun download(plugin: LoadedDownloaderPlugin, data: Parcelable) =
is SelectedApp.Download -> {
downloadedAppRepository.download( downloadedAppRepository.download(
selectedApp.app, plugin,
prefs.preferSplits.get(), data,
onDownload = { args.downloadProgress.emit(it) } args.packageName,
args.input.version,
onDownload = args.onDownloadProgress
).also { ).also {
args.setInputFile(it) args.setInputFile(it)
updateProgress(state = State.COMPLETED) // Download APK updateProgress(state = State.COMPLETED) // Download APK
} }
val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> {
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(selectedApp.data)
download(plugin, data)
}
is SelectedApp.Search -> {
downloaderPluginRepository.loadedPluginsFlow.first()
.firstNotNullOfOrNull { plugin ->
try {
val getScope = object : GetScope {
override val pluginPackageName = plugin.packageName
override val hostPackageName = applicationContext.packageName
override suspend fun requestStartActivity(intent: Intent): Intent? {
val result = args.handleStartActivityRequest(plugin, intent)
return when (result.resultCode) {
Activity.RESULT_OK -> result.data
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
else -> throw UserInteractionException.Activity.NotCompleted(
result.resultCode,
result.data
)
}
}
}
withContext(Dispatchers.IO) {
plugin.get(
getScope,
selectedApp.packageName,
selectedApp.version
)
}?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version }
} catch (e: UserInteractionException.Activity.NotCompleted) {
throw e
} catch (_: UserInteractionException) {
null
}?.let { (data, _) -> download(plugin, data) }
} ?: throw Exception("App is not available.")
} }
is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) } is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) }
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo.sourceDir) is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo!!.sourceDir)
} }
val runtime = if (prefs.useProcessRuntime.get()) { val runtime = if (prefs.useProcessRuntime.get()) {
@ -170,11 +222,7 @@ class PatcherWorker(
args.selectedPatches, args.selectedPatches,
args.options, args.options,
args.logger, args.logger,
onPatchCompleted = { args.onPatchCompleted,
args.patchesProgress.update { (completed, total) ->
completed + 1 to total
}
},
args.onProgress args.onProgress
) )
@ -184,7 +232,10 @@ class PatcherWorker(
Log.i(tag, "Patching succeeded".logFmt()) Log.i(tag, "Patching succeeded".logFmt())
Result.success() Result.success()
} catch (e: ProcessRuntime.RemoteFailureException) { } catch (e: ProcessRuntime.RemoteFailureException) {
Log.e(tag, "An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()) Log.e(
tag,
"An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()
)
updateProgress(state = State.FAILED, message = e.originalStackTrace) updateProgress(state = State.FAILED, message = e.originalStackTrace)
Result.failure() Result.failure()
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -8,7 +8,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Source import androidx.compose.material.icons.outlined.Source
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@ -24,6 +23,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.util.transparentListItemColors
@Composable @Composable
fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) { fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) {
@ -76,6 +77,7 @@ private fun AutoUpdatesItem(
) = ListItem( ) = ListItem(
leadingContent = { Icon(icon, null) }, leadingContent = { Icon(icon, null) },
headlineContent = { Text(stringResource(headline)) }, headlineContent = { Text(stringResource(headline)) },
trailingContent = { Checkbox(checked = checked, onCheckedChange = null) }, trailingContent = { HapticCheckbox(checked = checked, onCheckedChange = null) },
modifier = Modifier.clickable { onCheckedChange(!checked) } modifier = Modifier.clickable { onCheckedChange(!checked) },
colors = transparentListItemColors
) )

View File

@ -12,10 +12,12 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.util.transparentListItemColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AvailableUpdateDialog( fun AvailableUpdateDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
@ -69,10 +71,11 @@ fun AvailableUpdateDialog(
Text(stringResource(R.string.never_show_again)) Text(stringResource(R.string.never_show_again))
}, },
leadingContent = { leadingContent = {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Checkbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it }) HapticCheckbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it })
}
} }
},
colors = transparentListItemColors
) )
} }
}, },

View File

@ -0,0 +1,61 @@
package app.revanced.manager.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandIn
import androidx.compose.animation.shrinkOut
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.SelectableChipColors
import androidx.compose.material3.SelectableChipElevation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
@Composable
fun CheckedFilterChip(
selected: Boolean,
onClick: () -> Unit,
label: @Composable () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
trailingIcon: @Composable (() -> Unit)? = null,
shape: Shape = FilterChipDefaults.shape,
colors: SelectableChipColors = FilterChipDefaults.filterChipColors(),
elevation: SelectableChipElevation? = FilterChipDefaults.filterChipElevation(),
border: BorderStroke? = FilterChipDefaults.filterChipBorder(enabled, selected),
interactionSource: MutableInteractionSource? = null
) {
FilterChip(
selected = selected,
onClick = onClick,
label = label,
modifier = modifier,
enabled = enabled,
leadingIcon = {
AnimatedVisibility(
visible = selected,
enter = expandIn(expandFrom = Alignment.CenterStart),
exit = shrinkOut(shrinkTowards = Alignment.CenterStart)
) {
Icon(
modifier = Modifier.size(FilterChipDefaults.IconSize),
imageVector = Icons.Filled.Done,
contentDescription = null,
)
}
},
trailingIcon = trailingIcon,
shape = shape,
colors = colors,
elevation = elevation,
border = border,
interactionSource = interactionSource
)
}

View File

@ -0,0 +1,79 @@
package app.revanced.manager.ui.component
import android.content.Intent
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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 app.revanced.manager.R
import app.revanced.manager.ui.component.bundle.BundleTopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExceptionViewerDialog(text: String, onDismiss: () -> Unit) {
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(
Icons.AutoMirrored.Filled.ArrowBack,
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

@ -6,7 +6,6 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -21,35 +20,25 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.model.InstallerModel
import com.github.materiiapps.enumutil.FromValue import com.github.materiiapps.enumutil.FromValue
private typealias InstallerStatusDialogButtonHandler = ((model: InstallerModel) -> Unit) private typealias InstallerStatusDialogButtonHandler = ((model: InstallerModel) -> Unit)
private typealias InstallerStatusDialogButton = @Composable (model: InstallerStatusDialogModel) -> Unit private typealias InstallerStatusDialogButton = @Composable (model: InstallerModel, dismiss: () -> Unit) -> Unit
interface InstallerModel {
fun reinstall()
fun install()
}
interface InstallerStatusDialogModel : InstallerModel {
var packageInstallerStatus: Int?
}
@Composable @Composable
fun InstallerStatusDialog(model: InstallerStatusDialogModel) { fun InstallerStatusDialog(installerStatus: Int, model: InstallerModel, onDismiss: () -> Unit) {
val dialogKind = remember { val dialogKind = remember {
DialogKind.fromValue(model.packageInstallerStatus!!) ?: DialogKind.FAILURE DialogKind.fromValue(installerStatus) ?: DialogKind.FAILURE
} }
AlertDialog( AlertDialog(
onDismissRequest = { onDismissRequest = onDismiss,
model.packageInstallerStatus = null
},
confirmButton = { confirmButton = {
dialogKind.confirmButton(model) dialogKind.confirmButton(model, onDismiss)
}, },
dismissButton = { dismissButton = {
dialogKind.dismissButton?.invoke(model) dialogKind.dismissButton?.invoke(model, onDismiss)
}, },
icon = { icon = {
Icon(dialogKind.icon, null) Icon(dialogKind.icon, null)
@ -75,10 +64,10 @@ fun InstallerStatusDialog(model: InstallerStatusDialogModel) {
private fun installerStatusDialogButton( private fun installerStatusDialogButton(
@StringRes buttonStringResId: Int, @StringRes buttonStringResId: Int,
buttonHandler: InstallerStatusDialogButtonHandler = { }, buttonHandler: InstallerStatusDialogButtonHandler = { },
): InstallerStatusDialogButton = { model -> ): InstallerStatusDialogButton = { model, dismiss ->
TextButton( TextButton(
onClick = { onClick = {
model.packageInstallerStatus = null dismiss()
buttonHandler(model) buttonHandler(model)
} }
) { ) {
@ -154,6 +143,7 @@ enum class DialogKind(
model.install() model.install()
}, },
); );
// Needed due to the @FromValue annotation. // Needed due to the @FromValue annotation.
companion object companion object
} }

View File

@ -0,0 +1,60 @@
package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarColors
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
placeholder: (@Composable () -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val colors = SearchBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
dividerColor = MaterialTheme.colorScheme.outline
)
val keyboardController = LocalSoftwareKeyboardController.current
Box(modifier = Modifier.fillMaxWidth()) {
SearchBar(
modifier = Modifier.align(Alignment.Center),
inputField = {
SearchBarDefaults.InputField(
modifier = Modifier.sizeIn(minWidth = 380.dp),
query = query,
onQueryChange = onQueryChange,
onSearch = {
keyboardController?.hide()
},
expanded = expanded,
onExpandedChange = onExpandedChange,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon
)
},
expanded = expanded,
onExpandedChange = onExpandedChange,
colors = colors,
content = content
)
}
}

View File

@ -1,13 +1,15 @@
package app.revanced.manager.ui.component package app.revanced.manager.ui.component
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarColors
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -27,29 +29,38 @@ fun SearchView(
placeholder: (@Composable () -> Unit)? = null, placeholder: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
val colors = SearchBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
dividerColor = MaterialTheme.colorScheme.outline
)
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
SearchBar( SearchBar(
inputField = {
SearchBarDefaults.InputField(
query = query, query = query,
onQueryChange = onQueryChange, onQueryChange = onQueryChange,
onSearch = { onSearch = {
keyboardController?.hide() keyboardController?.hide()
}, },
active = true, expanded = true,
onActiveChange = onActiveChange, onExpandedChange = onActiveChange,
modifier = Modifier
.fillMaxSize()
.focusRequester(focusRequester),
placeholder = placeholder, placeholder = placeholder,
leadingIcon = { leadingIcon = {
IconButton({ onActiveChange(false) }) { IconButton(onClick = { onActiveChange(false) }) {
Icon( Icon(
Icons.AutoMirrored.Filled.ArrowBack, Icons.AutoMirrored.Filled.ArrowBack,
stringResource(R.string.back) stringResource(R.string.back)
) )
} }
}
)
}, },
expanded = true,
onExpandedChange = onActiveChange,
modifier = Modifier.focusRequester(focusRequester),
colors = colors,
content = content content = content
) )

View File

@ -23,6 +23,7 @@ import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.TextInputDialog import app.revanced.manager.ui.component.TextInputDialog
import app.revanced.manager.ui.component.haptics.HapticSwitch
@Composable @Composable
fun BaseBundleDialog( fun BaseBundleDialog(
@ -89,7 +90,7 @@ fun BaseBundleDialog(
headlineText = stringResource(R.string.bundle_auto_update), headlineText = stringResource(R.string.bundle_auto_update),
supportingText = stringResource(R.string.bundle_auto_update_description), supportingText = stringResource(R.string.bundle_auto_update_description),
trailingContent = { trailingContent = {
Switch( HapticSwitch(
checked = autoUpdate, checked = autoUpdate,
onCheckedChange = onAutoUpdateChange onCheckedChange = onAutoUpdateChange
) )

View File

@ -1,21 +1,16 @@
package app.revanced.manager.ui.component.bundle package app.revanced.manager.ui.component.bundle
import android.content.Intent
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
@ -26,7 +21,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull 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.isDefault
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ExceptionViewerDialog
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -104,7 +99,7 @@ fun BundleInformationDialog(
name = bundleName, name = bundleName,
remoteUrl = bundle.asRemoteOrNull?.endpoint, remoteUrl = bundle.asRemoteOrNull?.endpoint,
patchCount = patchCount, patchCount = patchCount,
version = props?.versionInfo?.patches, version = props?.version,
autoUpdate = props?.autoUpdate ?: false, autoUpdate = props?.autoUpdate ?: false,
onAutoUpdateChange = { onAutoUpdateChange = {
composableScope.launch { composableScope.launch {
@ -119,7 +114,7 @@ fun BundleInformationDialog(
var showDialog by rememberSaveable { var showDialog by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
if (showDialog) BundleErrorViewerDialog( if (showDialog) ExceptionViewerDialog(
onDismiss = { showDialog = false }, onDismiss = { showDialog = false },
text = remember(it) { it.stackTraceToString() } text = remember(it) { it.stackTraceToString() }
) )
@ -149,60 +144,3 @@ fun BundleInformationDialog(
} }
} }
} }
@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

@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.Warning import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -27,6 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -45,7 +45,7 @@ fun BundleItem(
val state by bundle.state.collectAsStateWithLifecycle() val state by bundle.state.collectAsStateWithLifecycle()
val version by remember(bundle) { val version by remember(bundle) {
bundle.propsFlow().map { props -> props?.versionInfo?.patches } bundle.propsFlow().map { props -> props?.version }
}.collectAsStateWithLifecycle(null) }.collectAsStateWithLifecycle(null)
val name by bundle.nameState val name by bundle.nameState
@ -71,7 +71,7 @@ fun BundleItem(
), ),
leadingContent = if (selectable) { leadingContent = if (selectable) {
{ {
Checkbox( HapticCheckbox(
checked = isBundleSelected, checked = isBundleSelected,
onCheckedChange = toggleSelection, onCheckedChange = toggleSelection,
) )

View File

@ -10,47 +10,32 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Topic import androidx.compose.material.icons.filled.Topic
import androidx.compose.material3.Checkbox import androidx.compose.material3.*
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.TextHorizontalPadding import app.revanced.manager.ui.component.TextHorizontalPadding
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.ui.model.BundleType import app.revanced.manager.ui.model.BundleType
import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.BIN_MIMETYPE
import app.revanced.manager.util.JAR_MIMETYPE import app.revanced.manager.util.transparentListItemColors
@Composable @Composable
fun ImportPatchBundleDialog( fun ImportPatchBundleDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onRemoteSubmit: (String, Boolean) -> Unit, onRemoteSubmit: (String, Boolean) -> Unit,
onLocalSubmit: (Uri, Uri?) -> Unit onLocalSubmit: (Uri) -> Unit
) { ) {
var currentStep by rememberSaveable { mutableIntStateOf(0) } var currentStep by rememberSaveable { mutableIntStateOf(0) }
var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) } var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) }
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) } var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
var integrations by rememberSaveable { mutableStateOf<Uri?>(null) }
var remoteUrl by rememberSaveable { mutableStateOf("") } var remoteUrl by rememberSaveable { mutableStateOf("") }
var autoUpdate by rememberSaveable { mutableStateOf(false) } var autoUpdate by rememberSaveable { mutableStateOf(false) }
@ -60,16 +45,7 @@ fun ImportPatchBundleDialog(
} }
fun launchPatchActivity() { fun launchPatchActivity() {
patchActivityLauncher.launch(JAR_MIMETYPE) patchActivityLauncher.launch(BIN_MIMETYPE)
}
val integrationsActivityLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { integrations = it }
}
fun launchIntegrationsActivity() {
integrationsActivityLauncher.launch(APK_MIMETYPE)
} }
val steps = listOf<@Composable () -> Unit>( val steps = listOf<@Composable () -> Unit>(
@ -82,11 +58,9 @@ fun ImportPatchBundleDialog(
ImportBundleStep( ImportBundleStep(
bundleType, bundleType,
patchBundle, patchBundle,
integrations,
remoteUrl, remoteUrl,
autoUpdate, autoUpdate,
{ launchPatchActivity() }, { launchPatchActivity() },
{ launchIntegrationsActivity() },
{ remoteUrl = it }, { remoteUrl = it },
{ autoUpdate = it } { autoUpdate = it }
) )
@ -114,13 +88,7 @@ fun ImportPatchBundleDialog(
enabled = inputsAreValid, enabled = inputsAreValid,
onClick = { onClick = {
when (bundleType) { when (bundleType) {
BundleType.Local -> patchBundle?.let { BundleType.Local -> patchBundle?.let(onLocalSubmit)
onLocalSubmit(
it,
integrations
)
}
BundleType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate) BundleType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate)
} }
} }
@ -170,11 +138,12 @@ fun SelectBundleTypeStep(
overlineContent = { Text(stringResource(R.string.recommended)) }, overlineContent = { Text(stringResource(R.string.recommended)) },
supportingContent = { Text(stringResource(R.string.remote_bundle_description)) }, supportingContent = { Text(stringResource(R.string.remote_bundle_description)) },
leadingContent = { leadingContent = {
RadioButton( HapticRadioButton(
selected = bundleType == BundleType.Remote, selected = bundleType == BundleType.Remote,
onClick = null onClick = null
) )
} },
colors = transparentListItemColors
) )
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
ListItem( ListItem(
@ -186,11 +155,12 @@ fun SelectBundleTypeStep(
supportingContent = { Text(stringResource(R.string.local_bundle_description)) }, supportingContent = { Text(stringResource(R.string.local_bundle_description)) },
overlineContent = { }, overlineContent = { },
leadingContent = { leadingContent = {
RadioButton( HapticRadioButton(
selected = bundleType == BundleType.Local, selected = bundleType == BundleType.Local,
onClick = null onClick = null
) )
} },
colors = transparentListItemColors
) )
} }
} }
@ -201,11 +171,9 @@ fun SelectBundleTypeStep(
fun ImportBundleStep( fun ImportBundleStep(
bundleType: BundleType, bundleType: BundleType,
patchBundle: Uri?, patchBundle: Uri?,
integrations: Uri?,
remoteUrl: String, remoteUrl: String,
autoUpdate: Boolean, autoUpdate: Boolean,
launchPatchActivity: () -> Unit, launchPatchActivity: () -> Unit,
launchIntegrationsActivity: () -> Unit,
onRemoteUrlChange: (String) -> Unit, onRemoteUrlChange: (String) -> Unit,
onAutoUpdateChange: (Boolean) -> Unit onAutoUpdateChange: (Boolean) -> Unit
) { ) {
@ -225,19 +193,8 @@ fun ImportBundleStep(
Icon(imageVector = Icons.Default.Topic, contentDescription = null) Icon(imageVector = Icons.Default.Topic, contentDescription = null)
} }
}, },
modifier = Modifier.clickable { launchPatchActivity() } modifier = Modifier.clickable { launchPatchActivity() },
) colors = transparentListItemColors
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() }
) )
} }
} }
@ -262,8 +219,8 @@ fun ImportBundleStep(
), ),
headlineContent = { Text(stringResource(R.string.auto_update)) }, headlineContent = { Text(stringResource(R.string.auto_update)) },
leadingContent = { leadingContent = {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Checkbox( HapticCheckbox(
checked = autoUpdate, checked = autoUpdate,
onCheckedChange = { onCheckedChange = {
onAutoUpdateChange(!autoUpdate) onAutoUpdateChange(!autoUpdate)
@ -271,6 +228,7 @@ fun ImportBundleStep(
) )
} }
}, },
colors = transparentListItemColors
) )
} }
} }

View File

@ -0,0 +1,30 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxColors
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import app.revanced.manager.util.withHapticFeedback
@Composable
fun HapticCheckbox(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: CheckboxColors = CheckboxDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange?.withHapticFeedback(HapticFeedbackConstants.CLOCK_TICK),
modifier = modifier,
enabled = enabled,
colors = colors,
interactionSource = interactionSource
)
}

View File

@ -0,0 +1,41 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.FloatingActionButtonElevation
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import app.revanced.manager.util.withHapticFeedback
@Composable
fun HapticExtendedFloatingActionButton (
text: @Composable () -> Unit,
icon: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
expanded: Boolean = true,
shape: Shape = FloatingActionButtonDefaults.extendedFabShape,
containerColor: Color = FloatingActionButtonDefaults.containerColor,
contentColor: Color = contentColorFor(containerColor),
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
ExtendedFloatingActionButton(
text = text,
icon = icon,
onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY),
modifier = modifier,
expanded = expanded,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
elevation = elevation,
interactionSource = interactionSource
)
}

View File

@ -0,0 +1,37 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.FloatingActionButtonElevation
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import app.revanced.manager.util.withHapticFeedback
@Composable
fun HapticFloatingActionButton (
onClick: () -> Unit,
modifier: Modifier = Modifier,
shape: Shape = FloatingActionButtonDefaults.shape,
containerColor: Color = FloatingActionButtonDefaults.containerColor,
contentColor: Color = contentColorFor(containerColor),
elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
content: @Composable () -> Unit,
) {
FloatingActionButton(
onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY),
modifier = modifier,
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
elevation = elevation,
interactionSource = interactionSource,
content = content
)
}

View File

@ -0,0 +1,38 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.RadioButton
import androidx.compose.material3.RadioButtonColors
import androidx.compose.material3.RadioButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
@Composable
fun HapticRadioButton(
selected: Boolean,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colors: RadioButtonColors = RadioButtonDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
val view = LocalView.current
RadioButton(
selected = selected,
onClick = onClick?.let {
{
// Perform haptic feedback
if (!selected) view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
it()
}
},
modifier = modifier,
enabled = enabled,
colors = colors,
interactionSource = interactionSource
)
}

View File

@ -0,0 +1,41 @@
package app.revanced.manager.ui.component.haptics
import android.os.Build
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchColors
import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@Composable
fun HapticSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
thumbContent: (@Composable () -> Unit)? = null,
enabled: Boolean = true,
colors: SwitchColors = SwitchDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
Switch(
checked = checked,
onCheckedChange = { newChecked ->
val useNewConstants = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
when {
newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_ON
newChecked -> HapticFeedbackConstants.VIRTUAL_KEY
!newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_OFF
!newChecked -> HapticFeedbackConstants.CLOCK_TICK
}
onCheckedChange(newChecked)
},
modifier = modifier,
thumbContent = thumbContent,
enabled = enabled,
colors = colors,
interactionSource = interactionSource,
)
}

View File

@ -0,0 +1,36 @@
package app.revanced.manager.ui.component.haptics
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import app.revanced.manager.util.withHapticFeedback
@Composable
fun HapticTab (
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
text: @Composable (() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
selectedContentColor: Color = LocalContentColor.current,
unselectedContentColor: Color = selectedContentColor,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
Tab(
selected = selected,
onClick = onClick.withHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY),
modifier = modifier,
enabled = enabled,
text = text,
icon = icon,
selectedContentColor = selectedContentColor,
unselectedContentColor = unselectedContentColor,
interactionSource = interactionSource
)
}

View File

@ -2,12 +2,7 @@ package app.revanced.manager.ui.component.patcher
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog import androidx.compose.material3.*
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -17,6 +12,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.util.transparentListItemColors
@Composable @Composable
fun InstallPickerDialog( fun InstallPickerDialog(
@ -45,16 +42,17 @@ fun InstallPickerDialog(
title = { Text(stringResource(R.string.select_install_type)) }, title = { Text(stringResource(R.string.select_install_type)) },
text = { text = {
Column { Column {
InstallType.values().forEach { InstallType.entries.forEach {
ListItem( ListItem(
modifier = Modifier.clickable { selectedInstallType = it }, modifier = Modifier.clickable { selectedInstallType = it },
leadingContent = { leadingContent = {
RadioButton( HapticRadioButton(
selected = selectedInstallType == it, selected = selectedInstallType == it,
onClick = null onClick = null
) )
}, },
headlineContent = { Text(stringResource(it.stringResource)) } headlineContent = { Text(stringResource(it.stringResource)) },
colors = transparentListItemColors
) )
} }
} }

View File

@ -36,13 +36,15 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.R
import app.revanced.manager.ui.component.ArrowButton import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.component.LoadingIndicator import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.model.ProgressKey
import app.revanced.manager.ui.model.State import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.Step import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.StepProgressProvider
import java.util.Locale
import kotlin.math.floor import kotlin.math.floor
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt // Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
@ -51,6 +53,7 @@ fun Steps(
category: StepCategory, category: StepCategory,
steps: List<Step>, steps: List<Step>,
stepCount: Pair<Int, Int>? = null, stepCount: Pair<Int, Int>? = null,
stepProgressProvider: StepProgressProvider
) { ) {
var expanded by rememberSaveable { mutableStateOf(true) } var expanded by rememberSaveable { mutableStateOf(true) }
@ -115,13 +118,20 @@ fun Steps(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
steps.forEach { step -> steps.forEach { step ->
val downloadProgress = step.downloadProgress?.collectAsStateWithLifecycle() val (progress, progressText) = when (step.progressKey) {
null -> null
ProgressKey.DOWNLOAD -> stepProgressProvider.downloadProgress?.let { (downloaded, total) ->
if (total != null) downloaded.toFloat() / total.toFloat() to "${downloaded.megaBytes}/${total.megaBytes} MB"
else null to "${downloaded.megaBytes} MB"
}
} ?: (null to null)
SubStep( SubStep(
name = step.name, name = step.name,
state = step.state, state = step.state,
message = step.message, message = step.message,
downloadProgress = downloadProgress?.value progress = progress,
progressText = progressText
) )
} }
} }
@ -134,7 +144,8 @@ fun SubStep(
name: String, name: String,
state: State, state: State,
message: String? = null, message: String? = null,
downloadProgress: Pair<Float, Float>? = null progress: Float? = null,
progressText: String? = null
) { ) {
var messageExpanded by rememberSaveable { mutableStateOf(true) } var messageExpanded by rememberSaveable { mutableStateOf(true) }
@ -155,7 +166,7 @@ fun SubStep(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
StepIcon(state, downloadProgress, size = 20.dp) StepIcon(state, progress, size = 20.dp)
} }
Text( Text(
@ -166,8 +177,8 @@ fun SubStep(
modifier = Modifier.weight(1f, true), modifier = Modifier.weight(1f, true),
) )
if (message != null) { when {
Box( message != null -> Box(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@ -177,15 +188,13 @@ fun SubStep(
onClick = null onClick = null
) )
} }
} else {
downloadProgress?.let { (current, total) -> progressText != null -> Text(
Text( progressText,
"$current/$total MB",
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.labelSmall
) )
} }
} }
}
AnimatedVisibility(visible = messageExpanded && message != null) { AnimatedVisibility(visible = messageExpanded && message != null) {
Text( Text(
@ -199,7 +208,7 @@ fun SubStep(
} }
@Composable @Composable
fun StepIcon(state: State, progress: Pair<Float, Float>? = null, size: Dp) { fun StepIcon(state: State, progress: Float? = null, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1) val strokeWidth = Dp(floor(size.value / 10) + 1)
when (state) { when (state) {
@ -233,8 +242,10 @@ fun StepIcon(state: State, progress: Pair<Float, Float>? = null, size: Dp) {
contentDescription = description contentDescription = description
} }
}, },
progress = { progress?.let { (current, total) -> current / total } }, progress = { progress },
strokeWidth = strokeWidth strokeWidth = strokeWidth
) )
} }
} }
private val Long.megaBytes get() = "%.1f".format(locale = Locale.ROOT, toDouble() / 1_000_000)

View File

@ -8,6 +8,7 @@ import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -20,58 +21,37 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.*
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.Composable
import androidx.compose.runtime.DisallowComposableCalls import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog as ComposeDialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.patcher.patch.Option import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.*
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.FloatInputDialog import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.ui.component.IntInputDialog import app.revanced.manager.ui.component.haptics.HapticSwitch
import app.revanced.manager.ui.component.LongInputDialog
import app.revanced.manager.util.isScrollingUp import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.mutableStateSetOf import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.saver.snapshotStateListSaver import app.revanced.manager.util.saver.snapshotStateListSaver
import app.revanced.manager.util.saver.snapshotStateSetSaver import app.revanced.manager.util.saver.snapshotStateSetSaver
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import app.revanced.manager.util.transparentListItemColors
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -80,6 +60,8 @@ import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyColumnState import sh.calvin.reorderable.rememberReorderableLazyColumnState
import java.io.Serializable import java.io.Serializable
import kotlin.random.Random import kotlin.random.Random
import kotlin.reflect.typeOf
import androidx.compose.ui.window.Dialog as ComposeDialog
private class OptionEditorScope<T : Any>( private class OptionEditorScope<T : Any>(
private val editor: OptionEditor<T>, private val editor: OptionEditor<T>,
@ -117,17 +99,17 @@ private interface OptionEditor<T : Any> {
fun Dialog(scope: OptionEditorScope<T>) fun Dialog(scope: OptionEditorScope<T>)
} }
private inline fun <reified T : Serializable> OptionEditor<T>.toMapEditorElements() = arrayOf(
typeOf<T>() to this,
typeOf<List<T>>() to ListOptionEditor(this)
)
private val optionEditors = mapOf( private val optionEditors = mapOf(
"Boolean" to BooleanOptionEditor, *BooleanOptionEditor.toMapEditorElements(),
"String" to StringOptionEditor, *StringOptionEditor.toMapEditorElements(),
"Int" to IntOptionEditor, *IntOptionEditor.toMapEditorElements(),
"Long" to LongOptionEditor, *LongOptionEditor.toMapEditorElements(),
"Float" to FloatOptionEditor, *FloatOptionEditor.toMapEditorElements()
"BooleanArray" to ListOptionEditor(BooleanOptionEditor),
"StringArray" to ListOptionEditor(StringOptionEditor),
"IntArray" to ListOptionEditor(IntOptionEditor),
"LongArray" to ListOptionEditor(LongOptionEditor),
"FloatArray" to ListOptionEditor(FloatOptionEditor),
) )
@Composable @Composable
@ -160,13 +142,19 @@ private inline fun <T : Any> WithOptionEditor(
} }
@Composable @Composable
fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) { fun <T : Any> OptionItem(
option: Option<T>,
value: T?,
setValue: (T?) -> Unit,
) {
val editor = remember(option.type, option.presets) { val editor = remember(option.type, option.presets) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val baseOptionEditor = val baseOptionEditor =
optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor<T> optionEditors.getOrDefault(option.type, UnknownTypeEditor) as OptionEditor<T>
if (option.type != "Boolean" && option.presets != null) PresetOptionEditor(baseOptionEditor) if (option.type != typeOf<Boolean>() && option.presets != null) PresetOptionEditor(
baseOptionEditor
)
else baseOptionEditor else baseOptionEditor
} }
@ -174,7 +162,15 @@ fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) {
ListItem( ListItem(
modifier = Modifier.clickable(onClick = ::clickAction), modifier = Modifier.clickable(onClick = ::clickAction),
headlineContent = { Text(option.title) }, headlineContent = { Text(option.title) },
supportingContent = { Text(option.description) }, supportingContent = {
Column {
Text(option.description)
if (option.required && value == null) Text(
stringResource(R.string.option_required),
color = MaterialTheme.colorScheme.error
)
}
},
trailingContent = { ListItemTrailingContent() } trailingContent = { ListItemTrailingContent() }
) )
} }
@ -335,7 +331,7 @@ private object BooleanOptionEditor : OptionEditor<Boolean> {
@Composable @Composable
override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) { override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) {
Switch(checked = scope.current, onCheckedChange = scope.setValue) HapticSwitch(checked = scope.current, onCheckedChange = scope.setValue)
} }
@Composable @Composable
@ -422,11 +418,12 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
headlineContent = { Text(title) }, headlineContent = { Text(title) },
supportingContent = value?.toString()?.let { { Text(it) } }, supportingContent = value?.toString()?.let { { Text(it) } },
leadingContent = { leadingContent = {
RadioButton( HapticRadioButton(
selected = selectedPreset == presetKey, selected = selectedPreset == presetKey,
onClick = { selectedPreset = presetKey } onClick = { selectedPreset = presetKey }
) )
} },
colors = transparentListItemColors
) )
} }
@ -451,7 +448,7 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
option.key, option.key,
option.description, option.description,
option.required, option.required,
option.type.removeSuffix("Array"), option.type.arguments.first().type!!,
null, null,
null null
) { true } ) { true }
@ -568,7 +565,7 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
floatingActionButton = { floatingActionButton = {
if (deleteMode) return@Scaffold if (deleteMode) return@Scaffold
ExtendedFloatingActionButton( HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.add)) }, text = { Text(stringResource(R.string.add)) },
icon = { icon = {
Icon( Icon(

View File

@ -2,13 +2,13 @@ package app.revanced.manager.ui.component.settings
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import app.revanced.manager.domain.manager.base.Preference import app.revanced.manager.domain.manager.base.Preference
import app.revanced.manager.ui.component.haptics.HapticSwitch
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -45,7 +45,7 @@ fun BooleanItem(
headlineContent = stringResource(headline), headlineContent = stringResource(headline),
supportingContent = stringResource(description), supportingContent = stringResource(description),
trailingContent = { trailingContent = {
Switch( HapticSwitch(
checked = value, checked = value,
onCheckedChange = onValueChange, onCheckedChange = onValueChange,
) )

View File

@ -26,7 +26,6 @@ import app.revanced.manager.ui.component.Markdown
fun Changelog( fun Changelog(
markdown: String, markdown: String,
version: String, version: String,
downloadCount: String,
publishDate: String publishDate: String
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
@ -55,10 +54,6 @@ fun Changelog(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
) { ) {
Tag(
Icons.Outlined.FileDownload,
downloadCount
)
Tag( Tag(
Icons.Outlined.CalendarToday, Icons.Outlined.CalendarToday,
publishDate publishDate

View File

@ -0,0 +1,54 @@
package app.revanced.manager.ui.component.settings
import androidx.annotation.StringRes
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.domain.manager.base.Preference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
fun SafeguardBooleanItem(
modifier: Modifier = Modifier,
preference: Preference<Boolean>,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
@StringRes headline: Int,
@StringRes description: Int,
@StringRes confirmationText: Int
) {
val value by preference.getAsState()
var showSafeguardWarning by rememberSaveable {
mutableStateOf(false)
}
if (showSafeguardWarning) {
SafeguardConfirmationDialog(
onDismiss = { showSafeguardWarning = false },
onConfirm = {
coroutineScope.launch { preference.update(!value) }
showSafeguardWarning = false
},
body = stringResource(confirmationText)
)
}
BooleanItem(
modifier = modifier,
value = value,
onValueChange = {
if (it != preference.default) {
showSafeguardWarning = true
} else {
coroutineScope.launch { preference.update(it) }
}
},
headline = headline,
description = description
)
}

View File

@ -0,0 +1,46 @@
package app.revanced.manager.ui.component.settings
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 SafeguardConfirmationDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit,
body: String,
) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.yes))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.no))
}
},
icon = {
Icon(Icons.Outlined.WarningAmber, null)
},
title = {
Text(
text = stringResource(id = R.string.warning),
style = MaterialTheme.typography.headlineSmall.copy(textAlign = TextAlign.Center)
)
},
text = {
Text(body)
}
)
}

View File

@ -22,13 +22,36 @@ fun SettingsListItem(
colors: ListItemColors = ListItemDefaults.colors(), colors: ListItemColors = ListItemDefaults.colors(),
tonalElevation: Dp = ListItemDefaults.Elevation, tonalElevation: Dp = ListItemDefaults.Elevation,
shadowElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation,
) = ListItem( ) = SettingsListItem(
headlineContent = { headlineContent = {
Text( Text(
text = headlineContent, text = headlineContent,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
}, },
modifier = modifier,
overlineContent = overlineContent,
supportingContent = supportingContent,
leadingContent = leadingContent,
trailingContent = trailingContent,
colors = colors,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation
)
@Composable
fun SettingsListItem(
headlineContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
overlineContent: @Composable (() -> Unit)? = null,
supportingContent: String? = null,
leadingContent: @Composable (() -> Unit)? = null,
trailingContent: @Composable (() -> Unit)? = null,
colors: ListItemColors = ListItemDefaults.colors(),
tonalElevation: Dp = ListItemDefaults.Elevation,
shadowElevation: Dp = ListItemDefaults.Elevation,
) = ListItem(
headlineContent = headlineContent,
modifier = modifier.then(Modifier.padding(horizontal = 8.dp)), modifier = modifier.then(Modifier.padding(horizontal = 8.dp)),
overlineContent = overlineContent, overlineContent = overlineContent,
supportingContent = { supportingContent = {

View File

@ -1,34 +0,0 @@
package app.revanced.manager.ui.destination
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.PatchSelection
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
sealed interface Destination : Parcelable {
@Parcelize
data object Dashboard : Destination
@Parcelize
data class InstalledApplicationInfo(val installedApp: InstalledApp) : Destination
@Parcelize
data object AppSelector : Destination
@Parcelize
data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination
@Parcelize
data class VersionSelector(val packageName: String, val patchSelection: PatchSelection? = null) : Destination
@Parcelize
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination
@Parcelize
data class Patcher(val selectedApp: SelectedApp, val selectedPatches: PatchSelection, val options: @RawValue Options) : Destination
}

View File

@ -1,19 +0,0 @@
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.PatchSelection
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
sealed interface SelectedAppInfoDestination : Parcelable {
@Parcelize
data object Main : SelectedAppInfoDestination
@Parcelize
data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchSelection?, val options: @RawValue Options) : SelectedAppInfoDestination
@Parcelize
data object VersionSelector: SelectedAppInfoDestination
}

View File

@ -1,43 +0,0 @@
package app.revanced.manager.ui.destination
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
sealed interface SettingsDestination : Parcelable {
@Parcelize
data object Settings : SettingsDestination
@Parcelize
data object General : SettingsDestination
@Parcelize
data object Advanced : SettingsDestination
@Parcelize
data object Updates : SettingsDestination
@Parcelize
data object Downloads : SettingsDestination
@Parcelize
data object ImportExport : SettingsDestination
@Parcelize
data object About : SettingsDestination
@Parcelize
data class Update(val downloadOnScreenEntry: Boolean = false) : SettingsDestination
@Parcelize
data object Changelogs : SettingsDestination
@Parcelize
data object Contributors: SettingsDestination
@Parcelize
data object Licenses: SettingsDestination
@Parcelize
data object DeveloperOptions: SettingsDestination
}

View File

@ -34,7 +34,10 @@ data class BundleInfo(
} }
companion object Extensions { companion object Extensions {
inline fun Iterable<BundleInfo>.toPatchSelection(allowUnsupported: Boolean, condition: (Int, PatchInfo) -> Boolean): PatchSelection = this.associate { bundle -> inline fun Iterable<BundleInfo>.toPatchSelection(
allowUnsupported: Boolean,
condition: (Int, PatchInfo) -> Boolean
): PatchSelection = this.associate { bundle ->
val patches = val patches =
bundle.patchSequence(allowUnsupported) bundle.patchSequence(allowUnsupported)
.mapNotNullTo(mutableSetOf()) { patch -> .mapNotNullTo(mutableSetOf()) { patch ->
@ -49,7 +52,7 @@ data class BundleInfo(
bundle.uid to patches bundle.uid to patches
} }
fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String) = fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String?) =
sources.flatMapLatestAndCombine( sources.flatMapLatestAndCombine(
combiner = { it.filterNotNull() } combiner = { it.filterNotNull() }
) { source -> ) { source ->
@ -64,7 +67,7 @@ data class BundleInfo(
bundle.patches.filter { it.compatibleWith(packageName) }.forEach { bundle.patches.filter { it.compatibleWith(packageName) }.forEach {
val targetList = when { val targetList = when {
it.compatiblePackages == null -> universal it.compatiblePackages == null -> universal
it.supportsVersion( it.supports(
packageName, packageName,
version version
) -> supported ) -> supported
@ -78,6 +81,28 @@ data class BundleInfo(
BundleInfo(source.getName(), source.uid, supported, unsupported, universal) BundleInfo(source.getName(), source.uid, supported, unsupported, universal)
} }
} }
/**
* Algorithm for determining whether all required options have been set.
*/
inline fun Iterable<BundleInfo>.requiredOptionsSet(
crossinline isSelected: (BundleInfo, PatchInfo) -> Boolean,
crossinline optionsForPatch: (BundleInfo, PatchInfo) -> Map<String, Any?>?
) = all bundle@{ bundle ->
bundle
.all
.filter { isSelected(bundle, it) }
.all patch@{
if (it.options.isNullOrEmpty()) return@patch true
val opts by lazy { optionsForPatch(bundle, it).orEmpty() }
it.options.all option@{ option ->
if (!option.required || option.default != null) return@option true
option.key in opts
}
}
}
} }
} }

View File

@ -0,0 +1,6 @@
package app.revanced.manager.ui.model
interface InstallerModel {
fun reinstall()
fun install()
}

View File

@ -1,8 +1,9 @@
package app.revanced.manager.ui.model package app.revanced.manager.ui.model
import android.os.Parcelable
import androidx.annotation.StringRes import androidx.annotation.StringRes
import app.revanced.manager.R import app.revanced.manager.R
import kotlinx.coroutines.flow.StateFlow import kotlinx.parcelize.Parcelize
enum class StepCategory(@StringRes val displayName: Int) { enum class StepCategory(@StringRes val displayName: Int) {
PREPARING(R.string.patcher_step_group_preparing), PREPARING(R.string.patcher_step_group_preparing),
@ -14,10 +15,19 @@ enum class State {
WAITING, RUNNING, FAILED, COMPLETED WAITING, RUNNING, FAILED, COMPLETED
} }
enum class ProgressKey {
DOWNLOAD
}
interface StepProgressProvider {
val downloadProgress: Pair<Long, Long?>?
}
@Parcelize
data class Step( data class Step(
val name: String, val name: String,
val category: StepCategory, val category: StepCategory,
val state: State = State.WAITING, val state: State = State.WAITING,
val message: String? = null, val message: String? = null,
val downloadProgress: StateFlow<Pair<Float, Float>?>? = null val progressKey: ProgressKey? = null
) ) : Parcelable

View File

@ -1,20 +1,35 @@
package app.revanced.manager.ui.model package app.revanced.manager.ui.model
import android.os.Parcelable import android.os.Parcelable
import app.revanced.manager.network.downloader.AppDownloader import app.revanced.manager.network.downloader.ParceledDownloaderData
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.io.File import java.io.File
sealed class SelectedApp : Parcelable { sealed interface SelectedApp : Parcelable {
abstract val packageName: String val packageName: String
abstract val version: String val version: String?
@Parcelize @Parcelize
data class Download(override val packageName: String, override val version: String, val app: AppDownloader.App) : SelectedApp() data class Download(
override val packageName: String,
override val version: String?,
val data: ParceledDownloaderData
) : SelectedApp
@Parcelize @Parcelize
data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp() data class Search(override val packageName: String, override val version: String?) : SelectedApp
@Parcelize @Parcelize
data class Installed(override val packageName: String, override val version: String) : 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

@ -0,0 +1,96 @@
package app.revanced.manager.ui.model.navigation
import android.os.Parcelable
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
import kotlinx.serialization.Serializable
interface ComplexParameter<T : Parcelable>
@Serializable
object Dashboard
@Serializable
object AppSelector
@Serializable
data class InstalledApplicationInfo(val packageName: String)
@Serializable
data class Update(val downloadOnScreenEntry: Boolean = false)
@Serializable
data object SelectedApplicationInfo : ComplexParameter<SelectedApplicationInfo.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val app: SelectedApp,
val patches: PatchSelection? = null
) : Parcelable
@Serializable
object Main
@Serializable
data object PatchesSelector : ComplexParameter<PatchesSelector.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val app: SelectedApp,
val currentSelection: PatchSelection?,
val options: @RawValue Options,
) : Parcelable
}
@Serializable
data object RequiredOptions : ComplexParameter<PatchesSelector.ViewModelParams>
}
@Serializable
data object Patcher : ComplexParameter<Patcher.ViewModelParams> {
@Parcelize
data class ViewModelParams(
val selectedApp: SelectedApp,
val selectedPatches: PatchSelection,
val options: @RawValue Options
) : Parcelable
}
@Serializable
object Settings {
sealed interface Destination
@Serializable
data object Main : Destination
@Serializable
data object General : Destination
@Serializable
data object Advanced : Destination
@Serializable
data object Updates : Destination
@Serializable
data object Downloads : Destination
@Serializable
data object ImportExport : Destination
@Serializable
data object About : Destination
@Serializable
data object Changelogs : Destination
@Serializable
data object Contributors : Destination
@Serializable
data object Licenses : Destination
@Serializable
data object DeveloperOptions : Destination
}

View File

@ -10,7 +10,6 @@ import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -33,18 +32,20 @@ import app.revanced.manager.ui.component.SearchView
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.transparentListItemColors
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AppSelectorScreen( fun AppSelectorScreen(
onAppClick: (packageName: String) -> Unit, onSelect: (String) -> Unit,
onStorageClick: (SelectedApp.Local) -> Unit, onStorageSelect: (SelectedApp.Local) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: AppSelectorViewModel = koinViewModel() vm: AppSelectorViewModel = koinViewModel()
) { ) {
SideEffect { EventEffect(flow = vm.storageSelectionFlow) {
vm.onStorageClick = onStorageClick onStorageSelect(it)
} }
val pickApkLauncher = val pickApkLauncher =
@ -74,7 +75,7 @@ fun AppSelectorScreen(
) )
} }
if (search) { if (search)
SearchView( SearchView(
query = filterText, query = filterText,
onQueryChange = { filterText = it }, onQueryChange = { filterText = it },
@ -82,15 +83,15 @@ fun AppSelectorScreen(
placeholder = { Text(stringResource(R.string.search_apps)) } placeholder = { Text(stringResource(R.string.search_apps)) }
) { ) {
if (appList.isNotEmpty() && filterText.isNotEmpty()) { if (appList.isNotEmpty() && filterText.isNotEmpty()) {
LazyColumnWithScrollbar( LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize()
) {
items( items(
items = filteredAppList, items = filteredAppList,
key = { it.packageName } key = { it.packageName }
) { app -> ) { app ->
ListItem( ListItem(
modifier = Modifier.clickable { onAppClick(app.packageName) }, modifier = Modifier.clickable {
onSelect(app.packageName)
},
leadingContent = { leadingContent = {
AppIcon( AppIcon(
app.packageInfo, app.packageInfo,
@ -110,9 +111,9 @@ fun AppSelectorScreen(
) )
) )
} }
} },
colors = transparentListItemColors
) )
} }
} }
} else { } else {
@ -124,17 +125,18 @@ fun AppSelectorScreen(
Icon( Icon(
imageVector = Icons.Outlined.Search, imageVector = Icons.Outlined.Search,
contentDescription = stringResource(R.string.search), contentDescription = stringResource(R.string.search),
modifier = Modifier.size(64.dp) modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text( Text(
text = stringResource(R.string.type_anything), text = stringResource(R.string.type_anything),
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
} }
}
Scaffold( Scaffold(
topBar = { topBar = {
@ -183,7 +185,9 @@ fun AppSelectorScreen(
key = { it.packageName } key = { it.packageName }
) { app -> ) { app ->
ListItem( ListItem(
modifier = Modifier.clickable { onAppClick(app.packageName) }, modifier = Modifier.clickable {
onSelect(app.packageName)
},
leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) }, leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
headlineContent = { headlineContent = {
AppLabel( AppLabel(

View File

@ -0,0 +1,54 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.bundle.BundleItem
@Composable
fun BundleListScreen(
onDelete: (PatchBundleSource) -> Unit,
onUpdate: (PatchBundleSource) -> Unit,
sources: List<PatchBundleSource>,
selectedSources: SnapshotStateList<PatchBundleSource>,
bundlesSelectable: Boolean,
) {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
items(
sources,
key = { it.uid }
) { source ->
BundleItem(
bundle = source,
onDelete = {
onDelete(source)
},
onUpdate = {
onUpdate(source)
},
selectable = bundlesSelectable,
onSelect = {
selectedSources.add(source)
},
isBundleSelected = selectedSources.contains(source),
toggleSelection = { bundleIsNotSelected ->
if (bundleIsNotSelected) {
selectedSources.add(source)
} else {
selectedSources.remove(source)
}
}
)
}
}
}

View File

@ -5,7 +5,8 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@ -24,17 +25,19 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.AutoUpdatesDialog import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.component.AvailableUpdateDialog import app.revanced.manager.ui.component.AvailableUpdateDialog
import app.revanced.manager.ui.component.NotificationCard 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.BundleTopBar
import app.revanced.manager.ui.component.haptics.HapticFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog
import app.revanced.manager.ui.viewmodel.DashboardViewModel import app.revanced.manager.ui.viewmodel.DashboardViewModel
import app.revanced.manager.util.RequestInstallAppsContract
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@ -48,17 +51,21 @@ enum class DashboardPage(
} }
@SuppressLint("BatteryLife") @SuppressLint("BatteryLife")
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
vm: DashboardViewModel = koinViewModel(), vm: DashboardViewModel = koinViewModel(),
onAppSelectorClick: () -> Unit, onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onUpdateClick: () -> Unit, onUpdateClick: () -> Unit,
onAppClick: (InstalledApp) -> Unit onDownloaderPluginClick: () -> Unit,
onAppClick: (String) -> Unit
) { ) {
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } } val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } }
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0) val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle(
false
)
val androidContext = LocalContext.current val androidContext = LocalContext.current
val composableScope = rememberCoroutineScope() val composableScope = rememberCoroutineScope()
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
@ -77,9 +84,9 @@ fun DashboardScreen(
if (showAddBundleDialog) { if (showAddBundleDialog) {
ImportPatchBundleDialog( ImportPatchBundleDialog(
onDismiss = { showAddBundleDialog = false }, onDismiss = { showAddBundleDialog = false },
onLocalSubmit = { patches, integrations -> onLocalSubmit = { patches ->
showAddBundleDialog = false showAddBundleDialog = false
vm.createLocalSource(patches, integrations) vm.createLocalSource(patches)
}, },
onRemoteSubmit = { url, autoUpdate -> onRemoteSubmit = { url, autoUpdate ->
showAddBundleDialog = false showAddBundleDialog = false
@ -88,20 +95,35 @@ fun DashboardScreen(
) )
} }
var showDialog by rememberSaveable { mutableStateOf(vm.prefs.showManagerUpdateDialogOnLaunch.getBlocking()) } var showUpdateDialog by rememberSaveable { mutableStateOf(vm.prefs.showManagerUpdateDialogOnLaunch.getBlocking()) }
val availableUpdate by remember { val availableUpdate by remember {
derivedStateOf { vm.updatedManagerVersion.takeIf { showDialog } } derivedStateOf { vm.updatedManagerVersion.takeIf { showUpdateDialog } }
} }
availableUpdate?.let { version -> availableUpdate?.let { version ->
AvailableUpdateDialog( AvailableUpdateDialog(
onDismiss = { showDialog = false }, onDismiss = { showUpdateDialog = false },
setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch, setShowManagerUpdateDialogOnLaunch = vm::setShowManagerUpdateDialogOnLaunch,
onConfirm = onUpdateClick, onConfirm = onUpdateClick,
newVersion = version newVersion = version
) )
} }
var showAndroid11Dialog by rememberSaveable { mutableStateOf(false) }
val installAppsPermissionLauncher =
rememberLauncherForActivityResult(RequestInstallAppsContract) { granted ->
showAndroid11Dialog = false
if (granted) onAppSelectorClick()
}
if (showAndroid11Dialog) Android11Dialog(
onDismissRequest = {
showAndroid11Dialog = false
},
onContinue = {
installAppsPermissionLauncher.launch(androidContext.packageName)
}
)
Scaffold( Scaffold(
topBar = { topBar = {
if (bundlesSelectable) { if (bundlesSelectable) {
@ -168,7 +190,7 @@ fun DashboardScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( HapticFloatingActionButton(
onClick = { onClick = {
vm.cancelSourceSelection() vm.cancelSourceSelection()
@ -181,7 +203,11 @@ fun DashboardScreen(
DashboardPage.BUNDLES.ordinal DashboardPage.BUNDLES.ordinal
) )
} }
return@FloatingActionButton return@HapticFloatingActionButton
}
if (vm.android11BugActive) {
showAndroid11Dialog = true
return@HapticFloatingActionButton
} }
onAppSelectorClick() onAppSelectorClick()
@ -201,7 +227,7 @@ fun DashboardScreen(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) { ) {
DashboardPage.entries.forEachIndexed { index, page -> DashboardPage.entries.forEachIndexed { index, page ->
Tab( HapticTab(
selected = pagerState.currentPage == index, selected = pagerState.currentPage == index,
onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } }, onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(stringResource(page.titleResId)) }, text = { Text(stringResource(page.titleResId)) },
@ -212,6 +238,7 @@ fun DashboardScreen(
} }
} }
val showBatteryOptimizationsWarning by vm.showBatteryOptimizationsWarningFlow.collectAsStateWithLifecycle(false)
Notifications( Notifications(
if (!Aapt.supportsDevice()) { if (!Aapt.supportsDevice()) {
{ {
@ -223,7 +250,7 @@ fun DashboardScreen(
) )
} }
} else null, } else null,
if (vm.showBatteryOptimizationsWarning) { if (showBatteryOptimizationsWarning) {
{ {
NotificationCard( NotificationCard(
isWarning = true, isWarning = true,
@ -236,6 +263,20 @@ fun DashboardScreen(
} }
) )
} }
} else null,
if (showNewDownloaderPluginsNotification) {
{
NotificationCard(
text = stringResource(R.string.new_downloader_plugins_notification),
icon = Icons.Outlined.Download,
modifier = Modifier.clickable(onClick = onDownloaderPluginClick),
actions = {
TextButton(onClick = vm::ignoreNewDownloaderPlugins) {
Text(stringResource(R.string.dismiss))
}
}
)
}
} else null } else null
) )
@ -247,7 +288,7 @@ fun DashboardScreen(
when (DashboardPage.entries[index]) { when (DashboardPage.entries[index]) {
DashboardPage.DASHBOARD -> { DashboardPage.DASHBOARD -> {
InstalledAppsScreen( InstalledAppsScreen(
onAppClick = onAppClick onAppClick = { onAppClick(it.currentPackageName) }
) )
} }
@ -262,36 +303,20 @@ fun DashboardScreen(
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
Column( BundleListScreen(
modifier = Modifier.fillMaxSize(),
) {
sources.forEach {
BundleItem(
bundle = it,
onDelete = { onDelete = {
vm.delete(it) vm.delete(it)
}, },
onUpdate = { onUpdate = {
vm.update(it) vm.update(it)
}, },
selectable = bundlesSelectable, sources = sources,
onSelect = { selectedSources = vm.selectedSources,
vm.selectedSources.add(it) bundlesSelectable = bundlesSelectable
},
isBundleSelected = vm.selectedSources.contains(it),
toggleSelection = { bundleIsNotSelected ->
if (bundleIsNotSelected) {
vm.selectedSources.add(it)
} else {
vm.selectedSources.remove(it)
}
}
) )
} }
} }
} }
}
}
) )
} }
} }
@ -314,3 +339,24 @@ fun Notifications(
} }
} }
} }
@Composable
fun Android11Dialog(onDismissRequest: () -> Unit, onContinue: () -> Unit) {
AlertDialogExtended(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onContinue) {
Text(stringResource(R.string.continue_))
}
},
title = {
Text(stringResource(R.string.android_11_bug_dialog_title))
},
icon = {
Icon(Icons.Outlined.BugReport, null)
},
text = {
Text(stringResource(R.string.android_11_bug_dialog_description))
}
)
}

View File

@ -41,13 +41,12 @@ import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.SegmentedButton import app.revanced.manager.ui.component.SegmentedButton
import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun InstalledAppInfoScreen( fun InstalledAppInfoScreen(
onPatchClick: (packageName: String, patchSelection: PatchSelection) -> Unit, onPatchClick: (packageName: String) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
viewModel: InstalledAppInfoViewModel viewModel: InstalledAppInfoViewModel
) { ) {
@ -78,10 +77,12 @@ fun InstalledAppInfoScreen(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
AppInfo(viewModel.appInfo) { val installedApp = viewModel.installedApp ?: return@ColumnWithScrollbar
Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
if (viewModel.installedApp.installType == InstallType.MOUNT) { AppInfo(viewModel.appInfo) {
Text(installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
if (installedApp.installType == InstallType.MOUNT) {
Text( Text(
text = if (viewModel.isMounted) { text = if (viewModel.isMounted) {
stringResource(R.string.mounted) stringResource(R.string.mounted)
@ -105,7 +106,7 @@ fun InstalledAppInfoScreen(
onClick = viewModel::launch onClick = viewModel::launch
) )
when (viewModel.installedApp.installType) { when (installedApp.installType) {
InstallType.DEFAULT -> SegmentedButton( InstallType.DEFAULT -> SegmentedButton(
icon = Icons.Outlined.Delete, icon = Icons.Outlined.Delete,
text = stringResource(R.string.uninstall), text = stringResource(R.string.uninstall),
@ -134,11 +135,9 @@ fun InstalledAppInfoScreen(
icon = Icons.Outlined.Update, icon = Icons.Outlined.Update,
text = stringResource(R.string.repatch), text = stringResource(R.string.repatch),
onClick = { onClick = {
viewModel.appliedPatches?.let { onPatchClick(installedApp.originalPackageName)
onPatchClick(viewModel.installedApp.originalPackageName, it)
}
}, },
enabled = viewModel.installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess() enabled = installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess()
) )
} }
@ -161,19 +160,19 @@ fun InstalledAppInfoScreen(
SettingsListItem( SettingsListItem(
headlineContent = stringResource(R.string.package_name), headlineContent = stringResource(R.string.package_name),
supportingContent = viewModel.installedApp.currentPackageName supportingContent = installedApp.currentPackageName
) )
if (viewModel.installedApp.originalPackageName != viewModel.installedApp.currentPackageName) { if (installedApp.originalPackageName != installedApp.currentPackageName) {
SettingsListItem( SettingsListItem(
headlineContent = stringResource(R.string.original_package_name), headlineContent = stringResource(R.string.original_package_name),
supportingContent = viewModel.installedApp.originalPackageName supportingContent = installedApp.originalPackageName
) )
} }
SettingsListItem( SettingsListItem(
headlineContent = stringResource(R.string.install_type), headlineContent = stringResource(R.string.install_type),
supportingContent = stringResource(viewModel.installedApp.installType.stringResource) supportingContent = stringResource(installedApp.installType.stringResource)
) )
} }
} }

View File

@ -1,15 +1,13 @@
package app.revanced.manager.ui.screen package app.revanced.manager.ui.screen
import android.app.Activity
import android.view.WindowManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -17,14 +15,9 @@ import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.PostAdd import androidx.compose.material.icons.outlined.PostAdd
import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.*
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.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
@ -36,18 +29,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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.R
import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.AppScaffold import app.revanced.manager.ui.component.AppScaffold
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.InstallerStatusDialog import app.revanced.manager.ui.component.InstallerStatusDialog
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.patcher.InstallPickerDialog import app.revanced.manager.ui.component.patcher.InstallPickerDialog
import app.revanced.manager.ui.component.patcher.Steps 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.model.StepCategory
import app.revanced.manager.ui.viewmodel.PatcherViewModel import app.revanced.manager.ui.viewmodel.PatcherViewModel
import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -55,7 +48,11 @@ fun PatcherScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: PatcherViewModel vm: PatcherViewModel
) { ) {
BackHandler(onBack = onBackClick) fun leaveScreen() {
vm.onBack()
onBackClick()
}
BackHandler(onBack = ::leaveScreen)
val context = LocalContext.current val context = LocalContext.current
val exportApkLauncher = val exportApkLauncher =
@ -71,19 +68,13 @@ fun PatcherScreen(
} }
} }
val patchesProgress by vm.patchesProgress.collectAsStateWithLifecycle() if (patcherSucceeded == null) {
DisposableEffect(Unit) {
val progress by remember { val window = (context as Activity).window
derivedStateOf { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val (patchesCompleted, patchesTotal) = patchesProgress onDispose {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
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()
} }
} }
@ -93,14 +84,47 @@ fun PatcherScreen(
onConfirm = vm::install onConfirm = vm::install
) )
if (vm.installerStatusDialogModel.packageInstallerStatus != null) vm.packageInstallerStatus?.let {
InstallerStatusDialog(vm.installerStatusDialogModel) InstallerStatusDialog(it, vm, vm::dismissPackageInstallerDialog)
}
val activityLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = vm::handleActivityResult
)
EventEffect(flow = vm.launchActivityFlow) { intent ->
activityLauncher.launch(intent)
}
vm.activityPromptDialog?.let { title ->
AlertDialog(
onDismissRequest = vm::rejectInteraction,
confirmButton = {
TextButton(
onClick = vm::allowInteraction
) {
Text(stringResource(R.string.continue_))
}
},
dismissButton = {
TextButton(
onClick = vm::rejectInteraction
) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(title) },
text = {
Text(stringResource(R.string.plugin_activity_dialog_body))
}
)
}
AppScaffold( AppScaffold(
topBar = { topBar = {
AppTopBar( AppTopBar(
title = stringResource(R.string.patcher), title = stringResource(R.string.patcher),
onBackClick = onBackClick onBackClick = ::leaveScreen
) )
}, },
bottomBar = { bottomBar = {
@ -121,7 +145,7 @@ fun PatcherScreen(
}, },
floatingActionButton = { floatingActionButton = {
AnimatedVisibility(visible = canInstall) { AnimatedVisibility(visible = canInstall) {
ExtendedFloatingActionButton( HapticExtendedFloatingActionButton(
text = { text = {
Text( Text(
stringResource(if (vm.installedPackageName == null) R.string.install_app else R.string.open_app) stringResource(if (vm.installedPackageName == null) R.string.install_app else R.string.open_app)
@ -156,7 +180,7 @@ fun PatcherScreen(
.fillMaxSize() .fillMaxSize()
) { ) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { progress }, progress = { vm.progress },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@ -172,7 +196,8 @@ fun PatcherScreen(
Steps( Steps(
category = category, category = category,
steps = steps, steps = steps,
stepCount = if (category == StepCategory.PATCHING) patchesProgress else null stepCount = if (category == StepCategory.PATCHING) vm.patchesProgress else null,
stepProgressProvider = vm
) )
} }
} }

View File

@ -1,17 +1,50 @@
package app.revanced.manager.ui.screen package app.revanced.manager.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.EaseInOut
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.* import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
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.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -20,8 +53,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -32,20 +67,24 @@ import app.revanced.manager.R
import app.revanced.manager.patcher.patch.Option import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.CheckedFilterChip
import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.SafeguardDialog import app.revanced.manager.ui.component.SafeguardDialog
import app.revanced.manager.ui.component.SearchView import app.revanced.manager.ui.component.SearchBar
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.patches.OptionItem import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNSUPPORTED
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.isScrollingUp import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.transparentListItemColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun PatchesSelectorScreen( fun PatchesSelectorScreen(
onSave: (PatchSelection?, Options) -> Unit, onSave: (PatchSelection?, Options) -> Unit,
@ -60,20 +99,17 @@ fun PatchesSelectorScreen(
bundles.size bundles.size
} }
val composableScope = rememberCoroutineScope() val composableScope = rememberCoroutineScope()
var search: String? by rememberSaveable { val (query, setQuery) = rememberSaveable {
mutableStateOf(null) mutableStateOf("")
}
val (searchExpanded, setSearchExpanded) = rememberSaveable {
mutableStateOf(false)
} }
var showBottomSheet by rememberSaveable { mutableStateOf(false) } var showBottomSheet by rememberSaveable { mutableStateOf(false) }
val showPatchButton by remember { val showSaveButton by remember {
derivedStateOf { vm.selectionIsValid(bundles) } derivedStateOf { vm.selectionIsValid(bundles) }
} }
val availablePatchCount by remember {
derivedStateOf {
bundles.sumOf { it.patchCount }
}
}
val defaultPatchSelectionCount by vm.defaultSelectionCount val defaultPatchSelectionCount by vm.defaultSelectionCount
.collectAsStateWithLifecycle(initialValue = 0) .collectAsStateWithLifecycle(initialValue = 0)
@ -105,27 +141,22 @@ fun PatchesSelectorScreen(
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Row( FlowRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp) horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
FilterChip( CheckedFilterChip(
selected = vm.filter and SHOW_SUPPORTED != 0, selected = vm.filter and SHOW_UNSUPPORTED == 0,
onClick = { vm.toggleFlag(SHOW_SUPPORTED) }, onClick = { vm.toggleFlag(SHOW_UNSUPPORTED) },
label = { Text(stringResource(R.string.supported)) } label = { Text(stringResource(R.string.supported)) }
) )
FilterChip( CheckedFilterChip(
selected = vm.filter and SHOW_UNIVERSAL != 0, selected = vm.filter and SHOW_UNIVERSAL != 0,
onClick = { vm.toggleFlag(SHOW_UNIVERSAL) }, onClick = { vm.toggleFlag(SHOW_UNIVERSAL) },
label = { Text(stringResource(R.string.universal)) }, label = { Text(stringResource(R.string.universal)) },
) )
FilterChip(
selected = vm.filter and SHOW_UNSUPPORTED != 0,
onClick = { vm.toggleFlag(SHOW_UNSUPPORTED) },
label = { Text(stringResource(R.string.unsupported)) },
)
} }
} }
} }
@ -133,7 +164,7 @@ fun PatchesSelectorScreen(
if (vm.compatibleVersions.isNotEmpty()) if (vm.compatibleVersions.isNotEmpty())
UnsupportedPatchDialog( UnsupportedPatchDialog(
appVersion = vm.appVersion, appVersion = vm.appVersion ?: stringResource(R.string.any_version),
supportedVersions = vm.compatibleVersions, supportedVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs onDismissRequest = vm::dismissDialogs
) )
@ -142,7 +173,7 @@ fun PatchesSelectorScreen(
} }
if (showUnsupportedPatchesDialog) if (showUnsupportedPatchesDialog)
UnsupportedPatchesDialog( UnsupportedPatchesDialog(
appVersion = vm.appVersion, appVersion = vm.appVersion ?: stringResource(R.string.any_version),
onDismissRequest = { showUnsupportedPatchesDialog = false } onDismissRequest = { showUnsupportedPatchesDialog = false }
) )
@ -172,20 +203,21 @@ fun PatchesSelectorScreen(
fun LazyListScope.patchList( fun LazyListScope.patchList(
uid: Int, uid: Int,
patches: List<PatchInfo>, patches: List<PatchInfo>,
filterFlag: Int, visible: Boolean,
supported: Boolean, supported: Boolean,
header: (@Composable () -> Unit)? = null header: (@Composable () -> Unit)? = null
) { ) {
if (patches.isNotEmpty() && (vm.filter and filterFlag) != 0 || vm.filter == 0) { if (patches.isNotEmpty() && visible) {
header?.let { header?.let {
item { item(contentType = 0) {
it() it()
} }
} }
items( items(
items = patches, items = patches,
key = { it.name } key = { it.name },
contentType = { 1 }
) { patch -> ) { patch ->
PatchItem( PatchItem(
patch = patch, patch = patch,
@ -219,12 +251,64 @@ fun PatchesSelectorScreen(
} }
} }
search?.let { query -> Scaffold(
SearchView( topBar = {
SearchBar(
query = query, query = query,
onQueryChange = { search = it }, onQueryChange = setQuery,
onActiveChange = { if (!it) search = null }, expanded = searchExpanded,
placeholder = { Text(stringResource(R.string.search_patches)) } onExpandedChange = setSearchExpanded,
placeholder = {
Text(stringResource(R.string.search_patches))
},
leadingIcon = {
val rotation by animateFloatAsState(
targetValue = if (searchExpanded) 360f else 0f,
animationSpec = tween(durationMillis = 400, easing = EaseInOut),
label = "SearchBar back button"
)
IconButton(
onClick = {
if (searchExpanded) {
setSearchExpanded(false)
} else {
onBackClick()
}
}
) {
Icon(
modifier = Modifier.rotate(rotation),
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
}
},
trailingIcon = {
AnimatedContent(
targetState = searchExpanded,
label = "Filter/Clear",
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { searchExpanded ->
if (searchExpanded) {
IconButton(
onClick = { setQuery("") },
enabled = query.isNotEmpty()
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.clear)
)
}
} else {
IconButton(onClick = { showBottomSheet = true }) {
Icon(
imageVector = Icons.Outlined.FilterList,
contentDescription = stringResource(R.string.more)
)
}
}
}
}
) { ) {
val bundle = bundles[pagerState.currentPage] val bundle = bundles[pagerState.currentPage]
@ -238,13 +322,13 @@ fun PatchesSelectorScreen(
patchList( patchList(
uid = bundle.uid, uid = bundle.uid,
patches = bundle.supported.searched(), patches = bundle.supported.searched(),
filterFlag = SHOW_SUPPORTED, visible = true,
supported = true supported = true
) )
patchList( patchList(
uid = bundle.uid, uid = bundle.uid,
patches = bundle.universal.searched(), patches = bundle.universal.searched(),
filterFlag = SHOW_UNIVERSAL, visible = vm.filter and SHOW_UNIVERSAL != 0,
supported = true supported = true
) { ) {
ListHeader( ListHeader(
@ -252,12 +336,11 @@ fun PatchesSelectorScreen(
) )
} }
if (!vm.allowIncompatiblePatches) return@LazyColumnWithScrollbar
patchList( patchList(
uid = bundle.uid, uid = bundle.uid,
patches = bundle.unsupported.searched(), patches = bundle.unsupported.searched(),
filterFlag = SHOW_UNSUPPORTED, visible = vm.filter and SHOW_UNSUPPORTED != 0,
supported = true supported = vm.allowIncompatiblePatches
) { ) {
ListHeader( ListHeader(
title = stringResource(R.string.unsupported_patches), title = stringResource(R.string.unsupported_patches),
@ -266,52 +349,44 @@ fun PatchesSelectorScreen(
} }
} }
} }
}
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.patches_selected, selectedPatchCount, availablePatchCount),
onBackClick = onBackClick,
actions = {
IconButton(onClick = vm::reset) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
IconButton(onClick = { showBottomSheet = true }) {
Icon(Icons.Outlined.FilterList, stringResource(R.string.more))
}
IconButton(
onClick = {
search = ""
}
) {
Icon(Icons.Outlined.Search, stringResource(R.string.search))
}
}
)
}, },
floatingActionButton = { floatingActionButton = {
if (!showPatchButton) return@Scaffold if (!showSaveButton) return@Scaffold
ExtendedFloatingActionButton( AnimatedVisibility(
text = { Text(stringResource(R.string.save)) }, visible = !searchExpanded,
enter = slideInHorizontally { it } + fadeIn(),
exit = slideOutHorizontally { it } + fadeOut()
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
SmallFloatingActionButton(
onClick = vm::reset,
containerColor = MaterialTheme.colorScheme.tertiaryContainer
) {
Icon(Icons.Outlined.Restore, stringResource(R.string.reset))
}
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save_with_count, selectedPatchCount)) },
icon = { icon = {
Icon( Icon(
Icons.Outlined.Save, imageVector = Icons.Outlined.Save,
stringResource(R.string.save) contentDescription = stringResource(R.string.save)
) )
}, },
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp ?: true,
?: true,
onClick = { onClick = {
// TODO: only allow this if all required options have been set.
onSave(vm.getCustomSelection(), vm.getOptions()) onSave(vm.getCustomSelection(), vm.getOptions())
} }
) )
} }
}
}
) { paddingValues -> ) { paddingValues ->
Column( Column(
Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
@ -321,7 +396,7 @@ fun PatchesSelectorScreen(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp) containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) { ) {
bundles.forEachIndexed { index, bundle -> bundles.forEachIndexed { index, bundle ->
Tab( HapticTab(
selected = pagerState.currentPage == index, selected = pagerState.currentPage == index,
onClick = { onClick = {
composableScope.launch { composableScope.launch {
@ -353,13 +428,13 @@ fun PatchesSelectorScreen(
patchList( patchList(
uid = bundle.uid, uid = bundle.uid,
patches = bundle.supported, patches = bundle.supported,
filterFlag = SHOW_SUPPORTED, visible = true,
supported = true supported = true
) )
patchList( patchList(
uid = bundle.uid, uid = bundle.uid,
patches = bundle.universal, patches = bundle.universal,
filterFlag = SHOW_UNIVERSAL, visible = vm.filter and SHOW_UNIVERSAL != 0,
supported = true supported = true
) { ) {
ListHeader( ListHeader(
@ -369,7 +444,7 @@ fun PatchesSelectorScreen(
patchList( patchList(
uid = bundle.uid, uid = bundle.uid,
patches = bundle.unsupported, patches = bundle.unsupported,
filterFlag = SHOW_UNSUPPORTED, visible = vm.filter and SHOW_UNSUPPORTED != 0,
supported = vm.allowIncompatiblePatches supported = vm.allowIncompatiblePatches
) { ) {
ListHeader( ListHeader(
@ -438,7 +513,7 @@ private fun PatchItem(
.clickable(onClick = onToggle) .clickable(onClick = onToggle)
.fillMaxSize(), .fillMaxSize(),
leadingContent = { leadingContent = {
Checkbox( HapticCheckbox(
checked = selected, checked = selected,
onCheckedChange = { onToggle() }, onCheckedChange = { onToggle() },
enabled = supported enabled = supported
@ -452,11 +527,12 @@ private fun PatchItem(
Icon(Icons.Outlined.Settings, null) Icon(Icons.Outlined.Settings, null)
} }
} }
} },
colors = transparentListItemColors
) )
@Composable @Composable
private fun ListHeader( fun ListHeader(
title: String, title: String,
onHelpClick: (() -> Unit)? = null onHelpClick: (() -> Unit)? = null
) { ) {
@ -477,7 +553,8 @@ private fun ListHeader(
) )
} }
} }
} },
colors = transparentListItemColors
) )
} }

View File

@ -0,0 +1,158 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.AutoFixHigh
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
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.patcher.patch.Option
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.isScrollingUp
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RequiredOptionsScreen(
onContinue: (PatchSelection?, Options) -> Unit,
onBackClick: () -> Unit,
vm: PatchesSelectorViewModel
) {
val list by vm.requiredOptsPatches.collectAsStateWithLifecycle(emptyList())
val pagerState = rememberPagerState(
initialPage = 0,
initialPageOffsetFraction = 0f
) {
list.size
}
val patchLazyListStates = remember(list) { List(list.size, ::LazyListState) }
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(emptyList())
val showContinueButton by remember {
derivedStateOf {
bundles.requiredOptionsSet(
isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) },
optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) }
)
}
}
val composableScope = rememberCoroutineScope()
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.required_options_screen),
onBackClick = onBackClick
)
},
floatingActionButton = {
if (!showContinueButton) return@Scaffold
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch)) },
icon = {
Icon(
Icons.Default.AutoFixHigh,
stringResource(R.string.patch)
)
},
expanded = patchLazyListStates.getOrNull(pagerState.currentPage)?.isScrollingUp
?: true,
onClick = {
onContinue(vm.getCustomSelection(), vm.getOptions())
}
)
}
) { paddingValues ->
Column(
Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (list.isEmpty()) return@Column
else if (list.size > 1) ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
list.forEachIndexed { index, (bundle, _) ->
HapticTab(
selected = pagerState.currentPage == index,
onClick = {
composableScope.launch {
pagerState.animateScrollToPage(
index
)
}
},
text = { Text(bundle.name) },
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
HorizontalPager(
state = pagerState,
userScrollEnabled = true,
pageContent = { index ->
// Avoid crashing if the lists have not been fully initialized yet.
if (index > list.lastIndex || list.size != patchLazyListStates.size) return@HorizontalPager
val (bundle, patches) = list[index]
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
state = patchLazyListStates[index]
) {
items(patches, key = { it.name }) {
ListHeader(it.name)
val values = vm.getOptions(bundle.uid, it)
it.options?.forEach { option ->
val key = option.key
val value =
if (values == null || key !in values) option.default else values[key]
@Suppress("UNCHECKED_CAST")
OptionItem(
option = option as Option<Any>,
value = value,
setValue = { new ->
vm.setOption(bundle.uid, it, key, new)
}
)
}
}
}
}
)
}
}
}

View File

@ -1,52 +1,53 @@
package app.revanced.manager.ui.screen package app.revanced.manager.ui.screen
import android.content.pm.PackageInfo import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.filled.AutoFixHigh import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.*
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppInfo import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.destination.SelectedAppInfoDestination import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.enabled
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import dev.olshevski.navigation.reimagined.AnimatedNavHost import app.revanced.manager.util.transparentListItemColors
import dev.olshevski.navigation.reimagined.NavBackHandler import kotlinx.coroutines.launch
import dev.olshevski.navigation.reimagined.navigate
import dev.olshevski.navigation.reimagined.pop
import dev.olshevski.navigation.reimagined.rememberNavController
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SelectedAppInfoScreen( fun SelectedAppInfoScreen(
onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit, onPatchSelectorClick: (SelectedApp, PatchSelection?, Options) -> Unit,
onRequiredOptions: (SelectedApp, PatchSelection?, Options) -> Unit,
onPatchClick: () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
vm: SelectedAppInfoViewModel vm: SelectedAppInfoViewModel
) { ) {
@ -54,105 +55,26 @@ fun SelectedAppInfoScreen(
val packageName = vm.selectedApp.packageName val packageName = vm.selectedApp.packageName
val version = vm.selectedApp.version val version = vm.selectedApp.version
val bundles by remember(packageName, version) { val bundles by vm.bundleInfoFlow.collectAsStateWithLifecycle(emptyList())
vm.bundlesRepo.bundleInfoFlow(packageName, version)
}.collectAsStateWithLifecycle(initialValue = emptyList())
val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState() val allowIncompatiblePatches by vm.prefs.disablePatchVersionCompatCheck.getAsState()
val patches by remember { val patches = remember(bundles, allowIncompatiblePatches) {
derivedStateOf {
vm.getPatches(bundles, allowIncompatiblePatches) vm.getPatches(bundles, allowIncompatiblePatches)
} }
} val selectedPatchCount = remember(patches) {
val selectedPatchCount by remember {
derivedStateOf {
patches.values.sumOf { it.size } patches.values.sumOf { it.size }
} }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = vm::handlePluginActivityResult
)
EventEffect(flow = vm.launchActivityFlow) { intent ->
launcher.launch(intent)
} }
val composableScope = rememberCoroutineScope()
val navController = val error by vm.errorFlow.collectAsStateWithLifecycle(null)
rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main)
NavBackHandler(controller = navController)
AnimatedNavHost(controller = navController) { destination ->
when (destination) {
is SelectedAppInfoDestination.Main -> SelectedAppInfoScreen(
onPatchClick = patchClick@{
if (selectedPatchCount == 0) {
context.toast(context.getString(R.string.no_patches_selected))
return@patchClick
}
onPatchClick(
vm.selectedApp,
patches,
vm.getOptionsFiltered(bundles)
)
},
onPatchSelectorClick = {
navController.navigate(
SelectedAppInfoDestination.PatchesSelector(
vm.selectedApp,
vm.getCustomPatches(
bundles,
allowIncompatiblePatches
),
vm.options
)
)
},
onVersionSelectorClick = {
navController.navigate(SelectedAppInfoDestination.VersionSelector)
},
onBackClick = onBackClick,
selectedPatchCount = selectedPatchCount,
packageName = packageName,
version = version,
packageInfo = vm.selectedAppInfo,
)
is SelectedAppInfoDestination.VersionSelector -> VersionSelectorScreen(
onBackClick = navController::pop,
onAppClick = {
vm.selectedApp = it
navController.pop()
},
viewModel = koinViewModel { parametersOf(packageName) }
)
is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
onSave = { patches, options ->
vm.updateConfiguration(patches, options, bundles)
navController.pop()
},
onBackClick = navController::pop,
vm = koinViewModel {
parametersOf(
PatchesSelectorViewModel.Params(
destination.app,
destination.currentSelection,
destination.options,
)
)
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectedAppInfoScreen(
onPatchClick: () -> Unit,
onPatchSelectorClick: () -> Unit,
onVersionSelectorClick: () -> Unit,
onBackClick: () -> Unit,
selectedPatchCount: Int,
packageName: String,
version: String,
packageInfo: PackageInfo?,
) {
Scaffold( Scaffold(
topBar = { topBar = {
AppTopBar( AppTopBar(
@ -161,7 +83,9 @@ private fun SelectedAppInfoScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
ExtendedFloatingActionButton( if (error != null) return@Scaffold
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch)) }, text = { Text(stringResource(R.string.patch)) },
icon = { icon = {
Icon( Icon(
@ -169,18 +93,61 @@ private fun SelectedAppInfoScreen(
stringResource(R.string.patch) stringResource(R.string.patch)
) )
}, },
onClick = onPatchClick onClick = patchClick@{
if (selectedPatchCount == 0) {
context.toast(context.getString(R.string.no_patches_selected))
return@patchClick
}
composableScope.launch {
if (!vm.hasSetRequiredOptions(patches)) {
onRequiredOptions(
vm.selectedApp,
vm.getCustomPatches(bundles, allowIncompatiblePatches),
vm.options
)
return@launch
}
onPatchClick()
}
}
) )
} }
) { paddingValues -> ) { paddingValues ->
val plugins by vm.plugins.collectAsStateWithLifecycle(emptyList())
if (vm.showSourceSelector) {
val requiredVersion by vm.requiredVersion.collectAsStateWithLifecycle(null)
AppSourceSelectorDialog(
plugins = plugins,
installedApp = vm.installedAppData,
searchApp = SelectedApp.Search(
vm.packageName,
vm.desiredVersion
),
activeSearchJob = vm.activePluginAction,
hasRoot = vm.hasRoot,
onDismissRequest = vm::dismissSourceSelector,
onSelectPlugin = vm::searchUsingPlugin,
requiredVersion = requiredVersion,
onSelect = {
vm.selectedApp = it
vm.dismissSourceSelector()
}
)
}
ColumnWithScrollbar( ColumnWithScrollbar(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
AppInfo(packageInfo, placeholderLabel = packageName) { AppInfo(vm.selectedAppInfo, placeholderLabel = packageName) {
Text( Text(
stringResource(R.string.selected_app_meta, version), version ?: stringResource(R.string.selected_app_meta_any_version),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
@ -188,14 +155,45 @@ private fun SelectedAppInfoScreen(
PageItem( PageItem(
R.string.patch_selector_item, R.string.patch_selector_item,
stringResource(R.string.patch_selector_item_description, selectedPatchCount), stringResource(
onPatchSelectorClick R.string.patch_selector_item_description,
selectedPatchCount
),
onClick = {
onPatchSelectorClick(
vm.selectedApp,
vm.getCustomPatches(
bundles,
allowIncompatiblePatches
),
vm.options
)
}
) )
PageItem( PageItem(
R.string.version_selector_item, R.string.apk_source_selector_item,
stringResource(R.string.version_selector_item_description, version), when (val app = vm.selectedApp) {
onVersionSelectorClick is SelectedApp.Search -> stringResource(R.string.apk_source_auto)
is SelectedApp.Installed -> stringResource(R.string.apk_source_installed)
is SelectedApp.Download -> stringResource(
R.string.apk_source_downloader,
plugins.find { it.packageName == app.data.pluginPackageName }?.name
?: app.data.pluginPackageName
) )
is SelectedApp.Local -> stringResource(R.string.apk_source_local)
},
onClick = {
vm.showSourceSelector()
}
)
error?.let {
Text(
stringResource(it.resourceId),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 24.dp)
)
}
} }
} }
} }
@ -225,3 +223,88 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
} }
) )
} }
@Composable
private fun AppSourceSelectorDialog(
plugins: List<LoadedDownloaderPlugin>,
installedApp: Pair<SelectedApp.Installed, InstalledApp?>?,
searchApp: SelectedApp.Search,
activeSearchJob: String?,
hasRoot: Boolean,
requiredVersion: String?,
onDismissRequest: () -> Unit,
onSelectPlugin: (LoadedDownloaderPlugin) -> Unit,
onSelect: (SelectedApp) -> Unit,
) {
val canSelect = activeSearchJob == null
AlertDialogExtended(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(stringResource(R.string.app_source_dialog_title)) },
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
text = {
LazyColumn {
item(key = "auto") {
val hasPlugins = plugins.isNotEmpty()
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && hasPlugins) { onSelect(searchApp) }
.enabled(hasPlugins),
headlineContent = { Text(stringResource(R.string.app_source_dialog_option_auto)) },
supportingContent = {
Text(
if (hasPlugins)
stringResource(R.string.app_source_dialog_option_auto_description)
else
stringResource(R.string.app_source_dialog_option_auto_unavailable)
)
},
colors = transparentListItemColors
)
}
installedApp?.let { (app, meta) ->
item(key = "installed") {
val (usable, text) = when {
// Mounted apps must be unpatched before patching, which cannot be done without root access.
meta?.installType == InstallType.MOUNT && !hasRoot -> false to stringResource(
R.string.app_source_dialog_option_installed_no_root
)
// Patching already patched apps is not allowed because patches expect unpatched apps.
meta?.installType == InstallType.DEFAULT -> false to stringResource(R.string.already_patched)
// Version does not match suggested version.
requiredVersion != null && app.version != requiredVersion -> false to stringResource(
R.string.app_source_dialog_option_installed_version_not_suggested,
app.version
)
else -> true to app.version
}
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && usable) { onSelect(app) }
.enabled(usable),
headlineContent = { Text(stringResource(R.string.installed)) },
supportingContent = { Text(text) },
colors = transparentListItemColors
)
}
}
items(plugins, key = { "plugin_${it.packageName}" }) { plugin ->
ListItem(
modifier = Modifier.clickable(enabled = canSelect) { onSelectPlugin(plugin) },
headlineContent = { Text(plugin.name) },
trailingContent = (@Composable { LoadingIndicator() }).takeIf { activeSearchJob == plugin.packageName },
colors = transparentListItemColors
)
}
}
}
)
}

View File

@ -13,128 +13,49 @@ import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.destination.SettingsDestination import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.screen.settings.*
import app.revanced.manager.ui.screen.settings.update.ChangelogsScreen
import app.revanced.manager.ui.screen.settings.update.UpdateScreen
import app.revanced.manager.ui.screen.settings.update.UpdatesSettingsScreen
import app.revanced.manager.ui.viewmodel.SettingsViewModel
import dev.olshevski.navigation.reimagined.*
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class) private val settingsSections = listOf(
@Composable
fun SettingsScreen(
onBackClick: () -> Unit,
startDestination: SettingsDestination,
viewModel: SettingsViewModel = koinViewModel()
) {
val navController = rememberNavController(startDestination)
val backClick: () -> Unit = {
if (navController.backstack.entries.size == 1)
onBackClick()
else navController.pop()
}
val settingsSections = listOf(
Triple( Triple(
R.string.general, R.string.general,
R.string.general_description, R.string.general_description,
Icons.Outlined.Settings Icons.Outlined.Settings
) to SettingsDestination.General, ) to Settings.General,
Triple( Triple(
R.string.updates, R.string.updates,
R.string.updates_description, R.string.updates_description,
Icons.Outlined.Update Icons.Outlined.Update
) to SettingsDestination.Updates, ) to Settings.Updates,
Triple( Triple(
R.string.downloads, R.string.downloads,
R.string.downloads_description, R.string.downloads_description,
Icons.Outlined.Download Icons.Outlined.Download
) to SettingsDestination.Downloads, ) to Settings.Downloads,
Triple( Triple(
R.string.import_export, R.string.import_export,
R.string.import_export_description, R.string.import_export_description,
Icons.Outlined.SwapVert Icons.Outlined.SwapVert
) to SettingsDestination.ImportExport, ) to Settings.ImportExport,
Triple( Triple(
R.string.advanced, R.string.advanced,
R.string.advanced_description, R.string.advanced_description,
Icons.Outlined.Tune Icons.Outlined.Tune
) to SettingsDestination.Advanced, ) to Settings.Advanced,
Triple( Triple(
R.string.about, R.string.about,
R.string.app_name, R.string.app_name,
Icons.Outlined.Info Icons.Outlined.Info
) to SettingsDestination.About, ) to Settings.About,
)
NavBackHandler(navController)
AnimatedNavHost(
controller = navController
) { destination ->
when (destination) {
is SettingsDestination.General -> GeneralSettingsScreen(
onBackClick = backClick,
viewModel = viewModel
) )
is SettingsDestination.Advanced -> AdvancedSettingsScreen( @OptIn(ExperimentalMaterial3Api::class)
onBackClick = backClick @Composable
) fun SettingsScreen(onBackClick: () -> Unit, navigate: (Settings.Destination) -> Unit) {
is SettingsDestination.Updates -> UpdatesSettingsScreen(
onBackClick = backClick,
onChangelogClick = { navController.navigate(SettingsDestination.Changelogs) },
onUpdateClick = { navController.navigate(SettingsDestination.Update(false)) }
)
is SettingsDestination.Downloads -> DownloadsSettingsScreen(
onBackClick = backClick
)
is SettingsDestination.ImportExport -> ImportExportSettingsScreen(
onBackClick = backClick
)
is SettingsDestination.About -> AboutSettingsScreen(
onBackClick = backClick,
onContributorsClick = { navController.navigate(SettingsDestination.Contributors) },
onDeveloperOptionsClick = { navController.navigate(SettingsDestination.DeveloperOptions) },
onLicensesClick = { navController.navigate(SettingsDestination.Licenses) },
)
is SettingsDestination.Update -> UpdateScreen(
onBackClick = backClick,
vm = koinViewModel {
parametersOf(
destination.downloadOnScreenEntry
)
}
)
is SettingsDestination.Changelogs -> ChangelogsScreen(
onBackClick = backClick,
)
is SettingsDestination.Contributors -> ContributorScreen(
onBackClick = backClick,
)
is SettingsDestination.Licenses -> LicensesScreen(
onBackClick = backClick,
)
is SettingsDestination.DeveloperOptions -> DeveloperOptionsScreen(onBackClick = backClick)
is SettingsDestination.Settings -> {
Scaffold( Scaffold(
topBar = { topBar = {
AppTopBar( AppTopBar(
title = stringResource(R.string.settings), title = stringResource(R.string.settings),
onBackClick = backClick, onBackClick = onBackClick,
) )
} }
) { paddingValues -> ) { paddingValues ->
@ -145,7 +66,7 @@ fun SettingsScreen(
) { ) {
settingsSections.forEach { (titleDescIcon, destination) -> settingsSections.forEach { (titleDescIcon, destination) ->
SettingsListItem( SettingsListItem(
modifier = Modifier.clickable { navController.navigate(destination) }, modifier = Modifier.clickable { navigate(destination) },
headlineContent = stringResource(titleDescIcon.first), headlineContent = stringResource(titleDescIcon.first),
supportingContent = stringResource(titleDescIcon.second), supportingContent = stringResource(titleDescIcon.second),
leadingContent = { Icon(titleDescIcon.third, null) } leadingContent = { Icon(titleDescIcon.third, null) }
@ -154,6 +75,3 @@ fun SettingsScreen(
} }
} }
} }
}
}
}

View File

@ -1,4 +1,4 @@
package app.revanced.manager.ui.screen.settings.update package app.revanced.manager.ui.screen
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
@ -33,12 +33,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.revanced.manager.BuildConfig import app.revanced.manager.BuildConfig
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.settings.Changelog import app.revanced.manager.ui.component.settings.Changelog
import app.revanced.manager.ui.viewmodel.UpdateViewModel import app.revanced.manager.ui.viewmodel.UpdateViewModel
import app.revanced.manager.ui.viewmodel.UpdateViewModel.Changelog
import app.revanced.manager.ui.viewmodel.UpdateViewModel.State import app.revanced.manager.ui.viewmodel.UpdateViewModel.State
import app.revanced.manager.util.formatNumber
import app.revanced.manager.util.relativeTime import app.revanced.manager.util.relativeTime
import com.gigamole.composefadingedges.content.FadingEdgesContentType import com.gigamole.composefadingedges.content.FadingEdgesContentType
import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig import com.gigamole.composefadingedges.content.scrollconfig.FadingEdgesScrollConfig
@ -77,10 +76,10 @@ fun UpdateScreen(
) { ) {
Header( Header(
vm.state, vm.state,
vm.changelog, vm.releaseInfo,
DownloadData(vm.downloadProgress, vm.downloadedSize, vm.totalSize) DownloadData(vm.downloadProgress, vm.downloadedSize, vm.totalSize)
) )
vm.changelog?.let { changelog -> vm.releaseInfo?.let { changelog ->
HorizontalDivider() HorizontalDivider()
Changelog(changelog) Changelog(changelog)
} ?: Spacer(modifier = Modifier.weight(1f)) } ?: Spacer(modifier = Modifier.weight(1f))
@ -118,7 +117,7 @@ private fun MeteredDownloadConfirmationDialog(
} }
@Composable @Composable
private fun Header(state: State, changelog: Changelog?, downloadData: DownloadData) { private fun Header(state: State, releaseInfo: ReVancedAsset?, downloadData: DownloadData) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text( Text(
text = stringResource(state.title), text = stringResource(state.title),
@ -134,11 +133,11 @@ private fun Header(state: State, changelog: Changelog?, downloadData: DownloadDa
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
changelog?.let { changelog -> releaseInfo?.version?.let {
Text( Text(
text = stringResource( text = stringResource(
id = R.string.new_version, R.string.new_version,
changelog.version.replace("v", "") it.replace("v", "")
), ),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
@ -170,7 +169,7 @@ private fun Header(state: State, changelog: Changelog?, downloadData: DownloadDa
} }
@Composable @Composable
private fun ColumnScope.Changelog(changelog: Changelog) { private fun ColumnScope.Changelog(releaseInfo: ReVancedAsset) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
@ -194,10 +193,9 @@ private fun ColumnScope.Changelog(changelog: Changelog) {
) )
) { ) {
Changelog( Changelog(
markdown = changelog.body.replace("`", ""), markdown = releaseInfo.description.replace("`", ""),
version = changelog.version, version = releaseInfo.version,
downloadCount = changelog.downloadCount.formatNumber(), publishDate = releaseInfo.createdAt.relativeTime(LocalContext.current)
publishDate = changelog.publishDate.relativeTime(LocalContext.current)
) )
} }
} }

View File

@ -1,201 +0,0 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.clickable
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.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.pluralStringResource
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.data.room.apps.installed.InstallType
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.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.NonSuggestedVersionDialog
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.VersionSelectorViewModel
import app.revanced.manager.util.isScrollingUp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VersionSelectorScreen(
onBackClick: () -> Unit,
onAppClick: (SelectedApp) -> Unit,
viewModel: VersionSelectorViewModel
) {
val supportedVersions by viewModel.supportedVersions.collectAsStateWithLifecycle(emptyMap())
val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList())
val list by remember {
derivedStateOf {
val apps = (downloadedVersions + viewModel.downloadableVersions)
.distinctBy { it.version }
.sortedWith(
compareByDescending<SelectedApp> {
it is SelectedApp.Local
}.thenByDescending { supportedVersions[it.version] }
.thenByDescending { it.version }
)
viewModel.requiredVersion?.let { requiredVersion ->
apps.filter { it.version == requiredVersion }
} ?: apps
}
}
if (viewModel.showNonSuggestedVersionDialog)
NonSuggestedVersionDialog(
suggestedVersion = viewModel.requiredVersion.orEmpty(),
onDismiss = viewModel::dismissNonSuggestedVersionDialog
)
val lazyListState = rememberLazyListState()
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.select_version),
onBackClick = onBackClick,
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.select_version)) },
icon = {
Icon(
Icons.Default.Check,
stringResource(R.string.select_version)
)
},
expanded = lazyListState.isScrollingUp,
onClick = { viewModel.selectedVersion?.let(onAppClick) }
)
}
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
state = lazyListState
) {
viewModel.installedApp?.let { (packageInfo, installedApp) ->
SelectedApp.Installed(
packageName = viewModel.packageName,
version = packageInfo.versionName
).let {
item {
SelectedAppItem(
selectedApp = it,
selected = viewModel.selectedVersion == it,
onClick = { viewModel.select(it) },
patchCount = supportedVersions[it.version],
enabled =
!(installedApp?.installType == InstallType.MOUNT && !viewModel.rootInstaller.hasRootAccess()),
alreadyPatched = installedApp != null && installedApp.installType != InstallType.MOUNT
)
}
}
}
item {
Row(Modifier.fillMaxWidth()) {
GroupHeader(stringResource(R.string.downloadable_versions))
}
}
items(
items = list,
key = { it.version }
) {
SelectedAppItem(
selectedApp = it,
selected = viewModel.selectedVersion == it,
onClick = { viewModel.select(it) },
patchCount = supportedVersions[it.version]
)
}
if (viewModel.errorMessage != null) {
item {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.error_occurred))
Text(
text = viewModel.errorMessage!!,
modifier = Modifier.padding(horizontal = 15.dp)
)
}
}
} else if (viewModel.isLoading) {
item {
LoadingIndicator()
}
}
}
}
}
@Composable
fun SelectedAppItem(
selectedApp: SelectedApp,
selected: Boolean,
onClick: () -> Unit,
patchCount: Int?,
enabled: Boolean = true,
alreadyPatched: Boolean = false,
) {
ListItem(
leadingContent = { RadioButton(selected, null) },
headlineContent = { Text(selectedApp.version) },
supportingContent = when (selectedApp) {
is SelectedApp.Installed ->
if (alreadyPatched) {
{ Text(stringResource(R.string.already_patched)) }
} else {
{ Text(stringResource(R.string.installed)) }
}
is SelectedApp.Local -> {
{ Text(stringResource(R.string.already_downloaded)) }
}
else -> null
},
trailingContent = patchCount?.let {
{
Text(pluralStringResource(R.plurals.patch_count, it, it))
}
},
modifier = Modifier
.clickable(enabled = !alreadyPatched && enabled, onClick = onClick)
.run {
if (!enabled || alreadyPatched) alpha(0.5f)
else this
}
)
}

View File

@ -37,6 +37,7 @@ import app.revanced.manager.network.dto.ReVancedSocial
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.model.navigation.Settings
import app.revanced.manager.ui.viewmodel.AboutViewModel import app.revanced.manager.ui.viewmodel.AboutViewModel
import app.revanced.manager.ui.viewmodel.AboutViewModel.Companion.getSocialIcon import app.revanced.manager.ui.viewmodel.AboutViewModel.Companion.getSocialIcon
import app.revanced.manager.util.openUrl import app.revanced.manager.util.openUrl
@ -47,9 +48,7 @@ import org.koin.androidx.compose.koinViewModel
@Composable @Composable
fun AboutSettingsScreen( fun AboutSettingsScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onContributorsClick: () -> Unit, navigate: (Settings.Destination) -> Unit,
onLicensesClick: () -> Unit,
onDeveloperOptionsClick: () -> Unit,
viewModel: AboutViewModel = koinViewModel() viewModel: AboutViewModel = koinViewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -114,17 +113,17 @@ fun AboutSettingsScreen(
Triple( Triple(
stringResource(R.string.contributors), stringResource(R.string.contributors),
stringResource(R.string.contributors_description), stringResource(R.string.contributors_description),
third = onContributorsClick third = { navigate(Settings.Contributors) }
), ),
Triple( Triple(
stringResource(R.string.developer_options), stringResource(R.string.developer_options),
stringResource(R.string.developer_options_description), stringResource(R.string.developer_options_description),
third = onDeveloperOptionsClick third = { navigate(Settings.DeveloperOptions) }
), ),
Triple( Triple(
stringResource(R.string.opensource_licenses), stringResource(R.string.opensource_licenses),
stringResource(R.string.opensource_licenses_description), stringResource(R.string.opensource_licenses_description),
third = onLicensesClick third = { navigate(Settings.Licenses) }
) )
) )

View File

@ -4,6 +4,7 @@ import android.app.ActivityManager
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.os.Build import android.os.Build
import android.view.HapticFeedbackConstants
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@ -17,9 +18,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -32,9 +31,11 @@ import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.GroupHeader import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.settings.IntegerItem import app.revanced.manager.ui.component.settings.IntegerItem
import app.revanced.manager.ui.component.settings.SafeguardBooleanItem
import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.AdvancedSettingsViewModel import app.revanced.manager.ui.viewmodel.AdvancedSettingsViewModel
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import app.revanced.manager.util.withHapticFeedback
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@ -52,7 +53,6 @@ fun AdvancedSettingsScreen(
activityManager.largeMemoryClass activityManager.largeMemoryClass
) )
} }
val haptics = LocalHapticFeedback.current
Scaffold( Scaffold(
topBar = { topBar = {
@ -103,37 +103,35 @@ fun AdvancedSettingsScreen(
headline = R.string.process_runtime_memory_limit, headline = R.string.process_runtime_memory_limit,
description = R.string.process_runtime_memory_limit_description, description = R.string.process_runtime_memory_limit_description,
) )
BooleanItem(
preference = vm.prefs.multithreadingDexFileWriter,
coroutineScope = vm.viewModelScope,
headline = R.string.multithreaded_dex_file_writer,
description = R.string.multithreaded_dex_file_writer_description,
)
GroupHeader(stringResource(R.string.safeguards)) GroupHeader(stringResource(R.string.safeguards))
BooleanItem( SafeguardBooleanItem(
preference = vm.prefs.disablePatchVersionCompatCheck, preference = vm.prefs.disablePatchVersionCompatCheck,
coroutineScope = vm.viewModelScope, coroutineScope = vm.viewModelScope,
headline = R.string.patch_compat_check, headline = R.string.patch_compat_check,
description = R.string.patch_compat_check_description description = R.string.patch_compat_check_description,
confirmationText = R.string.patch_compat_check_confirmation
) )
BooleanItem( SafeguardBooleanItem(
preference = vm.prefs.disableUniversalPatchWarning, preference = vm.prefs.disableUniversalPatchWarning,
coroutineScope = vm.viewModelScope, coroutineScope = vm.viewModelScope,
headline = R.string.universal_patches_safeguard, headline = R.string.universal_patches_safeguard,
description = R.string.universal_patches_safeguard_description description = R.string.universal_patches_safeguard_description,
confirmationText = R.string.universal_patches_safeguard_confirmation
) )
BooleanItem( SafeguardBooleanItem(
preference = vm.prefs.suggestedVersionSafeguard, preference = vm.prefs.suggestedVersionSafeguard,
coroutineScope = vm.viewModelScope, coroutineScope = vm.viewModelScope,
headline = R.string.suggested_version_safeguard, headline = R.string.suggested_version_safeguard,
description = R.string.suggested_version_safeguard_description description = R.string.suggested_version_safeguard_description,
confirmationText = R.string.suggested_version_safeguard_confirmation
) )
BooleanItem( SafeguardBooleanItem(
preference = vm.prefs.disableSelectionWarning, preference = vm.prefs.disableSelectionWarning,
coroutineScope = vm.viewModelScope, coroutineScope = vm.viewModelScope,
headline = R.string.patch_selection_safeguard, headline = R.string.patch_selection_safeguard,
description = R.string.patch_selection_safeguard_description description = R.string.patch_selection_safeguard_description,
confirmationText = R.string.patch_selection_safeguard_confirmation
) )
GroupHeader(stringResource(R.string.debugging)) GroupHeader(stringResource(R.string.debugging))
@ -159,13 +157,12 @@ fun AdvancedSettingsScreen(
onClick = { }, onClick = { },
onLongClickLabel = stringResource(R.string.copy_to_clipboard), onLongClickLabel = stringResource(R.string.copy_to_clipboard),
onLongClick = { onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
clipboard.setPrimaryClip( clipboard.setPrimaryClip(
ClipData.newPlainText("Device Information", deviceContent) ClipData.newPlainText("Device Information", deviceContent)
) )
context.toast(context.getString(R.string.toast_copied_to_clipboard)) context.toast(context.getString(R.string.toast_copied_to_clipboard))
} }.withHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
), ),
headlineContent = stringResource(R.string.about_device), headlineContent = stringResource(R.string.about_device),
supportingContent = deviceContent supportingContent = deviceContent

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