Merge branch 'compose-dev' into compose/fix/patcher-screen-process-death

This commit is contained in:
Ax333l 2024-12-20 17:44:30 +01:00
commit 2f1426408a
No known key found for this signature in database
GPG Key ID: D2B4D85271127D23
136 changed files with 3659 additions and 1840 deletions

View File

@ -3,21 +3,22 @@ import kotlin.random.Random
plugins {
alias(libs.plugins.android.application)
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.about.libraries)
id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.9.23"
}
android {
namespace = "app.revanced.manager"
compileSdk = 34
buildToolsVersion = "34.0.0"
compileSdk = 35
buildToolsVersion = "35.0.0"
defaultConfig {
applicationId = "app.revanced.manager"
minSdk = 26
targetSdk = 34
targetSdk = 35
versionCode = 1
versionName = "0.0.1"
vectorDrawables.useSupportLibrary = true
@ -81,9 +82,11 @@ android {
jvmTarget = "17"
}
buildFeatures.compose = true
buildFeatures.aidl = true
buildFeatures.buildConfig=true
buildFeatures {
compose = true
aidl = true
buildConfig = true
}
android {
androidResources {
@ -91,7 +94,6 @@ android {
}
}
composeOptions.kotlinCompilerExtensionVersion = "1.5.10"
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
@ -111,10 +113,10 @@ dependencies {
implementation(libs.runtime.ktx)
implementation(libs.runtime.compose)
implementation(libs.splash.screen)
implementation(libs.compose.activity)
implementation(libs.paging.common.ktx)
implementation(libs.activity.compose)
implementation(libs.work.runtime.ktx)
implementation(libs.preferences.datastore)
implementation(libs.appcompat)
// Compose
implementation(platform(libs.compose.bom))
@ -142,6 +144,7 @@ dependencies {
// KotlinX
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.collection.immutable)
implementation(libs.kotlinx.datetime)
// Room
implementation(libs.room.runtime)
@ -153,6 +156,9 @@ dependencies {
implementation(libs.revanced.patcher)
implementation(libs.revanced.library)
// Downloader plugins
implementation(project(":downloader-plugin"))
// Native processes
implementation(libs.kotlin.process)
@ -194,7 +200,7 @@ dependencies {
// EnumUtil
implementation(libs.enumutil)
ksp(libs.enumutil.ksp)
// Reorderable lists
implementation(libs.reorderable)

View File

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

View File

@ -2,11 +2,11 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "1dd9d5c0201fdf3cfef3ae669fd65e46",
"identityHash": "d0119047505da435972c5247181de675",
"entities": [
{
"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": [
{
"fieldPath": "uid",
@ -20,6 +20,12 @@
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "source",
"columnName": "source",
@ -31,18 +37,6 @@
"columnName": "auto_update",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "versionInfo.patches",
"columnName": "version",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "versionInfo.integrations",
"columnName": "integrations_version",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
@ -150,7 +144,7 @@
},
{
"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": [
{
"fieldPath": "packageName",
@ -169,6 +163,12 @@
"columnName": "directory",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUsed",
"columnName": "last_used",
"affinity": "INTEGER",
"notNull": true
}
],
"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": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '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"
xmlns:tools="http://schemas.android.com/tools">
<permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="ReservedSystemPermission" />
<permission
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.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@ -17,12 +24,6 @@
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
</queries>
<application
android:name=".ManagerApplication"
android:allowBackup="true"
@ -47,6 +48,8 @@
</intent-filter>
</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.UninstallService" />

View File

@ -3,10 +3,12 @@ package app.revanced.manager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import app.revanced.manager.ui.destination.Destination
import app.revanced.manager.ui.destination.SettingsDestination
import app.revanced.manager.ui.screen.AppSelectorScreen
@ -15,11 +17,11 @@ import app.revanced.manager.ui.screen.InstalledAppInfoScreen
import app.revanced.manager.ui.screen.PatcherScreen
import app.revanced.manager.ui.screen.SelectedAppInfoScreen
import app.revanced.manager.ui.screen.SettingsScreen
import app.revanced.manager.ui.screen.VersionSelectorScreen
import app.revanced.manager.ui.theme.ReVancedManagerTheme
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.ui.viewmodel.MainViewModel
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.EventEffect
import dev.olshevski.navigation.reimagined.AnimatedNavHost
import dev.olshevski.navigation.reimagined.NavBackHandler
import dev.olshevski.navigation.reimagined.navigate
@ -35,6 +37,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
enableEdgeToEdge()
installSplashScreen()
val vm: MainViewModel = getAndroidViewModel()
@ -52,6 +56,10 @@ class MainActivity : ComponentActivity() {
rememberNavController<Destination>(startDestination = Destination.Dashboard)
NavBackHandler(navController)
EventEffect(vm.appSelectFlow) { app ->
navController.navigate(Destination.SelectedApplicationInfo(app))
}
AnimatedNavHost(
controller = navController
) { destination ->
@ -59,9 +67,12 @@ class MainActivity : ComponentActivity() {
is Destination.Dashboard -> DashboardScreen(
onSettingsClick = { navController.navigate(Destination.Settings()) },
onAppSelectorClick = { navController.navigate(Destination.AppSelector) },
onUpdateClick = { navController.navigate(
Destination.Settings(SettingsDestination.Update())
) },
onUpdateClick = {
navController.navigate(Destination.Settings(SettingsDestination.Update()))
},
onDownloaderPluginClick = {
navController.navigate(Destination.Settings(SettingsDestination.Downloads))
},
onAppClick = { installedApp ->
navController.navigate(
Destination.InstalledApplicationInfo(
@ -72,14 +83,7 @@ class MainActivity : ComponentActivity() {
)
is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen(
onPatchClick = { packageName, patchSelection ->
navController.navigate(
Destination.VersionSelector(
packageName,
patchSelection
)
)
},
onPatchClick = vm::selectApp,
onBackClick = { navController.pop() },
viewModel = getComposeViewModel { parametersOf(destination.installedApp) }
)
@ -90,35 +94,11 @@ class MainActivity : ComponentActivity() {
)
is Destination.AppSelector -> AppSelectorScreen(
onAppClick = { navController.navigate(Destination.VersionSelector(it)) },
onStorageClick = {
navController.navigate(
Destination.SelectedApplicationInfo(
it
)
)
},
onSelect = vm::selectApp,
onStorageSelect = vm::selectApp,
onBackClick = { navController.pop() }
)
is Destination.VersionSelector -> VersionSelectorScreen(
onBackClick = { navController.pop() },
onAppClick = { selectedApp ->
navController.navigate(
Destination.SelectedApplicationInfo(
selectedApp,
destination.patchSelection,
)
)
},
viewModel = getComposeViewModel {
parametersOf(
destination.packageName,
destination.patchSelection
)
}
)
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
onPatchClick = { app, patches, options ->
navController.navigate(

View File

@ -7,6 +7,7 @@ import android.util.Log
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.di.*
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.util.tag
import kotlinx.coroutines.Dispatchers
@ -28,6 +29,7 @@ class ManagerApplication : Application() {
private val scope = MainScope()
private val prefs: PreferencesManager by inject()
private val patchBundleRepository: PatchBundleRepository by inject()
private val downloaderPluginRepository: DownloaderPluginRepository by inject()
private val fs: Filesystem by inject()
override fun onCreate() {
@ -66,6 +68,9 @@ class ManagerApplication : Application() {
scope.launch {
prefs.preload()
}
scope.launch(Dispatchers.Default) {
downloaderPluginRepository.reload()
}
scope.launch(Dispatchers.Default) {
with(patchBundleRepository) {
reload()

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.OptionDao
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
@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)
abstract class AppDatabase : RoomDatabase() {
abstract fun patchBundleDao(): PatchBundleDao
@ -26,6 +31,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun downloadedAppDao(): DownloadedAppDao
abstract fun installedAppDao(): InstalledAppDao
abstract fun optionDao(): OptionDao
abstract fun trustedDownloaderPluginDao(): TrustedDownloaderPluginDao
companion object {
fun generateUid() = Random.Default.nextInt()

View File

@ -12,4 +12,5 @@ data class DownloadedApp(
@ColumnInfo(name = "package_name") val packageName: String,
@ColumnInfo(name = "version") val version: String,
@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.Insert
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
@ -14,8 +15,11 @@ interface DownloadedAppDao {
@Query("SELECT * FROM downloaded_app WHERE package_name = :packageName AND version = :version")
suspend fun get(packageName: String, version: String): DownloadedApp?
@Insert
suspend fun insert(downloadedApp: DownloadedApp)
@Upsert
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
suspend fun delete(downloadedApps: Collection<DownloadedApp>)

View File

@ -9,7 +9,7 @@ import kotlinx.parcelize.Parcelize
enum class InstallType(val stringResource: Int) {
DEFAULT(R.string.default_install),
ROOT(R.string.root_install)
MOUNT(R.string.mount_install)
}
@Parcelize

View File

@ -8,11 +8,11 @@ interface PatchBundleDao {
@Query("SELECT * FROM patch_bundles")
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?>
@Query("UPDATE patch_bundles SET version = :patches, integrations_version = :integrations WHERE uid = :uid")
suspend fun updateVersion(uid: Int, patches: String?, integrations: String?)
@Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid")
suspend fun updateVersion(uid: Int, patches: String?)
@Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid")
suspend fun setAutoUpdate(uid: Int, value: Boolean)
@ -26,7 +26,7 @@ interface PatchBundleDao {
@Transaction
suspend fun reset() {
purgeCustomBundles()
updateVersion(0, null, null) // Reset the main source
updateVersion(0, null) // Reset the main source
}
@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")
data class PatchBundleEntity(
@PrimaryKey val uid: Int,
@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 = "auto_update") val autoUpdate: Boolean
)
data class BundleProperties(
@Embedded val versionInfo: VersionInfo,
@ColumnInfo(name = "version") val version: String? = null,
@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.long
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.typeOf
@Entity(
tableName = "options",
@ -46,8 +48,8 @@ data class Option(
val errorMessage = "Cannot deserialize value as ${option.type}"
try {
if (option.type.endsWith("Array")) {
val elementType = option.type.removeSuffix("Array")
if (option.type.classifier == List::class) {
val elementType = option.type.arguments.first().type!!
return raw.jsonArray.map { deserializeBasicType(elementType, it.jsonPrimitive) }
}
@ -67,12 +69,17 @@ data class Option(
allowSpecialFloatingPointValues = true
}
private fun deserializeBasicType(type: String, value: JsonPrimitive) = when (type) {
"Boolean" -> value.boolean
"Int" -> value.int
"Long" -> value.long
"Float" -> value.float
"String" -> value.content.also { if (!value.isString) throw SerializationException("Expected value to be a string: $value") }
private fun deserializeBasicType(type: KType, value: JsonPrimitive) = when (type) {
typeOf<Boolean>() -> value.boolean
typeOf<Int>() -> value.int
typeOf<Long>() -> value.long
typeOf<Float>() -> value.float
typeOf<String>() -> value.content.also {
if (!value.isString) throw SerializationException(
"Expected value to be a string: $value"
)
}
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
createdAtStart()
}
singleOf(::DownloaderPluginRepository)
singleOf(::WorkerRepository)
singleOf(::DownloadedAppRepository)
singleOf(::InstalledAppRepository)

View File

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

View File

@ -12,7 +12,6 @@ val viewModelModule = module {
viewModelOf(::SettingsViewModel)
viewModelOf(::AdvancedSettingsViewModel)
viewModelOf(::AppSelectorViewModel)
viewModelOf(::VersionSelectorViewModel)
viewModelOf(::PatcherViewModel)
viewModelOf(::UpdateViewModel)
viewModelOf(::ChangelogsViewModel)

View File

@ -4,29 +4,18 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.InputStream
import java.nio.file.Files
import java.nio.file.StandardCopyOption
class LocalPatchBundle(name: String, id: Int, directory: File) :
PatchBundleSource(name, id, directory) {
suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) {
suspend fun replace(patches: InputStream) {
withContext(Dispatchers.IO) {
patches?.let { inputStream ->
patchBundleOutputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
integrations?.let {
Files.copy(
it,
this@LocalPatchBundle.integrationsFile.toPath(),
StandardCopyOption.REPLACE_EXISTING
)
patchBundleOutputStream().use { outputStream ->
patches.copyTo(outputStream)
}
}
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()
private val app: Application by inject()
protected val patchesFile = directory.resolve("patches.jar")
protected val integrationsFile = directory.resolve("integrations.apk")
private val _state = MutableStateFlow(load())
val state = _state.asStateFlow()
@ -58,7 +57,7 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil
if (!hasInstalled()) return State.Missing
return try {
State.Loaded(PatchBundle(patchesFile, integrationsFile.takeIf(File::exists)))
State.Loaded(PatchBundle(patchesFile))
} catch (t: Throwable) {
Log.e(tag, "Failed to load patch bundle with UID $uid", 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)
suspend fun getProps() = propsFlow().first()!!
suspend fun currentVersion() = getProps().versionInfo
protected suspend fun saveVersion(patches: String?, integrations: String?) =
configRepository.updateVersion(uid, patches, integrations)
suspend fun currentVersion() = getProps().version
protected suspend fun saveVersion(version: String?) =
configRepository.updateVersion(uid, version)
suspend fun setName(name: String) {
configRepository.setName(uid, name)

View File

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

View File

@ -43,7 +43,7 @@ class RootInstaller(
}
}
return withTimeoutOrNull(Duration.ofSeconds(120L)) {
return withTimeoutOrNull(Duration.ofSeconds(20L)) {
remoteFS.await()
} ?: throw RootServiceException()
}
@ -58,6 +58,10 @@ class RootInstaller(
fun hasRootAccess() = Shell.isAppGrantedRoot() ?: false
fun isDeviceRooted() = System.getenv("PATH")?.split(":")?.any { path ->
File(path, "su").canExecute()
} ?: false
suspend fun isAppInstalled(packageName: String) =
awaitRemoteFS().getFile("$modulesPath/$packageName-revanced").exists()
@ -105,7 +109,12 @@ class RootInstaller(
stockAPK?.let { stockApp ->
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")
}

View File

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

View File

@ -12,15 +12,12 @@ class PreferencesManager(
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 patcherProcessMemoryLimit = intPreference("process_runtime_memory_limit", 700)
val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
val preferSplits = booleanPreference("prefer_splits", false)
val firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
@ -29,4 +26,6 @@ class PreferencesManager(
val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false)
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) =
StringPreference(dataStore, key, default)
protected fun stringSetPreference(key: String, default: Set<String>) =
StringSetPreference(dataStore, key, default)
protected fun booleanPreference(key: String, default: Boolean) =
BooleanPreference(dataStore, key, default)
@ -52,6 +55,10 @@ class EditorContext(private val prefs: MutablePreferences) {
var <T> Preference<T>.value
get() = prefs.run { read() }
set(value) = prefs.run { write(value) }
operator fun Preference<Set<String>>.plusAssign(value: String) = prefs.run {
write(read() + value)
}
}
abstract class Preference<T>(
@ -65,10 +72,12 @@ abstract class Preference<T>(
suspend fun get() = flow.first()
fun getBlocking() = runBlocking { get() }
@Composable
fun getAsState() = flow.collectAsStateWithLifecycle(initialValue = remember {
getBlocking()
})
suspend fun update(value: T) = dataStore.editor {
this@Preference.value = value
}
@ -108,6 +117,14 @@ class StringPreference(
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(
dataStore: DataStore<Preferences>,
key: String,

View File

@ -2,56 +2,126 @@ package app.revanced.manager.domain.repository
import android.app.Application
import android.content.Context
import android.os.Parcelable
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
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.flowOn
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(
app: Application,
db: AppDatabase
private val app: Application,
db: AppDatabase,
private val pm: PM
) {
private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE)
private val dao = db.downloadedAppDao()
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()
suspend fun download(
app: AppDownloader.App,
preferSplits: Boolean,
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit = {},
plugin: LoadedDownloaderPlugin,
data: Parcelable,
expectedPackageName: String,
expectedVersion: String?,
onDownload: suspend (downloadProgress: Pair<Long, Long?>) -> Unit,
): 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.
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 {
app.download(savePath, preferSplits, onDownload)
val downloadSize = AtomicLong(0)
val downloadedBytes = AtomicLong(0)
dao.insert(DownloadedApp(
packageName = app.packageName,
version = app.version,
directory = relativePath,
))
channelFlow {
val scope = object : OutputDownloadScope {
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,
)
)
} catch (e: Exception) {
savePath.deleteRecursively()
saveDir.deleteRecursively()
throw e
}
// 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>) {
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.bundles.PatchBundleEntity
import app.revanced.manager.data.room.bundles.Source
import app.revanced.manager.data.room.bundles.VersionInfo
import kotlinx.coroutines.flow.distinctUntilChanged
class PatchBundlePersistenceRepository(db: AppDatabase) {
@ -26,7 +25,7 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
PatchBundleEntity(
uid = generateUid(),
name = name,
versionInfo = VersionInfo(),
version = null,
source = source,
autoUpdate = autoUpdate
).also {
@ -35,8 +34,8 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
suspend fun delete(uid: Int) = dao.remove(uid)
suspend fun updateVersion(uid: Int, patches: String?, integrations: String?) =
dao.updateVersion(uid, patches, integrations)
suspend fun updateVersion(uid: Int, version: String?) =
dao.updateVersion(uid, version)
suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value)
@ -48,7 +47,7 @@ class PatchBundlePersistenceRepository(db: AppDatabase) {
val defaultSource = PatchBundleEntity(
uid = 0,
name = "",
versionInfo = VersionInfo(),
version = null,
source = Source.API,
autoUpdate = false
)

View File

@ -3,7 +3,7 @@ package app.revanced.manager.domain.repository
import android.app.Application
import android.content.Context
import android.util.Log
import app.revanced.library.PatchUtils
import app.revanced.library.mostCommonCompatibleVersions
import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.room.bundles.PatchBundleEntity
@ -55,7 +55,7 @@ class PatchBundleRepository(
val allPatches =
it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()
PatchUtils.getMostCommonCompatibleVersions(allPatches, countUnusedPatches = true)
allPatches.mostCommonCompatibleVersions(countUnusedPatches = true)
.mapValues { (_, versions) ->
if (versions.keys.size < 2)
return@mapValues versions.keys.firstOrNull()
@ -137,11 +137,11 @@ class PatchBundleRepository(
private fun addBundle(patchBundle: PatchBundleSource) =
_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 bundle = LocalPatchBundle("", uid, directoryOf(uid))
bundle.replace(patches, integrations)
bundle.replace(patches)
addBundle(bundle)
}

View File

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

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.Serializable
@Serializable
data class ReVancedGitRepositories(
val repositories: List<ReVancedGitRepository>,
)
@Serializable
data class ReVancedGitRepository(
val name: String,
val url: String,
val contributors: List<ReVancedContributor>,
)
@Serializable
data class ReVancedContributor(
@SerialName("login") val username: String,
@SerialName("name") val username: String,
@SerialName("avatar_url") val avatarUrl: String,
)
)

View File

@ -1,12 +1,8 @@
package app.revanced.manager.network.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ReVancedInfoParent(
val info: ReVancedInfo,
)
@Serializable
data class ReVancedInfo(
val name: String,
@ -43,7 +39,8 @@ data class ReVancedDonation(
@Serializable
data class ReVancedWallet(
val network: String,
val currency_code: String,
@SerialName("currency_code")
val currencyCode: String,
val address: String,
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,7 +22,6 @@ class Session(
cacheDir: String,
frameworkDir: String,
aaptPath: String,
multithreadingDexFileWriter: Boolean,
private val androidContext: Context,
private val logger: Logger,
private val input: File,
@ -38,8 +37,7 @@ class Session(
apkFile = input,
temporaryFilesPath = tempDir,
frameworkFileDirectory = frameworkDir,
aaptBinaryPath = aaptPath,
multithreadingDexFileWriter = multithreadingDexFileWriter,
aaptBinaryPath = aaptPath
)
)
@ -51,7 +49,7 @@ class Session(
state = State.RUNNING
)
this.apply(true).collect { (patch, exception) ->
this().collect { (patch, exception) ->
if (patch !in selectedPatches) return@collect
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
java.util.logging.Logger.getLogger("").apply {
@ -103,8 +101,7 @@ class Session(
with(patcher) {
logger.info("Merging integrations")
acceptIntegrations(integrations.toSet())
acceptPatches(selectedPatches.toSet())
this += selectedPatches.toSet()
logger.info("Applying patches...")
applyPatchesVerbose(selectedPatches.sortedBy { it.name })

View File

@ -2,17 +2,17 @@ package app.revanced.manager.patcher.patch
import android.util.Log
import app.revanced.manager.util.tag
import app.revanced.patcher.PatchBundleLoader
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchLoader
import java.io.File
import java.io.IOException
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 fun load(): Iterable<Patch<*>> {
patchesJar.setReadOnly()
return PatchBundleLoader.Dex(patchesJar, optimizedDexDirectory = null)
return PatchLoader.Dex(setOf(patchesJar))
}
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.
*/
fun patchClasses(packageName: String) = loader.filter { patch ->
fun patches(packageName: String) = loader.filter { patch ->
val compatiblePackages = patch.compatiblePackages
?: // The patch has no compatibility constraints, which means it is universal.
return@filter true
if (!compatiblePackages.any { it.name == packageName }) {
if (!compatiblePackages.any { (name, _) -> name == packageName }) {
// Patch is not compatible with this package.
return@filter false
}

View File

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

View File

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

View File

@ -70,7 +70,7 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
onProgress: ProgressEventHandler,
) = coroutineScope {
// 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 propOverride = resolvePropOverride(context)?.absolutePath
@ -150,13 +150,11 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
packageName = packageName,
inputFile = inputFile,
outputFile = outputFile,
enableMultithrededDexWriter = enableMultithreadedDexWriter(),
configurations = selectedPatches.map { (id, patches) ->
val bundle = bundles[id]!!
PatchConfiguration(
bundle.patchesJar.absolutePath,
bundle.integrations?.absolutePath,
patches,
options[id].orEmpty()
)

View File

@ -26,7 +26,6 @@ sealed class Runtime(context: Context) : KoinComponent {
context.cacheDir.resolve("framework").also { it.mkdirs() }.absolutePath
protected suspend fun bundles() = patchBundlesRepo.bundles.first()
protected suspend fun enableMultithreadedDexWriter() = prefs.multithreadingDexFileWriter.get()
abstract suspend fun execute(
inputFile: String,

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package app.revanced.manager.patcher.worker
import android.app.Activity
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@ -9,9 +10,11 @@ import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Parcelable
import android.os.PowerManager
import android.util.Log
import android.view.WindowManager
import androidx.activity.result.ActivityResult
import androidx.core.content.ContextCompat
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
@ -22,26 +25,33 @@ import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.worker.Worker
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.runtime.CoroutineRuntime
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.State
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.tag
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit
@OptIn(PluginHostApi::class)
class PatcherWorker(
context: Context,
parameters: WorkerParameters
@ -49,20 +59,22 @@ class PatcherWorker(
private val workerRepository: WorkerRepository by inject()
private val prefs: PreferencesManager by inject()
private val keystoreManager: KeystoreManager by inject()
private val downloaderPluginRepository: DownloaderPluginRepository by inject()
private val downloadedAppRepository: DownloadedAppRepository by inject()
private val pm: PM by inject()
private val fs: Filesystem by inject()
private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject()
data class Args(
class Args(
val input: SelectedApp,
val output: String,
val selectedPatches: PatchSelection,
val options: Options,
val logger: Logger,
val onDownloadProgress: suspend (Pair<Float, Float>?) -> Unit,
val onDownloadProgress: suspend (Pair<Long, Long?>?) -> Unit,
val onPatchCompleted: suspend () -> Unit,
val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
val setInputFile: suspend (File) -> Unit,
val onProgress: ProgressEventHandler
) {
@ -135,26 +147,67 @@ class PatcherWorker(
return try {
if (args.input is SelectedApp.Installed) {
installedAppRepository.get(args.packageName)?.let {
if (it.installType == InstallType.ROOT) {
if (it.installType == InstallType.MOUNT) {
rootInstaller.unmount(args.packageName)
}
}
}
suspend fun download(plugin: LoadedDownloaderPlugin, data: Parcelable) =
downloadedAppRepository.download(
plugin,
data,
args.packageName,
args.input.version,
onDownload = args.onDownloadProgress
).also {
args.setInputFile(it)
updateProgress(state = State.COMPLETED) // Download APK
}
val inputFile = when (val selectedApp = args.input) {
is SelectedApp.Download -> {
downloadedAppRepository.download(
selectedApp.app,
prefs.preferSplits.get(),
onDownload = args.onDownloadProgress
).also {
args.setInputFile(it)
updateProgress(state = State.COMPLETED) // Download APK
}
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.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo.sourceDir)
is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo!!.sourceDir)
}
val runtime = if (prefs.useProcessRuntime.get()) {
@ -186,7 +239,10 @@ class PatcherWorker(
Log.i(tag, "Patching succeeded".logFmt())
Result.success()
} 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)
Result.failure()
} 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.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
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.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.util.transparentListItemColors
@Composable
fun AutoUpdatesDialog(onSubmit: (Boolean, Boolean) -> Unit) {
@ -76,6 +77,7 @@ private fun AutoUpdatesItem(
) = ListItem(
leadingContent = { Icon(icon, null) },
headlineContent = { Text(stringResource(headline)) },
trailingContent = { Checkbox(checked = checked, onCheckedChange = null) },
modifier = Modifier.clickable { onCheckedChange(!checked) }
)
trailingContent = { HapticCheckbox(checked = checked, onCheckedChange = null) },
modifier = Modifier.clickable { onCheckedChange(!checked) },
colors = transparentListItemColors
)

View File

@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.haptics.HapticCheckbox
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -70,7 +71,7 @@ fun AvailableUpdateDialog(
},
leadingContent = {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
Checkbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it })
HapticCheckbox(checked = dontShowAgain, onCheckedChange = { dontShowAgain = it })
}
}
)

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

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

View File

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

View File

@ -1,21 +1,16 @@
package app.revanced.manager.ui.component.bundle
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@ -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.isDefault
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
@OptIn(ExperimentalMaterial3Api::class)
@ -104,7 +99,7 @@ fun BundleInformationDialog(
name = bundleName,
remoteUrl = bundle.asRemoteOrNull?.endpoint,
patchCount = patchCount,
version = props?.versionInfo?.patches,
version = props?.version,
autoUpdate = props?.autoUpdate ?: false,
onAutoUpdateChange = {
composableScope.launch {
@ -119,7 +114,7 @@ fun BundleInformationDialog(
var showDialog by rememberSaveable {
mutableStateOf(false)
}
if (showDialog) BundleErrorViewerDialog(
if (showDialog) ExceptionViewerDialog(
onDismiss = { showDialog = false },
text = remember(it) { it.stackTraceToString() }
)
@ -148,61 +143,4 @@ 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.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
@ -27,6 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import kotlinx.coroutines.flow.map
@ -45,7 +45,7 @@ fun BundleItem(
val state by bundle.state.collectAsStateWithLifecycle()
val version by remember(bundle) {
bundle.propsFlow().map { props -> props?.versionInfo?.patches }
bundle.propsFlow().map { props -> props?.version }
}.collectAsStateWithLifecycle(null)
val name by bundle.nameState
@ -71,7 +71,7 @@ fun BundleItem(
),
leadingContent = if (selectable) {
{
Checkbox(
HapticCheckbox(
checked = isBundleSelected,
onCheckedChange = toggleSelection,
)

View File

@ -10,26 +10,9 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Topic
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.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.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
@ -37,20 +20,21 @@ import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.AlertDialogExtended
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.util.APK_MIMETYPE
import app.revanced.manager.util.JAR_MIMETYPE
import app.revanced.manager.util.BIN_MIMETYPE
import app.revanced.manager.util.transparentListItemColors
@Composable
fun ImportPatchBundleDialog(
onDismiss: () -> Unit,
onRemoteSubmit: (String, Boolean) -> Unit,
onLocalSubmit: (Uri, Uri?) -> Unit
onLocalSubmit: (Uri) -> Unit
) {
var currentStep by rememberSaveable { mutableIntStateOf(0) }
var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) }
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
var integrations by rememberSaveable { mutableStateOf<Uri?>(null) }
var remoteUrl by rememberSaveable { mutableStateOf("") }
var autoUpdate by rememberSaveable { mutableStateOf(false) }
@ -60,16 +44,7 @@ fun ImportPatchBundleDialog(
}
fun launchPatchActivity() {
patchActivityLauncher.launch(JAR_MIMETYPE)
}
val integrationsActivityLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { integrations = it }
}
fun launchIntegrationsActivity() {
integrationsActivityLauncher.launch(APK_MIMETYPE)
patchActivityLauncher.launch(BIN_MIMETYPE)
}
val steps = listOf<@Composable () -> Unit>(
@ -82,11 +57,9 @@ fun ImportPatchBundleDialog(
ImportBundleStep(
bundleType,
patchBundle,
integrations,
remoteUrl,
autoUpdate,
{ launchPatchActivity() },
{ launchIntegrationsActivity() },
{ remoteUrl = it },
{ autoUpdate = it }
)
@ -114,13 +87,7 @@ fun ImportPatchBundleDialog(
enabled = inputsAreValid,
onClick = {
when (bundleType) {
BundleType.Local -> patchBundle?.let {
onLocalSubmit(
it,
integrations
)
}
BundleType.Local -> patchBundle?.let(onLocalSubmit)
BundleType.Remote -> onRemoteSubmit(remoteUrl, autoUpdate)
}
}
@ -170,11 +137,12 @@ fun SelectBundleTypeStep(
overlineContent = { Text(stringResource(R.string.recommended)) },
supportingContent = { Text(stringResource(R.string.remote_bundle_description)) },
leadingContent = {
RadioButton(
HapticRadioButton(
selected = bundleType == BundleType.Remote,
onClick = null
)
}
},
colors = transparentListItemColors
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
ListItem(
@ -186,11 +154,12 @@ fun SelectBundleTypeStep(
supportingContent = { Text(stringResource(R.string.local_bundle_description)) },
overlineContent = { },
leadingContent = {
RadioButton(
HapticRadioButton(
selected = bundleType == BundleType.Local,
onClick = null
)
}
},
colors = transparentListItemColors
)
}
}
@ -201,11 +170,9 @@ fun SelectBundleTypeStep(
fun ImportBundleStep(
bundleType: BundleType,
patchBundle: Uri?,
integrations: Uri?,
remoteUrl: String,
autoUpdate: Boolean,
launchPatchActivity: () -> Unit,
launchIntegrationsActivity: () -> Unit,
onRemoteUrlChange: (String) -> Unit,
onAutoUpdateChange: (Boolean) -> Unit
) {
@ -225,19 +192,8 @@ fun ImportBundleStep(
Icon(imageVector = Icons.Default.Topic, contentDescription = null)
}
},
modifier = Modifier.clickable { launchPatchActivity() }
)
ListItem(
headlineContent = {
Text(stringResource(R.string.integrations_field))
},
supportingContent = { Text(stringResource(if (integrations != null) R.string.file_field_set else R.string.file_field_not_set)) },
trailingContent = {
IconButton(onClick = launchIntegrationsActivity) {
Icon(imageVector = Icons.Default.Topic, contentDescription = null)
}
},
modifier = Modifier.clickable { launchIntegrationsActivity() }
modifier = Modifier.clickable { launchPatchActivity() },
colors = transparentListItemColors
)
}
}
@ -263,7 +219,7 @@ fun ImportBundleStep(
headlineContent = { Text(stringResource(R.string.auto_update)) },
leadingContent = {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
Checkbox(
HapticCheckbox(
checked = autoUpdate,
onCheckedChange = {
onAutoUpdateChange(!autoUpdate)
@ -271,6 +227,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,11 +2,7 @@ package app.revanced.manager.ui.component.patcher
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ListItem
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -16,6 +12,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.util.transparentListItemColors
@Composable
fun InstallPickerDialog(
@ -27,7 +25,7 @@ fun InstallPickerDialog(
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
Button(onClick = onDismiss) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.cancel))
}
},
@ -44,16 +42,17 @@ fun InstallPickerDialog(
title = { Text(stringResource(R.string.select_install_type)) },
text = {
Column {
InstallType.values().forEach {
InstallType.entries.forEach {
ListItem(
modifier = Modifier.clickable { selectedInstallType = it },
leadingContent = {
RadioButton(
HapticRadioButton(
selected = selectedInstallType == it,
onClick = null
)
},
headlineContent = { Text(stringResource(it.stringResource)) }
headlineContent = { Text(stringResource(it.stringResource)) },
colors = transparentListItemColors
)
}
}

View File

@ -44,6 +44,7 @@ import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.model.StepProgressProvider
import java.util.Locale
import kotlin.math.floor
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
@ -238,8 +239,17 @@ fun StepIcon(state: State, progress: Float? = null, size: Dp) {
contentDescription = description
}
},
/*
progress = {
progress?.let { (current, total) ->
if (total == null) return@let null
current / total
}?.toFloat()
},*/
progress = { progress },
strokeWidth = strokeWidth
)
}
}
}
private val Long.megaBytes get() = "%.1f".format(locale = Locale.ROOT, toDouble() / 1_000_000)

View File

@ -20,58 +20,37 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material.icons.outlined.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog as ComposeDialog
import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.FloatInputDialog
import app.revanced.manager.ui.component.IntInputDialog
import app.revanced.manager.ui.component.LongInputDialog
import app.revanced.manager.ui.component.*
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.ui.component.haptics.HapticSwitch
import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.saver.snapshotStateListSaver
import app.revanced.manager.util.saver.snapshotStateSetSaver
import app.revanced.manager.util.toast
import app.revanced.manager.util.transparentListItemColors
import kotlinx.parcelize.Parcelize
import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent
@ -80,6 +59,8 @@ import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyColumnState
import java.io.Serializable
import kotlin.random.Random
import kotlin.reflect.typeOf
import androidx.compose.ui.window.Dialog as ComposeDialog
private class OptionEditorScope<T : Any>(
private val editor: OptionEditor<T>,
@ -117,17 +98,17 @@ private interface OptionEditor<T : Any> {
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(
"Boolean" to BooleanOptionEditor,
"String" to StringOptionEditor,
"Int" to IntOptionEditor,
"Long" to LongOptionEditor,
"Float" to FloatOptionEditor,
"BooleanArray" to ListOptionEditor(BooleanOptionEditor),
"StringArray" to ListOptionEditor(StringOptionEditor),
"IntArray" to ListOptionEditor(IntOptionEditor),
"LongArray" to ListOptionEditor(LongOptionEditor),
"FloatArray" to ListOptionEditor(FloatOptionEditor),
*BooleanOptionEditor.toMapEditorElements(),
*StringOptionEditor.toMapEditorElements(),
*IntOptionEditor.toMapEditorElements(),
*LongOptionEditor.toMapEditorElements(),
*FloatOptionEditor.toMapEditorElements()
)
@Composable
@ -166,7 +147,7 @@ fun <T : Any> OptionItem(option: Option<T>, value: T?, setValue: (T?) -> Unit) {
val baseOptionEditor =
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
}
@ -335,7 +316,7 @@ private object BooleanOptionEditor : OptionEditor<Boolean> {
@Composable
override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) {
Switch(checked = scope.current, onCheckedChange = scope.setValue)
HapticSwitch(checked = scope.current, onCheckedChange = scope.setValue)
}
@Composable
@ -422,11 +403,12 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
headlineContent = { Text(title) },
supportingContent = value?.toString()?.let { { Text(it) } },
leadingContent = {
RadioButton(
HapticRadioButton(
selected = selectedPreset == presetKey,
onClick = { selectedPreset = presetKey }
)
}
},
colors = transparentListItemColors
)
}
@ -451,7 +433,7 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
option.key,
option.description,
option.required,
option.type.removeSuffix("Array"),
option.type.arguments.first().type!!,
null,
null
) { true }
@ -568,7 +550,7 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
floatingActionButton = {
if (deleteMode) return@Scaffold
ExtendedFloatingActionButton(
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.add)) },
icon = {
Icon(

View File

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

View File

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

View File

@ -22,13 +22,36 @@ fun SettingsListItem(
colors: ListItemColors = ListItemDefaults.colors(),
tonalElevation: Dp = ListItemDefaults.Elevation,
shadowElevation: Dp = ListItemDefaults.Elevation,
) = ListItem(
) = SettingsListItem(
headlineContent = {
Text(
text = headlineContent,
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)),
overlineContent = overlineContent,
supportingContent = {

View File

@ -22,9 +22,6 @@ sealed interface Destination : Parcelable {
@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

View File

@ -13,7 +13,4 @@ sealed interface SelectedAppInfoDestination : Parcelable {
@Parcelize
data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchSelection?, val options: @RawValue Options) : SelectedAppInfoDestination
@Parcelize
data object VersionSelector: SelectedAppInfoDestination
}

View File

@ -49,7 +49,7 @@ data class BundleInfo(
bundle.uid to patches
}
fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String) =
fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String?) =
sources.flatMapLatestAndCombine(
combiner = { it.filterNotNull() }
) { source ->
@ -64,7 +64,7 @@ data class BundleInfo(
bundle.patches.filter { it.compatibleWith(packageName) }.forEach {
val targetList = when {
it.compatiblePackages == null -> universal
it.supportsVersion(
it.supports(
packageName,
version
) -> supported

View File

@ -1,20 +1,35 @@
package app.revanced.manager.ui.model
import android.os.Parcelable
import app.revanced.manager.network.downloader.AppDownloader
import app.revanced.manager.network.downloader.ParceledDownloaderData
import kotlinx.parcelize.Parcelize
import java.io.File
sealed class SelectedApp : Parcelable {
abstract val packageName: String
abstract val version: String
sealed interface SelectedApp : Parcelable {
val packageName: String
val version: String?
@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
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
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

@ -10,7 +10,6 @@ import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -33,18 +32,20 @@ import app.revanced.manager.ui.component.SearchView
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.transparentListItemColors
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSelectorScreen(
onAppClick: (packageName: String) -> Unit,
onStorageClick: (SelectedApp.Local) -> Unit,
onSelect: (String) -> Unit,
onStorageSelect: (SelectedApp.Local) -> Unit,
onBackClick: () -> Unit,
vm: AppSelectorViewModel = koinViewModel()
) {
SideEffect {
vm.onStorageClick = onStorageClick
EventEffect(flow = vm.storageSelectionFlow) {
onStorageSelect(it)
}
val pickApkLauncher =
@ -74,7 +75,7 @@ fun AppSelectorScreen(
)
}
if (search) {
if (search)
SearchView(
query = filterText,
onQueryChange = { filterText = it },
@ -82,15 +83,15 @@ fun AppSelectorScreen(
placeholder = { Text(stringResource(R.string.search_apps)) }
) {
if (appList.isNotEmpty() && filterText.isNotEmpty()) {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize()
) {
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
items(
items = filteredAppList,
key = { it.packageName }
) { app ->
ListItem(
modifier = Modifier.clickable { onAppClick(app.packageName) },
modifier = Modifier.clickable {
onSelect(app.packageName)
},
leadingContent = {
AppIcon(
app.packageInfo,
@ -110,9 +111,9 @@ fun AppSelectorScreen(
)
)
}
}
},
colors = transparentListItemColors
)
}
}
} else {
@ -124,17 +125,18 @@ fun AppSelectorScreen(
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = stringResource(R.string.search),
modifier = Modifier.size(64.dp)
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(R.string.type_anything),
style = MaterialTheme.typography.bodyLarge
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
Scaffold(
topBar = {
@ -183,7 +185,9 @@ fun AppSelectorScreen(
key = { it.packageName }
) { app ->
ListItem(
modifier = Modifier.clickable { onAppClick(app.packageName) },
modifier = Modifier.clickable {
onSelect(app.packageName)
},
leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
headlineContent = {
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,7 @@ import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@ -31,8 +31,9 @@ import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.AutoUpdatesDialog
import app.revanced.manager.ui.component.AvailableUpdateDialog
import app.revanced.manager.ui.component.NotificationCard
import app.revanced.manager.ui.component.bundle.BundleItem
import app.revanced.manager.ui.component.bundle.BundleTopBar
import app.revanced.manager.ui.component.haptics.HapticFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.bundle.ImportPatchBundleDialog
import app.revanced.manager.ui.viewmodel.DashboardViewModel
import app.revanced.manager.util.toast
@ -48,17 +49,21 @@ enum class DashboardPage(
}
@SuppressLint("BatteryLife")
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
vm: DashboardViewModel = koinViewModel(),
onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit,
onUpdateClick: () -> Unit,
onDownloaderPluginClick: () -> Unit,
onAppClick: (InstalledApp) -> Unit
) {
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } }
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle(
false
)
val androidContext = LocalContext.current
val composableScope = rememberCoroutineScope()
val pagerState = rememberPagerState(
@ -77,9 +82,9 @@ fun DashboardScreen(
if (showAddBundleDialog) {
ImportPatchBundleDialog(
onDismiss = { showAddBundleDialog = false },
onLocalSubmit = { patches, integrations ->
onLocalSubmit = { patches ->
showAddBundleDialog = false
vm.createLocalSource(patches, integrations)
vm.createLocalSource(patches)
},
onRemoteSubmit = { url, autoUpdate ->
showAddBundleDialog = false
@ -168,7 +173,7 @@ fun DashboardScreen(
}
},
floatingActionButton = {
FloatingActionButton(
HapticFloatingActionButton(
onClick = {
vm.cancelSourceSelection()
@ -181,7 +186,7 @@ fun DashboardScreen(
DashboardPage.BUNDLES.ordinal
)
}
return@FloatingActionButton
return@HapticFloatingActionButton
}
onAppSelectorClick()
@ -201,7 +206,7 @@ fun DashboardScreen(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
DashboardPage.entries.forEachIndexed { index, page ->
Tab(
HapticTab(
selected = pagerState.currentPage == index,
onClick = { composableScope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(stringResource(page.titleResId)) },
@ -236,6 +241,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
)
@ -262,33 +281,17 @@ fun DashboardScreen(
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
Column(
modifier = Modifier.fillMaxSize(),
) {
sources.forEach {
BundleItem(
bundle = it,
onDelete = {
vm.delete(it)
},
onUpdate = {
vm.update(it)
},
selectable = bundlesSelectable,
onSelect = {
vm.selectedSources.add(it)
},
isBundleSelected = vm.selectedSources.contains(it),
toggleSelection = { bundleIsNotSelected ->
if (bundleIsNotSelected) {
vm.selectedSources.add(it)
} else {
vm.selectedSources.remove(it)
}
}
)
}
}
BundleListScreen(
onDelete = {
vm.delete(it)
},
onUpdate = {
vm.update(it)
},
sources = sources,
selectedSources = vm.selectedSources,
bundlesSelectable = bundlesSelectable
)
}
}
}

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.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.toast
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InstalledAppInfoScreen(
onPatchClick: (packageName: String, patchSelection: PatchSelection) -> Unit,
onPatchClick: (packageName: String) -> Unit,
onBackClick: () -> Unit,
viewModel: InstalledAppInfoViewModel
) {
@ -81,7 +80,7 @@ fun InstalledAppInfoScreen(
AppInfo(viewModel.appInfo) {
Text(viewModel.installedApp.version, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium)
if (viewModel.installedApp.installType == InstallType.ROOT) {
if (viewModel.installedApp.installType == InstallType.MOUNT) {
Text(
text = if (viewModel.isMounted) {
stringResource(R.string.mounted)
@ -112,7 +111,7 @@ fun InstalledAppInfoScreen(
onClick = viewModel::uninstall
)
InstallType.ROOT -> {
InstallType.MOUNT -> {
SegmentedButton(
icon = Icons.Outlined.SettingsBackupRestore,
text = stringResource(R.string.unpatch),
@ -134,11 +133,9 @@ fun InstalledAppInfoScreen(
icon = Icons.Outlined.Update,
text = stringResource(R.string.repatch),
onClick = {
viewModel.appliedPatches?.let {
onPatchClick(viewModel.installedApp.originalPackageName, it)
}
onPatchClick(viewModel.installedApp.originalPackageName)
},
enabled = viewModel.installedApp.installType != InstallType.ROOT || viewModel.rootInstaller.hasRootAccess()
enabled = viewModel.installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess()
)
}

View File

@ -2,14 +2,10 @@ package app.revanced.manager.ui.screen
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
@ -17,13 +13,7 @@ import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material.icons.outlined.PostAdd
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -37,14 +27,17 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.ui.component.AppScaffold
import app.revanced.manager.ui.component.AppTopBar
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.Steps
import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.viewmodel.PatcherViewModel
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -78,6 +71,38 @@ fun PatcherScreen(
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(
topBar = {
AppTopBar(
@ -103,7 +128,7 @@ fun PatcherScreen(
},
floatingActionButton = {
AnimatedVisibility(visible = canInstall) {
ExtendedFloatingActionButton(
HapticExtendedFloatingActionButton(
text = {
Text(
stringResource(if (vm.installedPackageName == null) R.string.install_app else R.string.open_app)
@ -122,7 +147,8 @@ fun PatcherScreen(
},
onClick = {
if (vm.installedPackageName == null)
showInstallPicker = true
if (vm.isDeviceRooted()) showInstallPicker = true
else vm.install(InstallType.DEFAULT)
else vm.open()
}
)

View File

@ -1,6 +1,5 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
@ -35,6 +34,9 @@ import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.SafeguardDialog
import app.revanced.manager.ui.component.SearchView
import app.revanced.manager.ui.component.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.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_SUPPORTED
@ -43,9 +45,10 @@ import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.transparentListItemColors
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PatchesSelectorScreen(
onSave: (PatchSelection?, Options) -> Unit,
@ -133,7 +136,7 @@ fun PatchesSelectorScreen(
if (vm.compatibleVersions.isNotEmpty())
UnsupportedPatchDialog(
appVersion = vm.appVersion,
appVersion = vm.appVersion ?: stringResource(R.string.any_version),
supportedVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs
)
@ -142,7 +145,7 @@ fun PatchesSelectorScreen(
}
if (showUnsupportedPatchesDialog)
UnsupportedPatchesDialog(
appVersion = vm.appVersion,
appVersion = vm.appVersion ?: stringResource(R.string.any_version),
onDismissRequest = { showUnsupportedPatchesDialog = false }
)
@ -200,15 +203,15 @@ fun PatchesSelectorScreen(
when {
// Open unsupported dialog if the patch is not supported
!supported -> vm.openUnsupportedDialog(patch)
// Show selection warning if enabled
vm.selectionWarningEnabled -> showSelectionWarning = true
// Set pending universal patch action if the universal patch warning is enabled and there are no compatible packages
vm.universalPatchWarningEnabled && patch.compatiblePackages == null -> {
vm.pendingUniversalPatchAction = { vm.togglePatch(uid, patch) }
}
// Toggle the patch otherwise
else -> vm.togglePatch(uid, patch)
}
@ -271,7 +274,11 @@ fun PatchesSelectorScreen(
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.patches_selected, selectedPatchCount, availablePatchCount),
title = stringResource(
R.string.patches_selected,
selectedPatchCount,
availablePatchCount
),
onBackClick = onBackClick,
actions = {
IconButton(onClick = vm::reset) {
@ -293,7 +300,7 @@ fun PatchesSelectorScreen(
floatingActionButton = {
if (!showPatchButton) return@Scaffold
ExtendedFloatingActionButton(
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.save)) },
icon = {
Icon(
@ -321,7 +328,7 @@ fun PatchesSelectorScreen(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.0.dp)
) {
bundles.forEachIndexed { index, bundle ->
Tab(
HapticTab(
selected = pagerState.currentPage == index,
onClick = {
composableScope.launch {
@ -438,7 +445,7 @@ private fun PatchItem(
.clickable(onClick = onToggle)
.fillMaxSize(),
leadingContent = {
Checkbox(
HapticCheckbox(
checked = selected,
onCheckedChange = { onToggle() },
enabled = supported
@ -452,7 +459,8 @@ private fun PatchItem(
Icon(Icons.Outlined.Settings, null)
}
}
}
},
colors = transparentListItemColors
)
@Composable
@ -477,7 +485,8 @@ private fun ListHeader(
)
}
}
}
},
colors = transparentListItemColors
)
}

View File

@ -1,20 +1,18 @@
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.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -25,25 +23,31 @@ 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.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.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.destination.SelectedAppInfoDestination
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.enabled
import app.revanced.manager.util.toast
import dev.olshevski.navigation.reimagined.AnimatedNavHost
import dev.olshevski.navigation.reimagined.NavBackHandler
import dev.olshevski.navigation.reimagined.navigate
import dev.olshevski.navigation.reimagined.pop
import dev.olshevski.navigation.reimagined.rememberNavController
import app.revanced.manager.util.transparentListItemColors
import dev.olshevski.navigation.reimagined.*
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectedAppInfoScreen(
onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit,
@ -70,56 +74,137 @@ fun SelectedAppInfoScreen(
}
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = vm::handlePluginActivityResult
)
EventEffect(flow = vm.launchActivityFlow) { intent ->
launcher.launch(intent)
}
val navController =
rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main)
NavBackHandler(controller = navController)
AnimatedNavHost(controller = navController) { destination ->
val error by vm.errorFlow.collectAsStateWithLifecycle(null)
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)
is SelectedAppInfoDestination.Main -> Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.app_info),
onBackClick = onBackClick
)
},
onPatchSelectorClick = {
navController.navigate(
SelectedAppInfoDestination.PatchesSelector(
vm.selectedApp,
vm.getCustomPatches(
bundles,
allowIncompatiblePatches
),
vm.options
floatingActionButton = {
if (error != null) return@Scaffold
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch)) },
icon = {
Icon(
Icons.Default.AutoFixHigh,
stringResource(R.string.patch)
)
},
onClick = patchClick@{
if (selectedPatchCount == 0) {
context.toast(context.getString(R.string.no_patches_selected))
return@patchClick
}
onPatchClick(
vm.selectedApp,
patches,
vm.getOptionsFiltered(bundles)
)
}
)
}
) { 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(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
AppInfo(vm.selectedAppInfo, placeholderLabel = packageName) {
Text(
version ?: stringResource(R.string.selected_app_meta_any_version),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
)
},
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) }
)
PageItem(
R.string.patch_selector_item,
stringResource(
R.string.patch_selector_item_description,
selectedPatchCount
),
onClick = {
navController.navigate(
SelectedAppInfoDestination.PatchesSelector(
vm.selectedApp,
vm.getCustomPatches(
bundles,
allowIncompatiblePatches
),
vm.options
)
)
}
)
PageItem(
R.string.apk_source_selector_item,
when (val app = vm.selectedApp) {
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)
)
}
}
}
is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
onSave = { patches, options ->
@ -141,65 +226,6 @@ fun SelectedAppInfoScreen(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SelectedAppInfoScreen(
onPatchClick: () -> Unit,
onPatchSelectorClick: () -> Unit,
onVersionSelectorClick: () -> Unit,
onBackClick: () -> Unit,
selectedPatchCount: Int,
packageName: String,
version: String,
packageInfo: PackageInfo?,
) {
Scaffold(
topBar = {
AppTopBar(
title = stringResource(R.string.app_info),
onBackClick = onBackClick
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch)) },
icon = {
Icon(
Icons.Default.AutoFixHigh,
stringResource(R.string.patch)
)
},
onClick = onPatchClick
)
}
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
AppInfo(packageInfo, placeholderLabel = packageName) {
Text(
stringResource(R.string.selected_app_meta, version),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
)
}
PageItem(
R.string.patch_selector_item,
stringResource(R.string.patch_selector_item_description, selectedPatchCount),
onPatchSelectorClick
)
PageItem(
R.string.version_selector_item,
stringResource(R.string.version_selector_item_description, version),
onVersionSelectorClick
)
}
}
}
@Composable
private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) {
ListItem(
@ -224,4 +250,89 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
Icon(Icons.AutoMirrored.Outlined.ArrowRight, null)
}
)
}
@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

@ -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.ROOT && !viewModel.rootInstaller.hasRootAccess()),
alreadyPatched = installedApp != null && installedApp.installType != InstallType.ROOT
)
}
}
}
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

@ -4,6 +4,7 @@ import android.app.ActivityManager
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Build
import android.view.HapticFeedbackConstants
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
@ -17,9 +18,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -35,6 +34,7 @@ import app.revanced.manager.ui.component.settings.IntegerItem
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.AdvancedSettingsViewModel
import app.revanced.manager.util.toast
import app.revanced.manager.util.withHapticFeedback
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@ -52,7 +52,6 @@ fun AdvancedSettingsScreen(
activityManager.largeMemoryClass
)
}
val haptics = LocalHapticFeedback.current
Scaffold(
topBar = {
@ -103,12 +102,6 @@ fun AdvancedSettingsScreen(
headline = R.string.process_runtime_memory_limit,
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))
BooleanItem(
@ -159,13 +152,12 @@ fun AdvancedSettingsScreen(
onClick = { },
onLongClickLabel = stringResource(R.string.copy_to_clipboard),
onLongClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
clipboard.setPrimaryClip(
ClipData.newPlainText("Device Information", deviceContent)
)
context.toast(context.getString(R.string.toast_copied_to_clipboard))
}
}.withHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
),
headlineContent = stringResource(R.string.about_device),
supportingContent = deviceContent

View File

@ -1,39 +1,60 @@
package app.revanced.manager.ui.screen.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Checkbox
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.downloader.DownloaderPluginState
import app.revanced.manager.ui.component.AppLabel
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.DownloadsViewModel
import org.koin.androidx.compose.koinViewModel
import java.security.MessageDigest
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class)
@Composable
fun DownloadsSettingsScreen(
onBackClick: () -> Unit,
viewModel: DownloadsViewModel = koinViewModel()
) {
val prefs = viewModel.prefs
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(initialValue = emptyList())
val pullRefreshState = rememberPullToRefreshState()
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle()
Scaffold(
topBar = {
@ -41,8 +62,8 @@ fun DownloadsSettingsScreen(
title = stringResource(R.string.downloads),
onBackClick = onBackClick,
actions = {
if (viewModel.selection.isNotEmpty()) {
IconButton(onClick = { viewModel.delete() }) {
if (viewModel.appSelection.isNotEmpty()) {
IconButton(onClick = { viewModel.deleteApps() }) {
Icon(Icons.Default.Delete, stringResource(R.string.delete))
}
}
@ -50,35 +71,178 @@ fun DownloadsSettingsScreen(
)
}
) { paddingValues ->
ColumnWithScrollbar(
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.padding(paddingValues)
.fillMaxWidth()
.zIndex(1f)
) {
PullToRefreshDefaults.Indicator(
state = pullRefreshState,
isRefreshing = viewModel.isRefreshingPlugins
)
}
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.pullToRefresh(
isRefreshing = viewModel.isRefreshingPlugins,
state = pullRefreshState,
onRefresh = viewModel::refreshPlugins
)
) {
BooleanItem(
preference = prefs.preferSplits,
headline = R.string.prefer_splits,
description = R.string.prefer_splits_description,
)
item {
GroupHeader(stringResource(R.string.downloader_plugins))
}
pluginStates.forEach { (packageName, state) ->
item(key = packageName) {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
GroupHeader(stringResource(R.string.downloaded_apps))
fun dismiss() {
showDialog = false
}
downloadedApps.forEach { app ->
val selected = app in viewModel.selection
val packageInfo =
remember(packageName) {
viewModel.pm.getPackageInfo(
packageName
)
} ?: return@item
if (showDialog) {
val signature =
remember(packageName) {
val androidSignature =
viewModel.pm.getSignature(packageName)
val hash = MessageDigest.getInstance("SHA-256")
.digest(androidSignature.toByteArray())
hash.toHexString(format = HexFormat.UpperCase)
}
when (state) {
is DownloaderPluginState.Loaded -> TrustDialog(
title = R.string.downloader_plugin_revoke_trust_dialog_title,
body = stringResource(
R.string.downloader_plugin_trust_dialog_body,
packageName,
signature
),
onDismiss = ::dismiss,
onConfirm = {
viewModel.revokePluginTrust(packageName)
dismiss()
}
)
is DownloaderPluginState.Failed -> ExceptionViewerDialog(
text = remember(state.throwable) {
state.throwable.stackTraceToString()
},
onDismiss = ::dismiss
)
is DownloaderPluginState.Untrusted -> TrustDialog(
title = R.string.downloader_plugin_trust_dialog_title,
body = stringResource(
R.string.downloader_plugin_trust_dialog_body,
packageName,
signature
),
onDismiss = ::dismiss,
onConfirm = {
viewModel.trustPlugin(packageName)
dismiss()
}
)
}
}
SettingsListItem(
modifier = Modifier.clickable { showDialog = true },
headlineContent = {
AppLabel(
packageInfo = packageInfo,
style = MaterialTheme.typography.titleLarge
)
},
supportingContent = stringResource(
when (state) {
is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted
is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed
is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted
}
),
trailingContent = { Text(packageInfo.versionName!!) }
)
}
}
if (pluginStates.isEmpty()) {
item {
Text(
stringResource(R.string.downloader_no_plugins_installed),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
item {
GroupHeader(stringResource(R.string.downloaded_apps))
}
items(downloadedApps, key = { it.packageName to it.version }) { app ->
val selected = app in viewModel.appSelection
SettingsListItem(
modifier = Modifier.clickable { viewModel.toggleItem(app) },
modifier = Modifier.clickable { viewModel.toggleApp(app) },
headlineContent = app.packageName,
leadingContent = (@Composable {
Checkbox(
HapticCheckbox(
checked = selected,
onCheckedChange = { viewModel.toggleItem(app) }
onCheckedChange = { viewModel.toggleApp(app) }
)
}).takeIf { viewModel.selection.isNotEmpty() },
}).takeIf { viewModel.appSelection.isNotEmpty() },
supportingContent = app.version,
tonalElevation = if (selected) 8.dp else 0.dp
)
}
if (downloadedApps.isEmpty()) {
item {
Text(
stringResource(R.string.downloader_settings_no_apps),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
}
}
}
@Composable
private fun TrustDialog(
@StringRes title: Int,
body: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.continue_))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.dismiss))
}
},
title = { Text(stringResource(title)) },
text = { Text(body) }
)
}

View File

@ -28,6 +28,7 @@ import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.theme.Theme
@ -113,7 +114,7 @@ private fun ThemePicker(
.clickable { selectedTheme = it },
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
HapticRadioButton(
selected = selectedTheme == it,
onClick = { selectedTheme = it })
Text(stringResource(it.displayName))

View File

@ -5,12 +5,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -19,11 +15,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.settings.Changelog
import app.revanced.manager.ui.viewmodel.ChangelogsViewModel
import app.revanced.manager.util.formatNumber
import app.revanced.manager.util.relativeTime
import org.koin.androidx.compose.koinViewModel
@ -33,8 +28,6 @@ fun ChangelogsScreen(
onBackClick: () -> Unit,
vm: ChangelogsViewModel = koinViewModel()
) {
val changelogs = vm.changelogs
Scaffold(
topBar = {
AppTopBar(
@ -43,54 +36,22 @@ fun ChangelogsScreen(
)
}
) { paddingValues ->
LazyColumnWithScrollbar(
ColumnWithScrollbar(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = if (changelogs.isNullOrEmpty()) Arrangement.Center else Arrangement.Top
verticalArrangement = if (vm.releaseInfo == null) Arrangement.Center else Arrangement.Top
) {
if (changelogs == null) {
item {
LoadingIndicator()
}
} else if (changelogs.isEmpty()) {
item {
Text(
text = stringResource(id = R.string.no_changelogs_found),
style = MaterialTheme.typography.titleLarge
vm.releaseInfo?.let { info ->
Column(modifier = Modifier.padding(16.dp)) {
Changelog(
markdown = info.description.replace("`", ""),
version = info.version,
publishDate = info.createdAt.relativeTime(LocalContext.current)
)
}
} else {
val lastChangelog = changelogs.last()
items(
changelogs,
key = { it.version }
) { changelog ->
ChangelogItem(changelog, lastChangelog)
}
}
}
}
}
@Composable
fun ChangelogItem(
changelog: ChangelogsViewModel.Changelog,
lastChangelog: ChangelogsViewModel.Changelog
) {
Column(modifier = Modifier.padding(16.dp)) {
Changelog(
markdown = changelog.body.replace("`", ""),
version = changelog.version,
downloadCount = changelog.downloadCount.formatNumber(),
publishDate = changelog.publishDate.relativeTime(LocalContext.current)
)
if (changelog != lastChangelog) {
HorizontalDivider(
modifier = Modifier.padding(top = 32.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
} ?: LoadingIndicator()
}
}
}

View File

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

View File

@ -15,7 +15,9 @@ import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@ -30,7 +32,8 @@ class AppSelectorViewModel(
private val inputFile = File(fs.uiTempDir, "input.apk").also(File::delete)
val appList = pm.appList
var onStorageClick: (SelectedApp.Local) -> Unit = {}
private val storageSelectionChannel = Channel<SelectedApp.Local>()
val storageSelectionFlow = storageSelectionChannel.receiveAsFlow()
val suggestedAppVersions = patchBundleRepository.suggestedVersions.flowOn(Dispatchers.Default)
@ -54,7 +57,7 @@ class AppSelectorViewModel(
}
if (patchBundleRepository.isVersionAllowed(selectedApp.packageName, selectedApp.version)) {
onStorageClick(selectedApp)
storageSelectionChannel.send(selectedApp)
} else {
nonSuggestedVersionDialogSubject = selectedApp
}
@ -69,7 +72,7 @@ class AppSelectorViewModel(
pm.getPackageInfo(this)?.let { packageInfo ->
SelectedApp.Local(
packageName = packageInfo.packageName,
version = packageInfo.versionName,
version = packageInfo.versionName!!,
file = this,
temporary = true
)

View File

@ -8,9 +8,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType
import app.revanced.manager.network.utils.getOrNull
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.launch
@ -18,27 +17,14 @@ class ChangelogsViewModel(
private val api: ReVancedAPI,
private val app: Application,
) : ViewModel() {
var changelogs: List<Changelog>? by mutableStateOf(null)
var releaseInfo: ReVancedAsset? by mutableStateOf(null)
private set
init {
viewModelScope.launch {
uiSafe(app, R.string.changelog_download_fail, "Failed to download changelog") {
changelogs = api.getReleases("revanced-manager").getOrNull().orEmpty().map { release ->
Changelog(
release.version,
release.findAssetByType(APK_MIMETYPE).downloadCount,
release.metadata.publishedAt,
release.metadata.body
)
}
releaseInfo = api.getLatestAppInfo().getOrThrow()
}
}
}
data class Changelog(
val version: String,
val downloadCount: Int,
val publishDate: String,
val body: String,
)
}

View File

@ -18,6 +18,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.RemotePatchBundle
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.network.api.ReVancedAPI
import app.revanced.manager.util.PM
@ -30,6 +31,7 @@ import kotlinx.coroutines.launch
class DashboardViewModel(
private val app: Application,
private val patchBundleRepository: PatchBundleRepository,
private val downloaderPluginRepository: DownloaderPluginRepository,
private val reVancedAPI: ReVancedAPI,
private val networkInfo: NetworkInfo,
val prefs: PreferencesManager,
@ -42,6 +44,8 @@ class DashboardViewModel(
val sources = patchBundleRepository.sources
val selectedSources = mutableStateListOf<PatchBundleSource>()
val newDownloaderPluginsAvailable = downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() }
var updatedManagerVersion: String? by mutableStateOf(null)
private set
var showBatteryOptimizationsWarning by mutableStateOf(false)
@ -63,6 +67,10 @@ class DashboardViewModel(
}
}
fun ignoreNewDownloaderPlugins() = viewModelScope.launch {
downloaderPluginRepository.acknowledgeAllNewPlugins()
}
fun dismissUpdateDialog() {
updatedManagerVersion = null
}
@ -106,13 +114,10 @@ class DashboardViewModel(
selectedSources.clear()
}
fun createLocalSource(patchBundle: Uri, integrations: Uri?) =
fun createLocalSource(patchBundle: Uri) =
viewModelScope.launch {
contentResolver.openInputStream(patchBundle)!!.use { patchesStream ->
integrations?.let { contentResolver.openInputStream(it) }
.use { integrationsStream ->
patchBundleRepository.createLocal(patchesStream, integrationsStream)
}
patchBundleRepository.createLocal(patchesStream)
}
}

View File

@ -1,10 +1,15 @@
package app.revanced.manager.ui.viewmodel
import android.content.pm.PackageInfo
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.util.PM
import app.revanced.manager.util.mutableStateSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
@ -14,8 +19,10 @@ import kotlinx.coroutines.withContext
class DownloadsViewModel(
private val downloadedAppRepository: DownloadedAppRepository,
val prefs: PreferencesManager
private val downloaderPluginRepository: DownloaderPluginRepository,
val pm: PM
) : ViewModel() {
val downloaderPluginStates = downloaderPluginRepository.pluginStates
val downloadedApps = downloadedAppRepository.getAll().map { downloadedApps ->
downloadedApps.sortedWith(
compareBy<DownloadedApp> {
@ -23,24 +30,39 @@ class DownloadsViewModel(
}.thenBy { it.version }
)
}
val appSelection = mutableStateSetOf<DownloadedApp>()
val selection = mutableStateSetOf<DownloadedApp>()
var isRefreshingPlugins by mutableStateOf(false)
private set
fun toggleItem(downloadedApp: DownloadedApp) {
if (selection.contains(downloadedApp))
selection.remove(downloadedApp)
fun toggleApp(downloadedApp: DownloadedApp) {
if (appSelection.contains(downloadedApp))
appSelection.remove(downloadedApp)
else
selection.add(downloadedApp)
appSelection.add(downloadedApp)
}
fun delete() {
fun deleteApps() {
viewModelScope.launch(NonCancellable) {
downloadedAppRepository.delete(selection)
downloadedAppRepository.delete(appSelection)
withContext(Dispatchers.Main) {
selection.clear()
appSelection.clear()
}
}
}
fun refreshPlugins() = viewModelScope.launch {
isRefreshingPlugins = true
downloaderPluginRepository.reload()
isRefreshingPlugins = false
}
fun trustPlugin(packageName: String) = viewModelScope.launch {
downloaderPluginRepository.trustPackage(packageName)
}
fun revokePluginTrust(packageName: String) = viewModelScope.launch {
downloaderPluginRepository.revokeTrustForPackage(packageName)
}
}

View File

@ -78,7 +78,7 @@ class InstalledAppInfoViewModel(
when (installedApp.installType) {
InstallType.DEFAULT -> pm.uninstallPackage(installedApp.currentPackageName)
InstallType.ROOT -> viewModelScope.launch {
InstallType.MOUNT -> viewModelScope.launch {
rootInstaller.uninstall(installedApp.currentPackageName)
installedAppRepository.delete(installedApp)
onBackClick()

View File

@ -30,7 +30,7 @@ class InstalledAppsViewModel(
packageInfoMap[installedApp.currentPackageName] = withContext(Dispatchers.IO) {
try {
if (
installedApp.installType == InstallType.ROOT && !rootInstaller.isAppInstalled(installedApp.currentPackageName)
installedApp.installType == InstallType.MOUNT && !rootInstaller.isAppInstalled(installedApp.currentPackageName)
) {
installedAppsRepository.delete(installedApp)
return@withContext null
@ -39,7 +39,7 @@ class InstalledAppsViewModel(
val packageInfo = pm.getPackageInfo(installedApp.currentPackageName)
if (packageInfo == null && installedApp.installType != InstallType.ROOT) {
if (packageInfo == null && installedApp.installType != InstallType.MOUNT) {
installedAppsRepository.delete(installedApp)
return@withContext null
}

View File

@ -15,13 +15,17 @@ import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SerializedSelection
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.theme.Theme
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -29,10 +33,40 @@ import kotlinx.serialization.json.Json
class MainViewModel(
private val patchBundleRepository: PatchBundleRepository,
private val patchSelectionRepository: PatchSelectionRepository,
private val downloadedAppRepository: DownloadedAppRepository,
private val keystoreManager: KeystoreManager,
private val app: Application,
val prefs: PreferencesManager
) : ViewModel() {
private val appSelectChannel = Channel<SelectedApp>()
val appSelectFlow = appSelectChannel.receiveAsFlow()
private suspend fun suggestedVersion(packageName: String) =
patchBundleRepository.suggestedVersions.first()[packageName]
private suspend fun findDownloadedApp(app: SelectedApp): SelectedApp.Local? {
if (app !is SelectedApp.Search) return null
val suggestedVersion = suggestedVersion(app.packageName) ?: return null
val downloadedApp =
downloadedAppRepository.get(app.packageName, suggestedVersion, markUsed = true) ?: return null
return SelectedApp.Local(
downloadedApp.packageName,
downloadedApp.version,
downloadedAppRepository.getApkFileForApp(downloadedApp),
false
)
}
fun selectApp(app: SelectedApp) = viewModelScope.launch {
appSelectChannel.send(findDownloadedApp(app) ?: app)
}
fun selectApp(packageName: String) = viewModelScope.launch {
selectApp(SelectedApp.Search(packageName, suggestedVersion(packageName)))
}
fun importLegacySettings(componentActivity: ComponentActivity) {
if (!prefs.firstLaunch.getBlocking()) return

View File

@ -9,6 +9,7 @@ import android.content.pm.PackageInstaller
import android.net.Uri
import android.os.ParcelUuid
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -35,6 +36,8 @@ import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.logger.LogLevel
import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.worker.PatcherWorker
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import app.revanced.manager.ui.destination.Destination
@ -52,9 +55,12 @@ import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.time.withTimeout
import kotlinx.coroutines.withContext
@ -65,7 +71,7 @@ import java.nio.file.Files
import java.time.Duration
@Stable
@OptIn(SavedStateHandleSaveableApi::class)
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
class PatcherViewModel(
private val input: Destination.Patcher
) : ViewModel(), KoinComponent, StepProgressProvider, InstallerModel {
@ -100,12 +106,20 @@ class PatcherViewModel(
var isInstalling by mutableStateOf(ongoingPmSession)
private set
private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, String>? by mutableStateOf(null)
val activityPromptDialog by derivedStateOf { currentActivityRequest?.second }
private var launchedActivity: CompletableDeferred<ActivityResult>? = null
private val launchActivityChannel = Channel<Intent>()
val launchActivityFlow = launchActivityChannel.receiveAsFlow()
private val tempDir = savedStateHandle.saveable(key = "tempDir") {
fs.uiTempDir.resolve("installer").also {
it.deleteRecursively()
it.mkdirs()
}
}
private var inputFile: File? by savedStateHandle.saveableVar()
private val outputFile = tempDir.resolve("output.apk")
@ -171,6 +185,33 @@ class PatcherViewModel(
},
onPatchCompleted = { withContext(Dispatchers.Main) { completedPatchCount += 1 } },
setInputFile = { withContext(Dispatchers.Main) { inputFile = it } },
handleStartActivityRequest = { plugin, intent ->
withContext(Dispatchers.Main) {
if (currentActivityRequest != null) throw Exception("Another request is already pending.")
try {
// Wait for the dialog interaction.
val accepted = with(CompletableDeferred<Boolean>()) {
currentActivityRequest = this to plugin.name
await()
}
if (!accepted) throw UserInteractionException.RequestDenied()
// Launch the activity and wait for the result.
try {
with(CompletableDeferred<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
await()
}
} finally {
launchedActivity = null
}
} finally {
currentActivityRequest = null
}
}
},
onProgress = { name, state, message ->
viewModelScope.launch {
steps[currentStepIndex] = steps[currentStepIndex].run {
@ -194,8 +235,8 @@ class PatcherViewModel(
}
val patcherSucceeded =
workManager.getWorkInfoByIdLiveData(patcherWorkerId.uuid).map { workInfo: WorkInfo ->
when (workInfo.state) {
workManager.getWorkInfoByIdLiveData(patcherWorkerId.uuid).map { workInfo: WorkInfo? ->
when (workInfo?.state) {
WorkInfo.State.SUCCEEDED -> true
WorkInfo.State.FAILED -> false
else -> null
@ -215,13 +256,15 @@ class PatcherViewModel(
?.let(logger::trace)
if (pmStatus == PackageInstaller.STATUS_SUCCESS) {
app.toast(app.getString(R.string.install_app_success))
installedPackageName =
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME)
viewModelScope.launch {
installedAppRepository.addOrUpdate(
installedPackageName!!,
packageName,
input.selectedApp.version,
input.selectedApp.version
?: pm.getPackageInfo(outputFile)?.versionName!!,
InstallType.DEFAULT,
input.selectedPatches
)
@ -273,7 +316,7 @@ class PatcherViewModel(
app.unregisterReceiver(installerBroadcastReceiver)
workManager.cancelWorkById(patcherWorkerId.uuid)
if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.ROOT) {
if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) {
GlobalScope.launch(Dispatchers.Main) {
uiSafe(app, R.string.failed_to_mount, "Failed to mount") {
withTimeout(Duration.ofMinutes(1L)) {
@ -286,6 +329,20 @@ class PatcherViewModel(
tempDir.deleteRecursively()
}
fun isDeviceRooted() = rootInstaller.isDeviceRooted()
fun rejectInteraction() {
currentActivityRequest?.first?.complete(false)
}
fun allowInteraction() {
currentActivityRequest?.first?.complete(true)
}
fun handleActivityResult(result: ActivityResult) {
launchedActivity?.complete(result)
}
fun export(uri: Uri?) = viewModelScope.launch {
uri?.let {
withContext(Dispatchers.IO) {
@ -344,37 +401,42 @@ class PatcherViewModel(
pmInstallStarted = true
}
InstallType.ROOT -> {
InstallType.MOUNT -> {
try {
val packageInfo = pm.getPackageInfo(outputFile)
?: throw Exception("Failed to load application info")
val label = with(pm) {
packageInfo.label()
}
// Check for base APK, first check if the app is already installed
if (existingPackageInfo == null) {
// If the app is not installed, check if the output file is a base apk
if (currentPackageInfo.splitNames != null) {
if (currentPackageInfo.splitNames.isNotEmpty()) {
// Exit if there is no base APK package
packageInstallerStatus = PackageInstaller.STATUS_FAILURE_INVALID
return@launch
}
}
// Get label
val label = with(pm) {
currentPackageInfo.label()
}
val inputVersion = input.selectedApp.version
?: inputFile?.let(pm::getPackageInfo)?.versionName
?: throw Exception("Failed to determine input APK version")
// Install as root
rootInstaller.install(
outputFile,
inputFile,
packageName,
input.selectedApp.version,
inputVersion,
label
)
installedAppRepository.addOrUpdate(
packageInfo.packageName,
packageName,
packageName,
input.selectedApp.version,
InstallType.ROOT,
inputVersion,
InstallType.MOUNT,
input.selectedPatches
)
@ -402,7 +464,7 @@ class PatcherViewModel(
}
override fun install() {
// InstallType.ROOT is never used here since this overload is for the package installer status dialog.
// InstallType.MOUNT is never used here since this overload is for the package installer status dialog.
install(InstallType.DEFAULT)
}
@ -433,7 +495,8 @@ class PatcherViewModel(
}
fun generateSteps(context: Context, selectedApp: SelectedApp): List<Step> {
val needsDownload = selectedApp is SelectedApp.Download
val needsDownload =
selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search
return listOfNotNull(
Step(

View File

@ -1,50 +1,91 @@
package app.revanced.manager.ui.viewmodel
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.content.pm.PackageInfo
import android.os.Parcelable
import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.annotation.StringRes
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchOptionsRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.network.downloader.ParceledDownloaderData
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.BundleInfo
import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options
import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
@OptIn(SavedStateHandleSaveableApi::class)
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
private val app: Application = get()
val bundlesRepo: PatchBundleRepository = get()
private val bundleRepository: PatchBundleRepository = get()
private val selectionRepository: PatchSelectionRepository = get()
private val optionsRepository: PatchOptionsRepository = get()
private val pluginsRepository: DownloaderPluginRepository = get()
private val installedAppRepository: InstalledAppRepository = get()
private val rootInstaller: RootInstaller = get()
private val pm: PM = get()
private val savedStateHandle: SavedStateHandle = get()
val prefs: PreferencesManager = get()
val plugins = pluginsRepository.loadedPluginsFlow
val desiredVersion = input.app.version
val packageName = input.app.packageName
private val persistConfiguration = input.patches == null
val hasRoot = rootInstaller.hasRootAccess()
var installedAppData: Pair<SelectedApp.Installed, InstalledApp?>? by mutableStateOf(null)
private set
private var _selectedApp by savedStateHandle.saveable {
mutableStateOf(input.app)
}
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
private set
var selectedApp
get() = _selectedApp
set(value) {
@ -52,10 +93,27 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
invalidateSelectedAppInfo()
}
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
init {
invalidateSelectedAppInfo()
viewModelScope.launch(Dispatchers.Main) {
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val installedAppDeferred =
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
installedAppData =
packageInfo.await()?.let {
SelectedApp.Installed(
packageName,
it.versionName!!
) to installedAppDeferred.await()
}
}
}
val requiredVersion = combine(prefs.suggestedVersionSafeguard.flow, bundleRepository.suggestedVersions) { suggestedVersionSafeguard, suggestedVersions ->
if (!suggestedVersionSafeguard) return@combine null
suggestedVersions[input.app.packageName]
}
var options: Options by savedStateHandle.saveable {
@ -64,9 +122,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
val packageName =
selectedApp.packageName // Accessing this from another thread may cause crashes.
state.value = withContext(Dispatchers.Default) {
val bundlePatches = bundleRepository.bundles.first()
.mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } }
@ -89,7 +144,7 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
viewModelScope.launch {
if (!prefs.disableSelectionWarning.get()) return@launch
val previous = selectionRepository.getSelection(selectedApp.packageName)
val previous = selectionRepository.getSelection(packageName)
if (previous.values.sumOf { it.size } == 0) return@launch
selection.value = SelectionState.Customized(previous)
}
@ -97,11 +152,102 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
selection
}
var showSourceSelector by mutableStateOf(false)
private set
private var pluginAction: Pair<LoadedDownloaderPlugin, Job>? by mutableStateOf(null)
val activePluginAction get() = pluginAction?.first?.packageName
private var launchedActivity by mutableStateOf<CompletableDeferred<ActivityResult>?>(null)
private val launchActivityChannel = Channel<Intent>()
val launchActivityFlow = launchActivityChannel.receiveAsFlow()
val errorFlow = combine(plugins, snapshotFlow { selectedApp }) { pluginsList, app ->
when {
app is SelectedApp.Search && pluginsList.isEmpty() -> Error.NoPlugins
else -> null
}
}
fun showSourceSelector() {
dismissSourceSelector()
showSourceSelector = true
}
private fun cancelPluginAction() {
pluginAction?.second?.cancel()
pluginAction = null
}
fun dismissSourceSelector() {
cancelPluginAction()
showSourceSelector = false
}
fun searchUsingPlugin(plugin: LoadedDownloaderPlugin) {
cancelPluginAction()
pluginAction = plugin to viewModelScope.launch {
try {
val scope = object : GetScope {
override val hostPackageName = app.packageName
override val pluginPackageName = plugin.packageName
override suspend fun requestStartActivity(intent: Intent) =
withContext(Dispatchers.Main) {
if (launchedActivity != null) error("Previous activity has not finished")
try {
val result = with(CompletableDeferred<ActivityResult>()) {
launchedActivity = this
launchActivityChannel.send(intent)
await()
}
when (result.resultCode) {
Activity.RESULT_OK -> result.data
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
else -> throw UserInteractionException.Activity.NotCompleted(
result.resultCode,
result.data
)
}
} finally {
launchedActivity = null
}
}
}
withContext(Dispatchers.IO) {
plugin.get(scope, packageName, desiredVersion)
}?.let { (data, version) ->
if (desiredVersion != null && version != desiredVersion) {
app.toast(app.getString(R.string.downloader_invalid_version))
return@launch
}
selectedApp = SelectedApp.Download(
packageName,
version,
ParceledDownloaderData(plugin, data)
)
} ?: app.toast(app.getString(R.string.downloader_app_not_found))
} catch (e: UserInteractionException.Activity) {
app.toast(e.message!!)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
app.toast(app.getString(R.string.downloader_error, e.simpleMessage()))
Log.e(tag, "Downloader.get threw an exception", e)
} finally {
pluginAction = null
dismissSourceSelector()
}
}
}
fun handlePluginActivityResult(result: ActivityResult) {
launchedActivity?.complete(result)
}
private fun invalidateSelectedAppInfo() = viewModelScope.launch {
val info = when (val app = selectedApp) {
is SelectedApp.Download -> null
is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) }
is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) }
else -> null
}
selectedAppInfo = info
@ -129,8 +275,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
this.options = filteredOptions
if (!persistConfiguration) return
val packageName = selectedApp.packageName
viewModelScope.launch(Dispatchers.Default) {
selection?.let { selectionRepository.updateSelection(packageName, it) }
?: selectionRepository.clearSelection(packageName)
@ -144,6 +288,10 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
val patches: PatchSelection?,
)
enum class Error(@StringRes val resourceId: Int) {
NoPlugins(R.string.downloader_no_plugins_available)
}
private companion object {
/**
* Returns a copy with all nonexistent options removed.

View File

@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@ -19,16 +18,10 @@ import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.api.ReVancedAPI.Extensions.findAssetByType
import app.revanced.manager.network.dto.ReVancedRelease
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.service.HttpService
import app.revanced.manager.network.utils.getOrThrow
import app.revanced.manager.service.InstallService
import app.revanced.manager.service.UninstallService
import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.PM
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import io.ktor.client.plugins.onDownload
@ -38,7 +31,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
class UpdateViewModel(
private val downloadOnScreenEntry: Boolean
@ -65,23 +57,14 @@ class UpdateViewModel(
var installError by mutableStateOf("")
var changelog: Changelog? by mutableStateOf(null)
var releaseInfo: ReVancedAsset? by mutableStateOf(null)
private set
private val location = fs.tempDir.resolve("updater.apk")
private var release: ReVancedRelease? = null
private val job = viewModelScope.launch {
uiSafe(app, R.string.download_manager_failed, "Failed to download ReVanced Manager") {
withContext(Dispatchers.IO) {
val response = reVancedAPI.getAppUpdate() ?: throw Exception("No update available")
releaseInfo = reVancedAPI.getAppUpdate() ?: throw Exception("No update available")
release = response
changelog = Changelog(
response.version,
response.findAssetByType(APK_MIMETYPE).downloadCount,
response.metadata.publishedAt,
response.metadata.body
)
}
if (downloadOnScreenEntry) {
downloadUpdate()
} else {
@ -92,16 +75,15 @@ class UpdateViewModel(
fun downloadUpdate(ignoreInternetCheck: Boolean = false) = viewModelScope.launch {
uiSafe(app, R.string.failed_to_download_update, "Failed to download update") {
val release = releaseInfo!!
withContext(Dispatchers.IO) {
if (!networkInfo.isSafe() && !ignoreInternetCheck) {
showInternetCheckDialog = true
} else {
state = State.DOWNLOADING
val asset = release?.findAssetByType(APK_MIMETYPE)
?: throw Exception("couldn't find asset to download")
http.download(location) {
url(asset.downloadUrl)
url(release.downloadUrl)
onDownload { bytesSentTotal, contentLength ->
downloadedSize = bytesSentTotal
totalSize = contentLength
@ -153,13 +135,6 @@ class UpdateViewModel(
location.delete()
}
data class Changelog(
val version: String,
val downloadCount: Int,
val publishDate: String,
val body: String,
)
enum class State(@StringRes val title: Int, val showCancel: Boolean = false) {
CAN_DOWNLOAD(R.string.update_available),
DOWNLOADING(R.string.downloading_manager_update, true),

View File

@ -1,173 +0,0 @@
package app.revanced.manager.ui.viewmodel
import android.content.pm.PackageInfo
import android.util.Log
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.domain.installer.RootInstaller
import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.network.downloader.APKMirror
import app.revanced.manager.network.downloader.AppDownloader
import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM
import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
class VersionSelectorViewModel(
val packageName: String
) : ViewModel(), KoinComponent {
private val downloadedAppRepository: DownloadedAppRepository by inject()
private val installedAppRepository: InstalledAppRepository by inject()
private val patchBundleRepository: PatchBundleRepository by inject()
private val pm: PM by inject()
private val prefs: PreferencesManager by inject()
private val appDownloader: AppDownloader = APKMirror()
val rootInstaller: RootInstaller by inject()
var installedApp: Pair<PackageInfo, InstalledApp?>? by mutableStateOf(null)
private set
var isLoading by mutableStateOf(true)
private set
var errorMessage: String? by mutableStateOf(null)
private set
var requiredVersion: String? by mutableStateOf(null)
private set
var selectedVersion: SelectedApp? by mutableStateOf(null)
private set
private var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp?>(null)
val showNonSuggestedVersionDialog by derivedStateOf { nonSuggestedVersionDialogSubject != null }
private val requiredVersionAsync = viewModelScope.async(Dispatchers.Default) {
if (!prefs.suggestedVersionSafeguard.get()) return@async null
patchBundleRepository.suggestedVersions.first()[packageName]
}
val supportedVersions = patchBundleRepository.bundles.map supportedVersions@{ bundles ->
requiredVersionAsync.await()?.let { version ->
// It is mandatory to use the suggested version if the safeguard is enabled.
return@supportedVersions mapOf(
version to bundles
.asSequence()
.flatMap { (_, bundle) -> bundle.patches }
.flatMap { it.compatiblePackages.orEmpty() }
.filter { it.packageName == packageName }
.count { it.versions.isNullOrEmpty() || version in it.versions }
)
}
var patchesWithoutVersions = 0
bundles.flatMap { (_, bundle) ->
bundle.patches.flatMap { patch ->
patch.compatiblePackages.orEmpty()
.filter { it.packageName == packageName }
.onEach { if (it.versions == null) patchesWithoutVersions++ }
.flatMap { it.versions.orEmpty() }
}
}.groupingBy { it }
.eachCount()
.toMutableMap()
.apply {
replaceAll { _, count ->
count + patchesWithoutVersions
}
}
}.flowOn(Dispatchers.Default)
init {
viewModelScope.launch {
requiredVersion = requiredVersionAsync.await()
}
}
val downloadableVersions = mutableStateSetOf<SelectedApp.Download>()
val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps ->
downloadedApps.filter { it.packageName == packageName }.map {
SelectedApp.Local(
it.packageName,
it.version,
downloadedAppRepository.getApkFileForApp(it),
false
)
}
}
init {
viewModelScope.launch(Dispatchers.Main) {
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val installedAppDeferred =
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
installedApp =
packageInfo.await()?.let {
it to installedAppDeferred.await()
}
}
viewModelScope.launch(Dispatchers.IO) {
try {
val compatibleVersions = supportedVersions.first()
appDownloader.getAvailableVersions(
packageName,
compatibleVersions.keys
).collect {
if (it.version in compatibleVersions || compatibleVersions.isEmpty()) {
downloadableVersions.add(
SelectedApp.Download(
packageName,
it.version,
it
)
)
}
}
withContext(Dispatchers.Main) {
isLoading = false
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Log.e(tag, "Failed to load apps", e)
errorMessage = e.simpleMessage()
}
}
}
}
fun dismissNonSuggestedVersionDialog() {
nonSuggestedVersionDialogSubject = null
}
fun select(app: SelectedApp) {
if (requiredVersion != null && app.version != requiredVersion) {
nonSuggestedVersionDialogSubject = app
return
}
selectedVersion = app
}
}

View File

@ -1,14 +1,8 @@
package app.revanced.manager.util
private const val team = "revanced"
const val ghOrganization = "https://github.com/$team"
const val ghCli = "$team/revanced-cli"
const val ghPatches = "$team/revanced-patches"
const val ghPatcher = "$team/revanced-patcher"
const val ghManager = "$team/revanced-manager"
const val ghIntegrations = "$team/revanced-integrations"
const val tag = "ReVanced Manager"
const val JAR_MIMETYPE = "application/java-archive"
const val APK_MIMETYPE = "application/vnd.android.package-archive"
const val JSON_MIMETYPE = "application/json"
const val JSON_MIMETYPE = "application/json"
const val BIN_MIMETYPE = "application/octet-stream"

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