feat: Add downloader plugin system (#2041)

This commit is contained in:
Ax333l 2024-12-19 21:41:04 +01:00 committed by GitHub
parent 9dc716b1c8
commit 2ec1c0238d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 2984 additions and 1105 deletions

View File

@ -113,9 +113,10 @@ dependencies {
implementation(libs.runtime.ktx) implementation(libs.runtime.ktx)
implementation(libs.runtime.compose) implementation(libs.runtime.compose)
implementation(libs.splash.screen) implementation(libs.splash.screen)
implementation(libs.compose.activity) implementation(libs.activity.compose)
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
implementation(libs.preferences.datastore) implementation(libs.preferences.datastore)
implementation(libs.appcompat)
// Compose // Compose
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
@ -155,6 +156,9 @@ dependencies {
implementation(libs.revanced.patcher) implementation(libs.revanced.patcher)
implementation(libs.revanced.library) implementation(libs.revanced.library)
// Downloader plugins
implementation(project(":downloader-plugin"))
// Native processes // Native processes
implementation(libs.kotlin.process) implementation(libs.kotlin.process)

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ package app.revanced.manager
import android.app.Application import android.app.Application
import app.revanced.manager.di.* import app.revanced.manager.di.*
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import coil.Coil import coil.Coil
@ -23,6 +24,8 @@ class ManagerApplication : Application() {
private val scope = MainScope() private val scope = MainScope()
private val prefs: PreferencesManager by inject() private val prefs: PreferencesManager by inject()
private val patchBundleRepository: PatchBundleRepository by inject() private val patchBundleRepository: PatchBundleRepository by inject()
private val downloaderPluginRepository: DownloaderPluginRepository by inject()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@ -59,6 +62,9 @@ class ManagerApplication : Application() {
scope.launch { scope.launch {
prefs.preload() prefs.preload()
} }
scope.launch(Dispatchers.Default) {
downloaderPluginRepository.reload()
}
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
with(patchBundleRepository) { with(patchBundleRepository) {
reload() reload()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,8 +18,6 @@ class PreferencesManager(
val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT) val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT) val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
val preferSplits = booleanPreference("prefer_splits", false)
val firstLaunch = booleanPreference("first_launch", true) val firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false) val managerAutoUpdates = booleanPreference("manager_auto_updates", false)
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true) val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
@ -28,4 +26,6 @@ class PreferencesManager(
val disableSelectionWarning = booleanPreference("disable_selection_warning", false) val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false) val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false)
val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true) val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true)
val acknowledgedDownloaderPlugins = stringSetPreference("acknowledged_downloader_plugins", emptySet())
} }

View File

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

View File

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

View File

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

View File

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

@ -33,15 +33,14 @@ data class PatchInfo(
fun compatibleWith(packageName: String) = fun compatibleWith(packageName: String) =
compatiblePackages?.any { it.packageName == packageName } ?: true compatiblePackages?.any { it.packageName == packageName } ?: true
fun supportsVersion(packageName: String, versionName: String): Boolean { fun supports(packageName: String, versionName: String?): Boolean {
val packages = compatiblePackages ?: return true // Universal patch val packages = compatiblePackages ?: return true // Universal patch
return packages.any { pkg -> return packages.any { pkg ->
if (pkg.packageName != packageName) { if (pkg.packageName != packageName) return@any false
return@any false if (pkg.versions == null) return@any true
}
pkg.versions == null || pkg.versions.contains(versionName) versionName != null && versionName in pkg.versions
} }
} }

View File

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

View File

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

View File

@ -1,21 +1,16 @@
package app.revanced.manager.ui.component.bundle package app.revanced.manager.ui.component.bundle
import android.content.Intent
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
@ -26,7 +21,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ExceptionViewerDialog
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -119,7 +114,7 @@ fun BundleInformationDialog(
var showDialog by rememberSaveable { var showDialog by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
if (showDialog) BundleErrorViewerDialog( if (showDialog) ExceptionViewerDialog(
onDismiss = { showDialog = false }, onDismiss = { showDialog = false },
text = remember(it) { it.stackTraceToString() } text = remember(it) { it.stackTraceToString() }
) )
@ -149,60 +144,3 @@ fun BundleInformationDialog(
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BundleErrorViewerDialog(onDismiss: () -> Unit, text: String) {
val context = LocalContext.current
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true
)
) {
Scaffold(
topBar = {
BundleTopBar(
title = stringResource(R.string.bundle_error),
onBackClick = onDismiss,
backIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back)
)
},
actions = {
IconButton(
onClick = {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(
Intent.EXTRA_TEXT,
text
)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
}
) {
Icon(
Icons.Outlined.Share,
contentDescription = stringResource(R.string.share)
)
}
}
)
}
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier.padding(paddingValues)
) {
Text(text, modifier = Modifier.horizontalScroll(rememberScrollState()))
}
}
}
}

View File

@ -43,6 +43,7 @@ import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.model.State import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.Step import app.revanced.manager.ui.model.Step
import app.revanced.manager.ui.model.StepCategory import app.revanced.manager.ui.model.StepCategory
import java.util.Locale
import kotlin.math.floor import kotlin.math.floor
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt // Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
@ -134,7 +135,7 @@ fun SubStep(
name: String, name: String,
state: State, state: State,
message: String? = null, message: String? = null,
downloadProgress: Pair<Float, Float>? = null downloadProgress: Pair<Long, Long?>? = null
) { ) {
var messageExpanded by rememberSaveable { mutableStateOf(true) } var messageExpanded by rememberSaveable { mutableStateOf(true) }
@ -180,7 +181,7 @@ fun SubStep(
} else { } else {
downloadProgress?.let { (current, total) -> downloadProgress?.let { (current, total) ->
Text( Text(
"$current/$total MB", if (total != null) "${current.megaBytes}/${total.megaBytes} MB" else "${current.megaBytes} MB",
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.labelSmall
) )
} }
@ -199,7 +200,7 @@ fun SubStep(
} }
@Composable @Composable
fun StepIcon(state: State, progress: Pair<Float, Float>? = null, size: Dp) { fun StepIcon(state: State, progress: Pair<Long, Long?>? = null, size: Dp) {
val strokeWidth = Dp(floor(size.value / 10) + 1) val strokeWidth = Dp(floor(size.value / 10) + 1)
when (state) { when (state) {
@ -233,8 +234,15 @@ fun StepIcon(state: State, progress: Pair<Float, Float>? = null, size: Dp) {
contentDescription = description contentDescription = description
} }
}, },
progress = { progress?.let { (current, total) -> current / total } }, progress = {
progress?.let { (current, total) ->
if (total == null) return@let null
current / total
}?.toFloat()
},
strokeWidth = strokeWidth strokeWidth = strokeWidth
) )
} }
} }
private val Long.megaBytes get() = "%.1f".format(locale = Locale.ROOT, toDouble() / 1_000_000)

View File

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

View File

@ -22,9 +22,6 @@ sealed interface Destination : Parcelable {
@Parcelize @Parcelize
data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination
@Parcelize
data class VersionSelector(val packageName: String, val patchSelection: PatchSelection? = null) : Destination
@Parcelize @Parcelize
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination

View File

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

View File

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

View File

@ -19,5 +19,5 @@ data class Step(
val category: StepCategory, val category: StepCategory,
val state: State = State.WAITING, val state: State = State.WAITING,
val message: String? = null, val message: String? = null,
val downloadProgress: StateFlow<Pair<Float, Float>?>? = null val downloadProgress: StateFlow<Pair<Long, Long?>?>? = null
) )

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@ -49,17 +49,21 @@ enum class DashboardPage(
} }
@SuppressLint("BatteryLife") @SuppressLint("BatteryLife")
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
vm: DashboardViewModel = koinViewModel(), vm: DashboardViewModel = koinViewModel(),
onAppSelectorClick: () -> Unit, onAppSelectorClick: () -> Unit,
onSettingsClick: () -> Unit, onSettingsClick: () -> Unit,
onUpdateClick: () -> Unit, onUpdateClick: () -> Unit,
onDownloaderPluginClick: () -> Unit,
onAppClick: (InstalledApp) -> Unit onAppClick: (InstalledApp) -> Unit
) { ) {
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } } val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } }
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0) val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle(
false
)
val androidContext = LocalContext.current val androidContext = LocalContext.current
val composableScope = rememberCoroutineScope() val composableScope = rememberCoroutineScope()
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
@ -237,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 } else null
) )

View File

@ -41,13 +41,12 @@ import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.SegmentedButton import app.revanced.manager.ui.component.SegmentedButton
import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun InstalledAppInfoScreen( fun InstalledAppInfoScreen(
onPatchClick: (packageName: String, patchSelection: PatchSelection) -> Unit, onPatchClick: (packageName: String) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
viewModel: InstalledAppInfoViewModel viewModel: InstalledAppInfoViewModel
) { ) {
@ -134,9 +133,7 @@ fun InstalledAppInfoScreen(
icon = Icons.Outlined.Update, icon = Icons.Outlined.Update,
text = stringResource(R.string.repatch), text = stringResource(R.string.repatch),
onClick = { onClick = {
viewModel.appliedPatches?.let { onPatchClick(viewModel.installedApp.originalPackageName)
onPatchClick(viewModel.installedApp.originalPackageName, it)
}
}, },
enabled = viewModel.installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess() enabled = viewModel.installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess()
) )

View File

@ -2,6 +2,7 @@ package app.revanced.manager.ui.screen
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -38,6 +39,7 @@ import app.revanced.manager.ui.model.State
import app.revanced.manager.ui.model.StepCategory import app.revanced.manager.ui.model.StepCategory
import app.revanced.manager.ui.viewmodel.PatcherViewModel import app.revanced.manager.ui.viewmodel.PatcherViewModel
import app.revanced.manager.util.APK_MIMETYPE import app.revanced.manager.util.APK_MIMETYPE
import app.revanced.manager.util.EventEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -86,6 +88,38 @@ fun PatcherScreen(
if (vm.installerStatusDialogModel.packageInstallerStatus != null) if (vm.installerStatusDialogModel.packageInstallerStatus != null)
InstallerStatusDialog(vm.installerStatusDialogModel) InstallerStatusDialog(vm.installerStatusDialogModel)
val activityLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = vm::handleActivityResult
)
EventEffect(flow = vm.launchActivityFlow) { intent ->
activityLauncher.launch(intent)
}
vm.activityPromptDialog?.let { title ->
AlertDialog(
onDismissRequest = vm::rejectInteraction,
confirmButton = {
TextButton(
onClick = vm::allowInteraction
) {
Text(stringResource(R.string.continue_))
}
},
dismissButton = {
TextButton(
onClick = vm::rejectInteraction
) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(title) },
text = {
Text(stringResource(R.string.plugin_activity_dialog_body))
}
)
}
AppScaffold( AppScaffold(
topBar = { topBar = {
AppTopBar( AppTopBar(

View File

@ -1,6 +1,5 @@
package app.revanced.manager.ui.screen package app.revanced.manager.ui.screen
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
@ -49,7 +48,7 @@ import app.revanced.manager.util.isScrollingUp
import app.revanced.manager.util.transparentListItemColors import app.revanced.manager.util.transparentListItemColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PatchesSelectorScreen( fun PatchesSelectorScreen(
onSave: (PatchSelection?, Options) -> Unit, onSave: (PatchSelection?, Options) -> Unit,
@ -137,7 +136,7 @@ fun PatchesSelectorScreen(
if (vm.compatibleVersions.isNotEmpty()) if (vm.compatibleVersions.isNotEmpty())
UnsupportedPatchDialog( UnsupportedPatchDialog(
appVersion = vm.appVersion, appVersion = vm.appVersion ?: stringResource(R.string.any_version),
supportedVersions = vm.compatibleVersions, supportedVersions = vm.compatibleVersions,
onDismissRequest = vm::dismissDialogs onDismissRequest = vm::dismissDialogs
) )
@ -146,7 +145,7 @@ fun PatchesSelectorScreen(
} }
if (showUnsupportedPatchesDialog) if (showUnsupportedPatchesDialog)
UnsupportedPatchesDialog( UnsupportedPatchesDialog(
appVersion = vm.appVersion, appVersion = vm.appVersion ?: stringResource(R.string.any_version),
onDismissRequest = { showUnsupportedPatchesDialog = false } onDismissRequest = { showUnsupportedPatchesDialog = false }
) )
@ -275,7 +274,11 @@ fun PatchesSelectorScreen(
Scaffold( Scaffold(
topBar = { topBar = {
AppTopBar( AppTopBar(
title = stringResource(R.string.patches_selected, selectedPatchCount, availablePatchCount), title = stringResource(
R.string.patches_selected,
selectedPatchCount,
availablePatchCount
),
onBackClick = onBackClick, onBackClick = onBackClick,
actions = { actions = {
IconButton(onClick = vm::reset) { IconButton(onClick = vm::reset) {
@ -457,6 +460,7 @@ private fun PatchItem(
} }
} }
}, },
colors = transparentListItemColors
) )
@Composable @Composable

View File

@ -1,10 +1,14 @@
package app.revanced.manager.ui.screen package app.revanced.manager.ui.screen
import android.content.pm.PackageInfo import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.filled.AutoFixHigh import androidx.compose.material.icons.filled.AutoFixHigh
@ -19,22 +23,31 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.room.apps.installed.InstallType
import app.revanced.manager.data.room.apps.installed.InstalledApp
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppInfo import app.revanced.manager.ui.component.AppInfo
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.LoadingIndicator
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.destination.SelectedAppInfoDestination import app.revanced.manager.ui.destination.SelectedAppInfoDestination
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
import app.revanced.manager.util.EventEffect
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.enabled
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import app.revanced.manager.util.transparentListItemColors
import dev.olshevski.navigation.reimagined.* import dev.olshevski.navigation.reimagined.*
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SelectedAppInfoScreen( fun SelectedAppInfoScreen(
onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit, onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit,
@ -61,15 +74,41 @@ fun SelectedAppInfoScreen(
} }
} }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = vm::handlePluginActivityResult
)
EventEffect(flow = vm.launchActivityFlow) { intent ->
launcher.launch(intent)
}
val navController = val navController =
rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main) rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main)
NavBackHandler(controller = navController) NavBackHandler(controller = navController)
AnimatedNavHost(controller = navController) { destination -> AnimatedNavHost(controller = navController) { destination ->
val error by vm.errorFlow.collectAsStateWithLifecycle(null)
when (destination) { when (destination) {
is SelectedAppInfoDestination.Main -> SelectedAppInfoScreen( is SelectedAppInfoDestination.Main -> Scaffold(
onPatchClick = patchClick@{ topBar = {
AppTopBar(
title = stringResource(R.string.app_info),
onBackClick = onBackClick
)
},
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) { if (selectedPatchCount == 0) {
context.toast(context.getString(R.string.no_patches_selected)) context.toast(context.getString(R.string.no_patches_selected))
@ -80,8 +119,54 @@ fun SelectedAppInfoScreen(
patches, patches,
vm.getOptionsFiltered(bundles) vm.getOptionsFiltered(bundles)
) )
}, }
onPatchSelectorClick = { )
}
) { 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,
)
}
PageItem(
R.string.patch_selector_item,
stringResource(
R.string.patch_selector_item_description,
selectedPatchCount
),
onClick = {
navController.navigate( navController.navigate(
SelectedAppInfoDestination.PatchesSelector( SelectedAppInfoDestination.PatchesSelector(
vm.selectedApp, vm.selectedApp,
@ -92,25 +177,34 @@ fun SelectedAppInfoScreen(
vm.options vm.options
) )
) )
}, }
onVersionSelectorClick = { )
navController.navigate(SelectedAppInfoDestination.VersionSelector) PageItem(
}, R.string.apk_source_selector_item,
onBackClick = onBackClick, when (val app = vm.selectedApp) {
selectedPatchCount = selectedPatchCount, is SelectedApp.Search -> stringResource(R.string.apk_source_auto)
packageName = packageName, is SelectedApp.Installed -> stringResource(R.string.apk_source_installed)
version = version, is SelectedApp.Download -> stringResource(
packageInfo = vm.selectedAppInfo, R.string.apk_source_downloader,
plugins.find { it.packageName == app.data.pluginPackageName }?.name
?: app.data.pluginPackageName
) )
is SelectedAppInfoDestination.VersionSelector -> VersionSelectorScreen( is SelectedApp.Local -> stringResource(R.string.apk_source_local)
onBackClick = navController::pop,
onAppClick = {
vm.selectedApp = it
navController.pop()
}, },
viewModel = koinViewModel { parametersOf(packageName) } onClick = {
vm.showSourceSelector()
}
) )
error?.let {
Text(
stringResource(it.resourceId),
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 24.dp)
)
}
}
}
is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen( is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
onSave = { patches, options -> onSave = { patches, options ->
@ -132,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 = {
HapticExtendedFloatingActionButton(
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 @Composable
private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) { private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) {
ListItem( ListItem(
@ -216,3 +251,88 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
} }
) )
} }
@Composable
private fun AppSourceSelectorDialog(
plugins: List<LoadedDownloaderPlugin>,
installedApp: Pair<SelectedApp.Installed, InstalledApp?>?,
searchApp: SelectedApp.Search,
activeSearchJob: String?,
hasRoot: Boolean,
requiredVersion: String?,
onDismissRequest: () -> Unit,
onSelectPlugin: (LoadedDownloaderPlugin) -> Unit,
onSelect: (SelectedApp) -> Unit,
) {
val canSelect = activeSearchJob == null
AlertDialogExtended(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
},
title = { Text(stringResource(R.string.app_source_dialog_title)) },
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
text = {
LazyColumn {
item(key = "auto") {
val hasPlugins = plugins.isNotEmpty()
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && hasPlugins) { onSelect(searchApp) }
.enabled(hasPlugins),
headlineContent = { Text(stringResource(R.string.app_source_dialog_option_auto)) },
supportingContent = {
Text(
if (hasPlugins)
stringResource(R.string.app_source_dialog_option_auto_description)
else
stringResource(R.string.app_source_dialog_option_auto_unavailable)
)
},
colors = transparentListItemColors
)
}
installedApp?.let { (app, meta) ->
item(key = "installed") {
val (usable, text) = when {
// Mounted apps must be unpatched before patching, which cannot be done without root access.
meta?.installType == InstallType.MOUNT && !hasRoot -> false to stringResource(
R.string.app_source_dialog_option_installed_no_root
)
// Patching already patched apps is not allowed because patches expect unpatched apps.
meta?.installType == InstallType.DEFAULT -> false to stringResource(R.string.already_patched)
// Version does not match suggested version.
requiredVersion != null && app.version != requiredVersion -> false to stringResource(
R.string.app_source_dialog_option_installed_version_not_suggested,
app.version
)
else -> true to app.version
}
ListItem(
modifier = Modifier
.clickable(enabled = canSelect && usable) { onSelect(app) }
.enabled(usable),
headlineContent = { Text(stringResource(R.string.installed)) },
supportingContent = { Text(text) },
colors = transparentListItemColors
)
}
}
items(plugins, key = { "plugin_${it.packageName}" }) { plugin ->
ListItem(
modifier = Modifier.clickable(enabled = canSelect) { onSelectPlugin(plugin) },
headlineContent = { Text(plugin.name) },
trailingContent = (@Composable { LoadingIndicator() }).takeIf { activeSearchJob == plugin.packageName },
colors = transparentListItemColors
)
}
}
}
)
}

View File

@ -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.Icon
import androidx.compose.material3.ListItem
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.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticRadioButton
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 = {
HapticExtendedFloatingActionButton(
text = { Text(stringResource(R.string.select_version)) },
icon = {
Icon(
Icons.Default.Check,
stringResource(R.string.select_version)
)
},
expanded = lazyListState.isScrollingUp,
onClick = { viewModel.selectedVersion?.let(onAppClick) }
)
}
) { paddingValues ->
LazyColumnWithScrollbar(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
state = lazyListState
) {
viewModel.installedApp?.let { (packageInfo, installedApp) ->
SelectedApp.Installed(
packageName = viewModel.packageName,
version = packageInfo.versionName!!
).let {
item {
SelectedAppItem(
selectedApp = it,
selected = viewModel.selectedVersion == it,
onClick = { viewModel.select(it) },
patchCount = supportedVersions[it.version],
enabled =
!(installedApp?.installType == InstallType.MOUNT && !viewModel.rootInstaller.hasRootAccess()),
alreadyPatched = installedApp != null && installedApp.installType != InstallType.MOUNT
)
}
}
}
item {
Row(Modifier.fillMaxWidth()) {
GroupHeader(stringResource(R.string.downloadable_versions))
}
}
items(
items = list,
key = { it.version }
) {
SelectedAppItem(
selectedApp = it,
selected = viewModel.selectedVersion == it,
onClick = { viewModel.select(it) },
patchCount = supportedVersions[it.version]
)
}
if (viewModel.errorMessage != null) {
item {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.error_occurred))
Text(
text = viewModel.errorMessage!!,
modifier = Modifier.padding(horizontal = 15.dp)
)
}
}
} else if (viewModel.isLoading) {
item {
LoadingIndicator()
}
}
}
}
}
@Composable
fun SelectedAppItem(
selectedApp: SelectedApp,
selected: Boolean,
onClick: () -> Unit,
patchCount: Int?,
enabled: Boolean = true,
alreadyPatched: Boolean = false,
) {
ListItem(
leadingContent = { HapticRadioButton(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

@ -1,39 +1,60 @@
package app.revanced.manager.ui.screen.settings package app.revanced.manager.ui.screen.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold 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.Composable
import androidx.compose.runtime.getValue 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R 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.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.GroupHeader
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.DownloadsViewModel import app.revanced.manager.ui.viewmodel.DownloadsViewModel
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.security.MessageDigest
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class)
@Composable @Composable
fun DownloadsSettingsScreen( fun DownloadsSettingsScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
viewModel: DownloadsViewModel = koinViewModel() viewModel: DownloadsViewModel = koinViewModel()
) { ) {
val prefs = viewModel.prefs val pullRefreshState = rememberPullToRefreshState()
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(initialValue = emptyList()) val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { topBar = {
@ -41,8 +62,8 @@ fun DownloadsSettingsScreen(
title = stringResource(R.string.downloads), title = stringResource(R.string.downloads),
onBackClick = onBackClick, onBackClick = onBackClick,
actions = { actions = {
if (viewModel.selection.isNotEmpty()) { if (viewModel.appSelection.isNotEmpty()) {
IconButton(onClick = { viewModel.delete() }) { IconButton(onClick = { viewModel.deleteApps() }) {
Icon(Icons.Default.Delete, stringResource(R.string.delete)) Icon(Icons.Default.Delete, stringResource(R.string.delete))
} }
} }
@ -50,35 +71,178 @@ fun DownloadsSettingsScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
ColumnWithScrollbar( Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.padding(paddingValues)
.fillMaxWidth()
.zIndex(1f)
) {
PullToRefreshDefaults.Indicator(
state = pullRefreshState,
isRefreshing = viewModel.isRefreshingPlugins
)
}
LazyColumnWithScrollbar(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.pullToRefresh(
isRefreshing = viewModel.isRefreshingPlugins,
state = pullRefreshState,
onRefresh = viewModel::refreshPlugins
)
) { ) {
BooleanItem( item {
preference = prefs.preferSplits, GroupHeader(stringResource(R.string.downloader_plugins))
headline = R.string.prefer_splits, }
description = R.string.prefer_splits_description, pluginStates.forEach { (packageName, state) ->
item(key = packageName) {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
fun dismiss() {
showDialog = false
}
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()
}
) )
GroupHeader(stringResource(R.string.downloaded_apps)) is DownloaderPluginState.Failed -> ExceptionViewerDialog(
text = remember(state.throwable) {
state.throwable.stackTraceToString()
},
onDismiss = ::dismiss
)
downloadedApps.forEach { app -> is DownloaderPluginState.Untrusted -> TrustDialog(
val selected = app in viewModel.selection 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( SettingsListItem(
modifier = Modifier.clickable { viewModel.toggleItem(app) }, 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.toggleApp(app) },
headlineContent = app.packageName, headlineContent = app.packageName,
leadingContent = (@Composable { leadingContent = (@Composable {
HapticCheckbox( HapticCheckbox(
checked = selected, checked = selected,
onCheckedChange = { viewModel.toggleItem(app) } onCheckedChange = { viewModel.toggleApp(app) }
) )
}).takeIf { viewModel.selection.isNotEmpty() }, }).takeIf { viewModel.appSelection.isNotEmpty() },
supportingContent = app.version, supportingContent = app.version,
tonalElevation = if (selected) 8.dp else 0.dp 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

@ -14,7 +14,9 @@ import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@ -30,7 +32,8 @@ class AppSelectorViewModel(
} }
val appList = pm.appList 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) val suggestedAppVersions = patchBundleRepository.suggestedVersions.flowOn(Dispatchers.Default)
@ -54,7 +57,7 @@ class AppSelectorViewModel(
} }
if (patchBundleRepository.isVersionAllowed(selectedApp.packageName, selectedApp.version)) { if (patchBundleRepository.isVersionAllowed(selectedApp.packageName, selectedApp.version)) {
onStorageClick(selectedApp) storageSelectionChannel.send(selectedApp)
} else { } else {
nonSuggestedVersionDialogSubject = selectedApp nonSuggestedVersionDialogSubject = selectedApp
} }

View File

@ -17,6 +17,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloaderPluginRepository
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
@ -28,6 +29,7 @@ import kotlinx.coroutines.launch
class DashboardViewModel( class DashboardViewModel(
private val app: Application, private val app: Application,
private val patchBundleRepository: PatchBundleRepository, private val patchBundleRepository: PatchBundleRepository,
private val downloaderPluginRepository: DownloaderPluginRepository,
private val reVancedAPI: ReVancedAPI, private val reVancedAPI: ReVancedAPI,
private val networkInfo: NetworkInfo, private val networkInfo: NetworkInfo,
val prefs: PreferencesManager val prefs: PreferencesManager
@ -39,6 +41,8 @@ class DashboardViewModel(
val sources = patchBundleRepository.sources val sources = patchBundleRepository.sources
val selectedSources = mutableStateListOf<PatchBundleSource>() val selectedSources = mutableStateListOf<PatchBundleSource>()
val newDownloaderPluginsAvailable = downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() }
var updatedManagerVersion: String? by mutableStateOf(null) var updatedManagerVersion: String? by mutableStateOf(null)
private set private set
var showBatteryOptimizationsWarning by mutableStateOf(false) var showBatteryOptimizationsWarning by mutableStateOf(false)
@ -52,6 +56,10 @@ class DashboardViewModel(
} }
} }
fun ignoreNewDownloaderPlugins() = viewModelScope.launch {
downloaderPluginRepository.acknowledgeAllNewPlugins()
}
fun dismissUpdateDialog() { fun dismissUpdateDialog() {
updatedManagerVersion = null updatedManagerVersion = null
} }

View File

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

@ -15,13 +15,17 @@ import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.DownloadedAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SerializedSelection 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.ui.theme.Theme
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -29,10 +33,40 @@ import kotlinx.serialization.json.Json
class MainViewModel( class MainViewModel(
private val patchBundleRepository: PatchBundleRepository, private val patchBundleRepository: PatchBundleRepository,
private val patchSelectionRepository: PatchSelectionRepository, private val patchSelectionRepository: PatchSelectionRepository,
private val downloadedAppRepository: DownloadedAppRepository,
private val keystoreManager: KeystoreManager, private val keystoreManager: KeystoreManager,
private val app: Application, private val app: Application,
val prefs: PreferencesManager val prefs: PreferencesManager
) : ViewModel() { ) : 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) { fun importLegacySettings(componentActivity: ComponentActivity) {
if (!prefs.firstLaunch.getBlocking()) return if (!prefs.firstLaunch.getBlocking()) return

View File

@ -8,7 +8,9 @@ import android.content.IntentFilter
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import androidx.activity.result.ActivityResult
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -29,6 +31,8 @@ import app.revanced.manager.domain.worker.WorkerRepository
import app.revanced.manager.patcher.logger.LogLevel import app.revanced.manager.patcher.logger.LogLevel
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.worker.PatcherWorker 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.InstallService
import app.revanced.manager.service.UninstallService import app.revanced.manager.service.UninstallService
import app.revanced.manager.ui.component.InstallerStatusDialogModel import app.revanced.manager.ui.component.InstallerStatusDialogModel
@ -42,11 +46,14 @@ import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.time.withTimeout import kotlinx.coroutines.time.withTimeout
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -58,6 +65,7 @@ import java.time.Duration
import java.util.UUID import java.util.UUID
@Stable @Stable
@OptIn(PluginHostApi::class)
class PatcherViewModel( class PatcherViewModel(
private val input: Destination.Patcher private val input: Destination.Patcher
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
@ -68,7 +76,8 @@ class PatcherViewModel(
private val installedAppRepository: InstalledAppRepository by inject() private val installedAppRepository: InstalledAppRepository by inject()
private val rootInstaller: RootInstaller by inject() private val rootInstaller: RootInstaller by inject()
val installerStatusDialogModel : InstallerStatusDialogModel = object : InstallerStatusDialogModel { val installerStatusDialogModel: InstallerStatusDialogModel =
object : InstallerStatusDialogModel {
override var packageInstallerStatus: Int? by mutableStateOf(null) override var packageInstallerStatus: Int? by mutableStateOf(null)
override fun reinstall() { override fun reinstall() {
@ -89,6 +98,13 @@ class PatcherViewModel(
var isInstalling by mutableStateOf(false) var isInstalling by mutableStateOf(false)
private set 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 = fs.tempDir.resolve("installer").also { private val tempDir = fs.tempDir.resolve("installer").also {
it.deleteRecursively() it.deleteRecursively()
it.mkdirs() it.mkdirs()
@ -109,7 +125,7 @@ class PatcherViewModel(
} }
val patchesProgress = MutableStateFlow(Pair(0, input.selectedPatches.values.sumOf { it.size })) val patchesProgress = MutableStateFlow(Pair(0, input.selectedPatches.values.sumOf { it.size }))
private val downloadProgress = MutableStateFlow<Pair<Float, Float>?>(null) private val downloadProgress = MutableStateFlow<Pair<Long, Long?>?>(null)
val steps = generateSteps( val steps = generateSteps(
app, app,
input.selectedApp, input.selectedApp,
@ -130,6 +146,33 @@ class PatcherViewModel(
downloadProgress, downloadProgress,
patchesProgress, patchesProgress,
setInputFile = { inputFile = it }, setInputFile = { 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 -> onProgress = { name, state, message ->
viewModelScope.launch { viewModelScope.launch {
steps[currentStepIndex] = steps[currentStepIndex].run { steps[currentStepIndex] = steps[currentStepIndex].run {
@ -173,13 +216,15 @@ class PatcherViewModel(
?.let(logger::trace) ?.let(logger::trace)
if (pmStatus == PackageInstaller.STATUS_SUCCESS) { if (pmStatus == PackageInstaller.STATUS_SUCCESS) {
app.toast(app.getString(R.string.install_app_success))
installedPackageName = installedPackageName =
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME) intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME)
viewModelScope.launch { viewModelScope.launch {
installedAppRepository.addOrUpdate( installedAppRepository.addOrUpdate(
installedPackageName!!, installedPackageName!!,
packageName, packageName,
input.selectedApp.version, input.selectedApp.version
?: pm.getPackageInfo(outputFile)?.versionName!!,
InstallType.DEFAULT, InstallType.DEFAULT,
input.selectedPatches input.selectedPatches
) )
@ -245,6 +290,18 @@ class PatcherViewModel(
fun isDeviceRooted() = rootInstaller.isDeviceRooted() 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 { fun export(uri: Uri?) = viewModelScope.launch {
uri?.let { uri?.let {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@ -285,7 +342,8 @@ class PatcherViewModel(
// Check if the app version is less than the installed version // Check if the app version is less than the installed version
if (pm.getVersionCode(currentPackageInfo) < pm.getVersionCode(existingPackageInfo)) { if (pm.getVersionCode(currentPackageInfo) < pm.getVersionCode(existingPackageInfo)) {
// Exit if the selected app version is less than the installed version // Exit if the selected app version is less than the installed version
installerStatusDialogModel.packageInstallerStatus = PackageInstaller.STATUS_FAILURE_CONFLICT installerStatusDialogModel.packageInstallerStatus =
PackageInstaller.STATUS_FAILURE_CONFLICT
return@launch return@launch
} }
} }
@ -305,6 +363,11 @@ class PatcherViewModel(
InstallType.MOUNT -> { InstallType.MOUNT -> {
try { 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 // Check for base APK, first check if the app is already installed
if (existingPackageInfo == null) { if (existingPackageInfo == null) {
// If the app is not installed, check if the output file is a base apk // If the app is not installed, check if the output file is a base apk
@ -316,24 +379,23 @@ class PatcherViewModel(
} }
} }
// Get label val inputVersion = input.selectedApp.version
val label = with(pm) { ?: inputFile?.let(pm::getPackageInfo)?.versionName
currentPackageInfo.label() ?: throw Exception("Failed to determine input APK version")
}
// Install as root // Install as root
rootInstaller.install( rootInstaller.install(
outputFile, outputFile,
inputFile, inputFile,
packageName, packageName,
input.selectedApp.version, inputVersion,
label label
) )
installedAppRepository.addOrUpdate( installedAppRepository.addOrUpdate(
packageInfo.packageName,
packageName, packageName,
packageName, inputVersion,
input.selectedApp.version,
InstallType.MOUNT, InstallType.MOUNT,
input.selectedPatches input.selectedPatches
) )
@ -385,9 +447,10 @@ class PatcherViewModel(
fun generateSteps( fun generateSteps(
context: Context, context: Context,
selectedApp: SelectedApp, selectedApp: SelectedApp,
downloadProgress: StateFlow<Pair<Float, Float>?>? = null downloadProgress: StateFlow<Pair<Long, Long?>?>? = null
): List<Step> { ): List<Step> {
val needsDownload = selectedApp is SelectedApp.Download val needsDownload =
selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search
return listOfNotNull( return listOfNotNull(
Step( Step(

View File

@ -1,50 +1,91 @@
package app.revanced.manager.ui.viewmodel 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.content.pm.PackageInfo
import android.os.Parcelable 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.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable 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.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.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchOptionsRepository import app.revanced.manager.domain.repository.PatchOptionsRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository 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
import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchSelection 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.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.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
@OptIn(SavedStateHandleSaveableApi::class) @OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent { class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
private val app: Application = get()
val bundlesRepo: PatchBundleRepository = get() val bundlesRepo: PatchBundleRepository = get()
private val bundleRepository: PatchBundleRepository = get() private val bundleRepository: PatchBundleRepository = get()
private val selectionRepository: PatchSelectionRepository = get() private val selectionRepository: PatchSelectionRepository = get()
private val optionsRepository: PatchOptionsRepository = 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 pm: PM = get()
private val savedStateHandle: SavedStateHandle = get() private val savedStateHandle: SavedStateHandle = get()
val prefs: PreferencesManager = 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 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 { private var _selectedApp by savedStateHandle.saveable {
mutableStateOf(input.app) mutableStateOf(input.app)
} }
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
private set
var selectedApp var selectedApp
get() = _selectedApp get() = _selectedApp
set(value) { set(value) {
@ -52,10 +93,27 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
invalidateSelectedAppInfo() invalidateSelectedAppInfo()
} }
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
init { init {
invalidateSelectedAppInfo() 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 { var options: Options by savedStateHandle.saveable {
@ -64,9 +122,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
viewModelScope.launch { viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps. 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) { state.value = withContext(Dispatchers.Default) {
val bundlePatches = bundleRepository.bundles.first() val bundlePatches = bundleRepository.bundles.first()
.mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } } .mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } }
@ -89,7 +144,7 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
viewModelScope.launch { viewModelScope.launch {
if (!prefs.disableSelectionWarning.get()) return@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 if (previous.values.sumOf { it.size } == 0) return@launch
selection.value = SelectionState.Customized(previous) selection.value = SelectionState.Customized(previous)
} }
@ -97,11 +152,102 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
selection 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 { private fun invalidateSelectedAppInfo() = viewModelScope.launch {
val info = when (val app = selectedApp) { val info = when (val app = selectedApp) {
is SelectedApp.Download -> null
is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) } is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) }
is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) } is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) }
else -> null
} }
selectedAppInfo = info selectedAppInfo = info
@ -129,8 +275,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
this.options = filteredOptions this.options = filteredOptions
if (!persistConfiguration) return if (!persistConfiguration) return
val packageName = selectedApp.packageName
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
selection?.let { selectionRepository.updateSelection(packageName, it) } selection?.let { selectionRepository.updateSelection(packageName, it) }
?: selectionRepository.clearSelection(packageName) ?: selectionRepository.clearSelection(packageName)
@ -144,6 +288,10 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
val patches: PatchSelection?, val patches: PatchSelection?,
) )
enum class Error(@StringRes val resourceId: Int) {
NoPlugins(R.string.downloader_no_plugins_available)
}
private companion object { private companion object {
/** /**
* Returns a copy with all nonexistent options removed. * Returns a copy with all nonexistent options removed.

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

@ -8,9 +8,10 @@ import android.content.Intent
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.MATCH_UNINSTALLED_PACKAGES import android.content.pm.PackageManager.PackageInfoFlags
import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.PackageManager.NameNotFoundException
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import android.content.pm.Signature
import android.os.Build import android.os.Build
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@ -37,7 +38,6 @@ data class AppInfo(
) : Parcelable ) : Parcelable
@SuppressLint("QueryPermissionsNeeded") @SuppressLint("QueryPermissionsNeeded")
@Suppress("DEPRECATION")
class PM( class PM(
private val app: Application, private val app: Application,
patchBundleRepository: PatchBundleRepository patchBundleRepository: PatchBundleRepository
@ -68,7 +68,7 @@ class PM(
} }
val installedApps = scope.async { val installedApps = scope.async {
app.packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES).map { packageInfo -> getInstalledPackages().map { packageInfo ->
AppInfo( AppInfo(
packageInfo.packageName, packageInfo.packageName,
0, 0,
@ -94,9 +94,24 @@ class PM(
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
fun getPackageInfo(packageName: String): PackageInfo? = private fun getInstalledPackages(flags: Int = 0): List<PackageInfo> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
app.packageManager.getInstalledPackages(PackageInfoFlags.of(flags.toLong()))
else
app.packageManager.getInstalledPackages(flags)
fun getPackagesWithFeature(feature: String) =
getInstalledPackages(PackageManager.GET_CONFIGURATIONS)
.filter { pkg ->
pkg.reqFeatures?.any { it.name == feature } ?: false
}
fun getPackageInfo(packageName: String, flags: Int = 0): PackageInfo? =
try { try {
app.packageManager.getPackageInfo(packageName, 0) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
app.packageManager.getPackageInfo(packageName, PackageInfoFlags.of(flags.toLong()))
else
app.packageManager.getPackageInfo(packageName, flags)
} catch (e: NameNotFoundException) { } catch (e: NameNotFoundException) {
null null
} }
@ -118,6 +133,18 @@ class PM(
fun getVersionCode(packageInfo: PackageInfo) = PackageInfoCompat.getLongVersionCode(packageInfo) fun getVersionCode(packageInfo: PackageInfo) = PackageInfoCompat.getLongVersionCode(packageInfo)
fun getSignature(packageName: String): Signature =
// Get the last signature from the list because we want the newest one if SigningInfo.getSigningCertificateHistory() was used.
PackageInfoCompat.getSignatures(app.packageManager, packageName).last()
@SuppressLint("InlinedApi")
fun hasSignature(packageName: String, signature: ByteArray) = PackageInfoCompat.hasSignatures(
app.packageManager,
packageName,
mapOf(signature to PackageManager.CERT_INPUT_RAW_X509),
false
)
suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) { suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) {
val packageInstaller = app.packageManager.packageInstaller val packageInstaller = app.packageManager.packageInstaller
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session -> packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session ->

View File

@ -12,17 +12,22 @@ import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import app.revanced.manager.R import app.revanced.manager.R
@ -168,6 +173,30 @@ fun LocalDateTime.relativeTime(context: Context): String {
} }
} }
private var transparentListItemColorsCached: ListItemColors? = null
/**
* The default ListItem colors, but with [ListItemColors.containerColor] set to [Color.Transparent].
*/
val transparentListItemColors
@Composable get() = transparentListItemColorsCached
?: ListItemDefaults.colors(containerColor = Color.Transparent)
.also { transparentListItemColorsCached = it }
@Composable
fun <T> EventEffect(flow: Flow<T>, vararg keys: Any?, state: Lifecycle.State = Lifecycle.State.STARTED, block: suspend (T) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val currentBlock by rememberUpdatedState(block)
LaunchedEffect(flow, state, *keys) {
lifecycleOwner.repeatOnLifecycle(state) {
flow.collect {
currentBlock(it)
}
}
}
}
const val isScrollingUpSensitivity = 10 const val isScrollingUpSensitivity = 10
@Composable @Composable
@ -231,12 +260,4 @@ fun <T, R> ((T) -> R).withHapticFeedback(constant: Int): (T) -> R {
} }
} }
private var transparentListItemColorsCached: ListItemColors? = null fun Modifier.enabled(condition: Boolean) = if (condition) this else alpha(0.5f)
/**
* The default ListItem colors, but with [ListItemColors.containerColor] set to [Color.Transparent].
*/
val transparentListItemColors
@Composable get() = transparentListItemColorsCached
?: ListItemDefaults.colors(containerColor = Color.Transparent)
.also { transparentListItemColorsCached = it }

View File

@ -5,6 +5,9 @@
<string name="cli">CLI</string> <string name="cli">CLI</string>
<string name="manager">Manager</string> <string name="manager">Manager</string>
<string name="plugin_host_permission_label">ReVanced Manager plugin host</string>
<string name="plugin_host_permission_description">Used to control access to ReVanced Manager plugins. Only ReVanced Manager has this.</string>
<string name="toast_copied_to_clipboard">Copied!</string> <string name="toast_copied_to_clipboard">Copied!</string>
<string name="copy_to_clipboard">Copy to clipboard</string> <string name="copy_to_clipboard">Copy to clipboard</string>
@ -13,6 +16,7 @@
<string name="select_app">Select an app</string> <string name="select_app">Select an app</string>
<string name="patches_selected">%1$d/%2$d selected</string> <string name="patches_selected">%1$d/%2$d selected</string>
<string name="new_downloader_plugins_notification">New downloader plugins available. Click here to configure them.</string>
<string name="unsupported_architecture_warning">Patching on this device architecture is unsupported and will most likely fail.</string> <string name="unsupported_architecture_warning">Patching on this device architecture is unsupported and will most likely fail.</string>
<string name="import_">Import</string> <string name="import_">Import</string>
@ -31,15 +35,24 @@
<string name="bundle_name_default">Default</string> <string name="bundle_name_default">Default</string>
<string name="bundle_name_fallback">Unnamed</string> <string name="bundle_name_fallback">Unnamed</string>
<string name="selected_app_meta">%1$s</string> <string name="selected_app_meta_any_version">Any available version</string>
<string name="app_source_dialog_title">Select source</string>
<string name="app_source_dialog_option_auto">Auto</string>
<string name="app_source_dialog_option_auto_description">Use all installed downloaders to find a suitable APK file</string>
<string name="app_source_dialog_option_auto_unavailable">No plugins available</string>
<string name="app_source_dialog_option_installed_no_root">Mounted apps cannot be patched again without root access</string>
<string name="app_source_dialog_option_installed_version_not_suggested">Version %s does not match the suggested version</string>
<string name="patch_item_description">Start patching the application</string> <string name="patch_item_description">Start patching the application</string>
<string name="patch_selector_item">Patch selection and options</string> <string name="patch_selector_item">Patch selection and options</string>
<string name="patch_selector_item_description">%d patches selected</string> <string name="patch_selector_item_description">%d patches selected</string>
<string name="no_patches_selected">No patches selected</string> <string name="no_patches_selected">No patches selected</string>
<string name="version_selector_item">Change version</string> <string name="apk_source_selector_item">Change source</string>
<string name="version_selector_item_description">%s selected</string> <string name="apk_source_auto">Current: All downloaders</string>
<string name="apk_source_downloader">Current: %s</string>
<string name="apk_source_installed">Current: Installed</string>
<string name="apk_source_local">Current: File</string>
<string name="legacy_import_failed">Could not import legacy settings</string> <string name="legacy_import_failed">Could not import legacy settings</string>
@ -111,10 +124,14 @@
<string name="patch_options_reset_bundle_description">Resets patch options for all patches in a bundle</string> <string name="patch_options_reset_bundle_description">Resets patch options for all patches in a bundle</string>
<string name="patch_options_reset_all">Reset patch options</string> <string name="patch_options_reset_all">Reset patch options</string>
<string name="patch_options_reset_all_description">Resets all patch options</string> <string name="patch_options_reset_all_description">Resets all patch options</string>
<string name="prefer_splits">Prefer split APK\'s</string> <string name="downloader_plugins">Plugins</string>
<string name="prefer_splits_description">Prefer split APK\'s instead of full APK\'s</string> <string name="downloader_plugin_state_trusted">Trusted</string>
<string name="prefer_universal">Prefer universal APK\'s</string> <string name="downloader_plugin_state_failed">Failed to load. Click for more details</string>
<string name="prefer_universal_description">Prefer universal instead of arch-specific APK\'s</string> <string name="downloader_plugin_state_untrusted">Untrusted</string>
<string name="downloader_plugin_trust_dialog_title">Trust plugin?</string>
<string name="downloader_plugin_revoke_trust_dialog_title">Revoke trust?</string>
<string name="downloader_plugin_trust_dialog_body">Package name: %1$s\nSignature (SHA-256): %2$s</string>
<string name="downloader_settings_no_apps">No downloaded apps found</string>
<string name="search_apps">Search apps…</string> <string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string> <string name="loading_body">Loading…</string>
@ -227,10 +244,11 @@
<string name="unpatch_app">Unpatch app?</string> <string name="unpatch_app">Unpatch app?</string>
<string name="unpatch_description">Are you sure you want to unpatch this app?</string> <string name="unpatch_description">Are you sure you want to unpatch this app?</string>
<string name="error_occurred">An error occurred</string> <string name="downloader_invalid_version">Downloader did not fetch the correct version</string>
<string name="already_downloaded">Already downloaded</string> <string name="downloader_app_not_found">Downloader did not find the app</string>
<string name="select_version">Select version</string> <string name="downloader_error">Downloader error: %s</string>
<string name="downloadable_versions">Downloadable versions</string> <string name="downloader_no_plugins_installed">No plugins installed.</string>
<string name="downloader_no_plugins_available">No trusted plugins available for use. Check your settings.</string>
<string name="already_patched">Already patched</string> <string name="already_patched">Already patched</string>
<string name="patch_selector_sheet_filter_title">Filter</string> <string name="patch_selector_sheet_filter_title">Filter</string>
@ -258,6 +276,7 @@
<string name="save_apk_success">APK Saved</string> <string name="save_apk_success">APK Saved</string>
<string name="sign_fail">Failed to sign APK: %s</string> <string name="sign_fail">Failed to sign APK: %s</string>
<string name="save_logs">Save logs</string> <string name="save_logs">Save logs</string>
<string name="plugin_activity_dialog_body">User interaction is required in order to proceed with this plugin.</string>
<string name="select_install_type">Select installation type</string> <string name="select_install_type">Select installation type</string>
<string name="patcher_step_group_preparing">Preparing</string> <string name="patcher_step_group_preparing">Preparing</string>
@ -383,6 +402,7 @@
<string name="auto_update">Auto update</string> <string name="auto_update">Auto update</string>
<string name="unsupported_patches_dialog">These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details.</string> <string name="unsupported_patches_dialog">These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details.</string>
<string name="unsupported_patch">Unsupported patch</string> <string name="unsupported_patch">Unsupported patch</string>
<string name="any_version">Any</string>
<string name="never_show_again">Never show again</string> <string name="never_show_again">Never show again</string>
<string name="show_manager_update_dialog_on_launch">Show update message on launch</string> <string name="show_manager_update_dialog_on_launch">Show update message on launch</string>
<string name="show_manager_update_dialog_on_launch_description">Shows a popup notification whenever there is a new update available on launch.</string> <string name="show_manager_update_dialog_on_launch_description">Shows a popup notification whenever there is a new update available on launch.</string>

View File

@ -4,5 +4,7 @@
<style name="Theme.ReVancedManager" parent="Theme.SplashScreen"> <style name="Theme.ReVancedManager" parent="Theme.SplashScreen">
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item> <item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
<item name="postSplashScreenTheme">@style/Theme.AppCompat.NoActionBar</item> <item name="postSplashScreenTheme">@style/Theme.AppCompat.NoActionBar</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style> </style>
</resources> </resources>

View File

@ -1,9 +1,16 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.devtools) apply false alias(libs.plugins.devtools) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.about.libraries) apply false alias(libs.plugins.about.libraries) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.binary.compatibility.validator)
}
apiValidation {
ignoredProjects.addAll(listOf("app", "example-downloader-plugin"))
nonPublicMarkers += "app.revanced.manager.plugin.downloader.PluginHostApi"
} }

1
downloader-plugin/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,171 @@
public abstract interface class app/revanced/manager/plugin/downloader/BaseDownloadScope : app/revanced/manager/plugin/downloader/Scope {
}
public final class app/revanced/manager/plugin/downloader/ConstantsKt {
public static final field PLUGIN_HOST_PERMISSION Ljava/lang/String;
}
public final class app/revanced/manager/plugin/downloader/DownloadUrl : android/os/Parcelable {
public static final field CREATOR Landroid/os/Parcelable$Creator;
public fun <init> (Ljava/lang/String;Ljava/util/Map;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/util/Map;
public final fun copy (Ljava/lang/String;Ljava/util/Map;)Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public static synthetic fun copy$default (Lapp/revanced/manager/plugin/downloader/DownloadUrl;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public final fun describeContents ()I
public fun equals (Ljava/lang/Object;)Z
public final fun getHeaders ()Ljava/util/Map;
public final fun getUrl ()Ljava/lang/String;
public fun hashCode ()I
public final fun toDownloadResult ()Lkotlin/Pair;
public fun toString ()Ljava/lang/String;
public final fun writeToParcel (Landroid/os/Parcel;I)V
}
public final class app/revanced/manager/plugin/downloader/DownloadUrl$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/DownloadUrl;
public synthetic fun newArray (I)[Ljava/lang/Object;
}
public final class app/revanced/manager/plugin/downloader/Downloader {
}
public final class app/revanced/manager/plugin/downloader/DownloaderBuilder {
}
public final class app/revanced/manager/plugin/downloader/DownloaderKt {
public static final fun Downloader (Lkotlin/jvm/functions/Function1;)Lapp/revanced/manager/plugin/downloader/DownloaderBuilder;
}
public final class app/revanced/manager/plugin/downloader/DownloaderScope : app/revanced/manager/plugin/downloader/Scope {
public final fun download (Lkotlin/jvm/functions/Function3;)V
public final fun get (Lkotlin/jvm/functions/Function4;)V
public fun getHostPackageName ()Ljava/lang/String;
public fun getPluginPackageName ()Ljava/lang/String;
public final fun useService (Landroid/content/Intent;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class app/revanced/manager/plugin/downloader/ExtensionsKt {
public static final fun download (Lapp/revanced/manager/plugin/downloader/DownloaderScope;Lkotlin/jvm/functions/Function4;)V
}
public abstract interface class app/revanced/manager/plugin/downloader/GetScope : app/revanced/manager/plugin/downloader/Scope {
public abstract fun requestStartActivity (Landroid/content/Intent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public abstract interface class app/revanced/manager/plugin/downloader/InputDownloadScope : app/revanced/manager/plugin/downloader/BaseDownloadScope {
}
public abstract interface class app/revanced/manager/plugin/downloader/OutputDownloadScope : app/revanced/manager/plugin/downloader/BaseDownloadScope {
public abstract fun reportSize (JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class app/revanced/manager/plugin/downloader/Package : android/os/Parcelable {
public static final field CREATOR Landroid/os/Parcelable$Creator;
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lapp/revanced/manager/plugin/downloader/Package;
public static synthetic fun copy$default (Lapp/revanced/manager/plugin/downloader/Package;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/revanced/manager/plugin/downloader/Package;
public final fun describeContents ()I
public fun equals (Ljava/lang/Object;)Z
public final fun getName ()Ljava/lang/String;
public final fun getVersion ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
public final fun writeToParcel (Landroid/os/Parcel;I)V
}
public final class app/revanced/manager/plugin/downloader/Package$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/Package;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/Package;
public synthetic fun newArray (I)[Ljava/lang/Object;
}
public abstract interface annotation class app/revanced/manager/plugin/downloader/PluginHostApi : java/lang/annotation/Annotation {
}
public abstract interface class app/revanced/manager/plugin/downloader/Scope {
public abstract fun getHostPackageName ()Ljava/lang/String;
public abstract fun getPluginPackageName ()Ljava/lang/String;
}
public abstract class app/revanced/manager/plugin/downloader/UserInteractionException : java/lang/Exception {
public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
}
public abstract class app/revanced/manager/plugin/downloader/UserInteractionException$Activity : app/revanced/manager/plugin/downloader/UserInteractionException {
public synthetic fun <init> (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
}
public final class app/revanced/manager/plugin/downloader/UserInteractionException$Activity$Cancelled : app/revanced/manager/plugin/downloader/UserInteractionException$Activity {
}
public final class app/revanced/manager/plugin/downloader/UserInteractionException$Activity$NotCompleted : app/revanced/manager/plugin/downloader/UserInteractionException$Activity {
public final fun getIntent ()Landroid/content/Intent;
public final fun getResultCode ()I
}
public final class app/revanced/manager/plugin/downloader/UserInteractionException$RequestDenied : app/revanced/manager/plugin/downloader/UserInteractionException {
}
public final class app/revanced/manager/plugin/downloader/webview/APIKt {
public static final fun WebViewDownloader (Lkotlin/jvm/functions/Function4;)Lapp/revanced/manager/plugin/downloader/DownloaderBuilder;
public static final fun runWebView (Lapp/revanced/manager/plugin/downloader/GetScope;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public class app/revanced/manager/plugin/downloader/webview/IWebView$Default : app/revanced/manager/plugin/downloader/webview/IWebView {
public fun <init> ()V
public fun asBinder ()Landroid/os/IBinder;
public fun finish ()V
public fun load (Ljava/lang/String;)V
}
public abstract class app/revanced/manager/plugin/downloader/webview/IWebView$Stub : android/os/Binder, app/revanced/manager/plugin/downloader/webview/IWebView {
public fun <init> ()V
public fun asBinder ()Landroid/os/IBinder;
public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/plugin/downloader/webview/IWebView;
public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z
}
public class app/revanced/manager/plugin/downloader/webview/IWebViewEvents$Default : app/revanced/manager/plugin/downloader/webview/IWebViewEvents {
public fun <init> ()V
public fun asBinder ()Landroid/os/IBinder;
public fun download (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
public fun pageLoad (Ljava/lang/String;)V
public fun ready (Lapp/revanced/manager/plugin/downloader/webview/IWebView;)V
}
public abstract class app/revanced/manager/plugin/downloader/webview/IWebViewEvents$Stub : android/os/Binder, app/revanced/manager/plugin/downloader/webview/IWebViewEvents {
public fun <init> ()V
public fun asBinder ()Landroid/os/IBinder;
public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/plugin/downloader/webview/IWebViewEvents;
public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z
}
public final class app/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters$Creator : android/os/Parcelable$Creator {
public fun <init> ()V
public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters;
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters;
public synthetic fun newArray (I)[Ljava/lang/Object;
}
public abstract interface class app/revanced/manager/plugin/downloader/webview/WebViewCallbackScope : app/revanced/manager/plugin/downloader/Scope {
public abstract fun finish (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun load (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}
public final class app/revanced/manager/plugin/downloader/webview/WebViewScope : app/revanced/manager/plugin/downloader/Scope {
public final fun download (Lkotlin/jvm/functions/Function5;)V
public fun getHostPackageName ()Ljava/lang/String;
public fun getPluginPackageName ()Ljava/lang/String;
public final fun pageLoad (Lkotlin/jvm/functions/Function3;)V
}

View File

@ -0,0 +1,61 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
`maven-publish`
}
android {
namespace = "app.revanced.manager.plugin.downloader"
compileSdk = 35
defaultConfig {
minSdk = 26
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
aidl = true
}
}
dependencies {
implementation(libs.androidx.ktx)
implementation(libs.activity.ktx)
implementation(libs.runtime.ktx)
implementation(libs.appcompat)
}
publishing {
repositories {
mavenLocal()
}
publications {
create<MavenPublication>("release") {
groupId = "app.revanced"
artifactId = "manager-downloader-plugin"
version = "1.0"
afterEvaluate {
from(components["release"])
}
}
}
}

View File

21
downloader-plugin/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -0,0 +1,8 @@
// IWebView.aidl
package app.revanced.manager.plugin.downloader.webview;
@JavaPassthrough(annotation="@app.revanced.manager.plugin.downloader.PluginHostApi")
oneway interface IWebView {
void load(String url);
void finish();
}

View File

@ -0,0 +1,11 @@
// IWebViewEvents.aidl
package app.revanced.manager.plugin.downloader.webview;
import app.revanced.manager.plugin.downloader.webview.IWebView;
@JavaPassthrough(annotation="@app.revanced.manager.plugin.downloader.PluginHostApi")
oneway interface IWebViewEvents {
void ready(IWebView iface);
void pageLoad(String url);
void download(String url, String mimetype, String userAgent);
}

View File

@ -0,0 +1,7 @@
package app.revanced.manager.plugin.downloader
/**
* The permission ID of the special plugin host permission. Only ReVanced Manager will have this permission.
* Plugin UI activities and internal services can be protected using this permission.
*/
const val PLUGIN_HOST_PERMISSION = "app.revanced.manager.permission.PLUGIN_HOST"

View File

@ -0,0 +1,165 @@
package app.revanced.manager.plugin.downloader
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.app.Activity
import android.os.Parcelable
import kotlinx.coroutines.withTimeout
import java.io.InputStream
import java.io.OutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@RequiresOptIn(
level = RequiresOptIn.Level.ERROR,
message = "This API is only intended for plugin hosts, don't use it in a plugin.",
)
@Retention(AnnotationRetention.BINARY)
annotation class PluginHostApi
/**
* The base interface for all DSL scopes.
*/
interface Scope {
/**
* The package name of ReVanced Manager.
*/
val hostPackageName: String
/**
* The package name of the plugin.
*/
val pluginPackageName: String
}
/**
* The scope of [DownloaderScope.get].
*/
interface GetScope : Scope {
/**
* Ask the user to perform some required interaction in the activity specified by the provided [Intent].
* This function returns normally with the resulting [Intent] when the activity finishes with code [Activity.RESULT_OK].
*
* @throws UserInteractionException.RequestDenied User decided to skip this plugin.
* @throws UserInteractionException.Activity.Cancelled The activity was cancelled.
* @throws UserInteractionException.Activity.NotCompleted The activity finished with an unknown result code.
*/
suspend fun requestStartActivity(intent: Intent): Intent?
}
interface BaseDownloadScope : Scope
/**
* The scope for [DownloaderScope.download].
*/
interface InputDownloadScope : BaseDownloadScope
typealias Size = Long
typealias DownloadResult = Pair<InputStream, Size?>
typealias Version = String
typealias GetResult<T> = Pair<T, Version?>
class DownloaderScope<T : Parcelable> internal constructor(
private val scopeImpl: Scope,
internal val context: Context
) : Scope by scopeImpl {
// Returning an InputStream is the primary way for plugins to implement the download function, but we also want to offer an OutputStream API since using InputStream might not be convenient in all cases.
// It is much easier to implement the main InputStream API on top of OutputStreams compared to doing it the other way around, which is why we are using OutputStream here. This detail is not visible to plugins.
internal var download: (suspend OutputDownloadScope.(T, OutputStream) -> Unit)? = null
internal var get: (suspend GetScope.(String, String?) -> GetResult<T>?)? = null
private val inputDownloadScopeImpl = object : InputDownloadScope, Scope by scopeImpl {}
/**
* Define the download block of the plugin.
*/
fun download(block: suspend InputDownloadScope.(data: T) -> DownloadResult) {
download = { app, outputStream ->
val (inputStream, size) = inputDownloadScopeImpl.block(app)
inputStream.use {
if (size != null) reportSize(size)
it.copyTo(outputStream)
}
}
}
/**
* Define the get block of the plugin.
* The block should return null if the app cannot be found. The version in the result must match the version argument unless it is null.
*/
fun get(block: suspend GetScope.(packageName: String, version: String?) -> GetResult<T>?) {
get = block
}
/**
* Utilize the service specified by the provided [Intent]. The service will be unbound when the scope ends.
*/
suspend fun <R : Any?> useService(intent: Intent, block: suspend (IBinder) -> R): R {
var onBind: ((IBinder) -> Unit)? = null
val serviceConn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) =
onBind!!(service!!)
override fun onServiceDisconnected(name: ComponentName?) {}
}
return try {
val binder = withTimeout(10000L) {
suspendCoroutine { continuation ->
onBind = continuation::resume
context.bindService(intent, serviceConn, Context.BIND_AUTO_CREATE)
}
}
block(binder)
} finally {
onBind = null
context.unbindService(serviceConn)
}
}
}
class DownloaderBuilder<T : Parcelable> internal constructor(private val block: DownloaderScope<T>.() -> Unit) {
@PluginHostApi
fun build(scopeImpl: Scope, context: Context) =
with(DownloaderScope<T>(scopeImpl, context)) {
block()
Downloader(
download = download!!,
get = get!!
)
}
}
class Downloader<T : Parcelable> internal constructor(
@property:PluginHostApi val get: suspend GetScope.(packageName: String, version: String?) -> GetResult<T>?,
@property:PluginHostApi val download: suspend OutputDownloadScope.(data: T, outputStream: OutputStream) -> Unit
)
/**
* Define a downloader plugin.
*/
fun <T : Parcelable> Downloader(block: DownloaderScope<T>.() -> Unit) = DownloaderBuilder(block)
/**
* @see GetScope.requestStartActivity
*/
sealed class UserInteractionException(message: String) : Exception(message) {
class RequestDenied @PluginHostApi constructor() :
UserInteractionException("Request denied by user")
sealed class Activity(message: String) : UserInteractionException(message) {
class Cancelled @PluginHostApi constructor() : Activity("Interaction cancelled")
/**
* @param resultCode The result code of the activity.
* @param intent The [Intent] of the activity.
*/
class NotCompleted @PluginHostApi constructor(val resultCode: Int, val intent: Intent?) :
Activity("Unexpected activity result code: $resultCode")
}
}

View File

@ -0,0 +1,42 @@
package app.revanced.manager.plugin.downloader
import android.app.Activity
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.Parcelable
import java.io.OutputStream
/**
* The scope of the [OutputStream] version of [DownloaderScope.download].
*/
interface OutputDownloadScope : BaseDownloadScope {
suspend fun reportSize(size: Long)
}
/**
* A replacement for [DownloaderScope.download] that uses [OutputStream].
* The provided [OutputStream] does not need to be closed manually.
*/
fun <T : Parcelable> DownloaderScope<T>.download(block: suspend OutputDownloadScope.(T, OutputStream) -> Unit) {
download = block
}
/**
* Performs [GetScope.requestStartActivity] with an [Intent] created using the type information of [ACTIVITY].
* @see [GetScope.requestStartActivity]
*/
suspend inline fun <reified ACTIVITY : Activity> GetScope.requestStartActivity() =
requestStartActivity(
Intent().apply { setClassName(pluginPackageName, ACTIVITY::class.qualifiedName!!) }
)
/**
* Performs [DownloaderScope.useService] with an [Intent] created using the type information of [SERVICE].
* @see [DownloaderScope.useService]
*/
suspend inline fun <reified SERVICE : Service, R : Any?> DownloaderScope<*>.useService(
noinline block: suspend (IBinder) -> R
) = useService(
Intent().apply { setClassName(pluginPackageName, SERVICE::class.qualifiedName!!) }, block
)

View File

@ -0,0 +1,39 @@
package app.revanced.manager.plugin.downloader
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.net.HttpURLConnection
import java.net.URI
/**
* A simple parcelable data class for storing a package name and version.
* This can be used as the data type for plugins that only need a name and version to implement their [DownloaderScope.download] function.
*
* @param name The package name.
* @param version The version.
*/
@Parcelize
data class Package(val name: String, val version: String) : Parcelable
/**
* A data class for storing a download URL.
*
* @param url The download URL.
* @param headers The headers to use for the request.
*/
@Parcelize
data class DownloadUrl(val url: String, val headers: Map<String, String> = emptyMap()) : Parcelable {
/**
* Converts this into a [DownloadResult].
*/
fun toDownloadResult(): DownloadResult = with(URI.create(url).toURL().openConnection() as HttpURLConnection) {
useCaches = false
allowUserInteraction = false
headers.forEach(::setRequestProperty)
connectTimeout = 10_000
connect()
inputStream to getHeaderField("Content-Length").toLong()
}
}

View File

@ -0,0 +1,176 @@
package app.revanced.manager.plugin.downloader.webview
import android.content.Intent
import app.revanced.manager.plugin.downloader.DownloadUrl
import app.revanced.manager.plugin.downloader.DownloaderScope
import app.revanced.manager.plugin.downloader.GetScope
import app.revanced.manager.plugin.downloader.Scope
import app.revanced.manager.plugin.downloader.Downloader
import app.revanced.manager.plugin.downloader.PluginHostApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import kotlin.properties.Delegates
typealias InitialUrl = String
typealias PageLoadCallback<T> = suspend WebViewCallbackScope<T>.(url: String) -> Unit
typealias DownloadCallback<T> = suspend WebViewCallbackScope<T>.(url: String, mimeType: String, userAgent: String) -> Unit
interface WebViewCallbackScope<T> : Scope {
/**
* Finishes the activity and returns the [result].
*/
suspend fun finish(result: T)
/**
* Tells the WebView to load the specified [url].
*/
suspend fun load(url: String)
}
@OptIn(PluginHostApi::class)
class WebViewScope<T> internal constructor(
coroutineScope: CoroutineScope,
private val scopeImpl: Scope,
setResult: (T) -> Unit
) : Scope by scopeImpl {
private var onPageLoadCallback: PageLoadCallback<T> = {}
private var onDownloadCallback: DownloadCallback<T> = { _, _, _ -> }
@OptIn(ExperimentalCoroutinesApi::class)
private val dispatcher = Dispatchers.Default.limitedParallelism(1)
private lateinit var webView: IWebView
internal lateinit var initialUrl: String
internal val binder = object : IWebViewEvents.Stub() {
override fun ready(iface: IWebView?) {
coroutineScope.launch(dispatcher) {
webView = iface!!.also {
it.load(initialUrl)
}
}
}
override fun pageLoad(url: String?) {
coroutineScope.launch(dispatcher) { onPageLoadCallback(callbackScope, url!!) }
}
override fun download(url: String?, mimetype: String?, userAgent: String?) {
coroutineScope.launch(dispatcher) {
onDownloadCallback(
callbackScope,
url!!,
mimetype!!,
userAgent!!
)
}
}
}
private val callbackScope = object : WebViewCallbackScope<T>, Scope by scopeImpl {
override suspend fun finish(result: T) {
setResult(result)
// Tell the WebViewActivity to finish
webView.let { withContext(Dispatchers.IO) { it.finish() } }
}
override suspend fun load(url: String) {
webView.let { withContext(Dispatchers.IO) { it.load(url) } }
}
}
/**
* Called when the WebView attempts to download a file to disk.
*/
fun download(block: DownloadCallback<T>) {
onDownloadCallback = block
}
/**
* Called when the WebView finishes loading a page.
*/
fun pageLoad(block: PageLoadCallback<T>) {
onPageLoadCallback = block
}
}
@JvmInline
private value class Container<U>(val value: U)
/**
* Run a [android.webkit.WebView] Activity controlled by the provided code block.
* The activity will keep running until it is cancelled or an event handler calls [WebViewCallbackScope.finish].
* The [block] defines the event handlers and returns the initial URL.
*
* @param title The string displayed in the action bar.
* @param block The control block.
*/
@OptIn(PluginHostApi::class)
suspend fun <T> GetScope.runWebView(
title: String,
block: suspend WebViewScope<T>.() -> InitialUrl
) = supervisorScope {
var result by Delegates.notNull<Container<T>>()
val scope = WebViewScope<T>(this@supervisorScope, this@runWebView) { result = Container(it) }
scope.initialUrl = scope.block()
// Start the webview activity and wait until it finishes.
requestStartActivity(Intent().apply {
putExtra(
WebViewActivity.KEY,
WebViewActivity.Parameters(title, scope.binder)
)
setClassName(
hostPackageName,
WebViewActivity::class.qualifiedName!!
)
})
// Return the result and cancel any leftover coroutines.
coroutineContext.cancelChildren()
result.value
}
/**
* Implement a downloader using [runWebView] and [DownloadUrl]. This function will automatically define a handler for download events unlike [runWebView].
* Returning null inside the [block] is equivalent to returning null inside [DownloaderScope.get].
*
* @see runWebView
*/
fun WebViewDownloader(block: suspend WebViewScope<DownloadUrl>.(packageName: String, version: String?) -> InitialUrl?) =
Downloader<DownloadUrl> {
val label = context.applicationInfo.loadLabel(
context.packageManager
).toString()
get { packageName, version ->
class ReturnNull : Exception()
try {
runWebView(label) {
download { url, _, userAgent ->
finish(
DownloadUrl(
url,
mapOf("User-Agent" to userAgent)
)
)
}
block(this@runWebView, packageName, version) ?: throw ReturnNull()
} to version
} catch (_: ReturnNull) {
null
}
}
download {
it.toDownloadResult()
}
}

View File

@ -0,0 +1,161 @@
package app.revanced.manager.plugin.downloader.webview
import android.annotation.SuppressLint
import android.os.Bundle
import android.os.IBinder
import android.os.Parcelable
import android.view.MenuItem
import android.webkit.CookieManager
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.addCallback
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.R
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@OptIn(PluginHostApi::class)
@PluginHostApi
class WebViewActivity : ComponentActivity() {
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val vm by viewModels<WebViewModel>()
enableEdgeToEdge()
setContentView(R.layout.activity_webview)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
val webView = findViewById<WebView>(R.id.webview)
onBackPressedDispatcher.addCallback {
if (webView.canGoBack()) webView.goBack()
else cancelActivity()
}
val params = intent.getParcelableExtra<Parameters>(KEY)!!
actionBar?.apply {
title = params.title
setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel)
setDisplayHomeAsUpEnabled(true)
}
val events = IWebViewEvents.Stub.asInterface(params.events)!!
vm.setup(events)
webView.apply {
settings.apply {
cacheMode = WebSettings.LOAD_NO_CACHE
allowContentAccess = false
domStorageEnabled = true
javaScriptEnabled = true
}
webViewClient = vm.webViewClient
setDownloadListener { url, userAgent, _, mimetype, _ ->
vm.onDownload(url, mimetype, userAgent)
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.commands.collect {
when (it) {
is WebViewModel.Command.Finish -> {
setResult(RESULT_OK)
finish()
}
is WebViewModel.Command.Load -> webView.loadUrl(it.url)
}
}
}
}
}
private fun cancelActivity() {
setResult(RESULT_CANCELED)
finish()
}
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) {
cancelActivity()
true
} else super.onOptionsItemSelected(item)
@Parcelize
internal class Parameters(
val title: String, val events: IBinder
) : Parcelable
internal companion object {
const val KEY = "params"
}
}
@OptIn(PluginHostApi::class)
internal class WebViewModel : ViewModel() {
init {
CookieManager.getInstance().apply {
removeAllCookies(null)
setAcceptCookie(true)
}
}
private val commandChannel = Channel<Command>()
val commands = commandChannel.receiveAsFlow()
private var eventBinder: IWebViewEvents? = null
private val ctrlBinder = object : IWebView.Stub() {
override fun load(url: String?) {
viewModelScope.launch {
commandChannel.send(Command.Load(url!!))
}
}
override fun finish() {
viewModelScope.launch {
commandChannel.send(Command.Finish)
}
}
}
val webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
eventBinder!!.pageLoad(url)
}
}
fun onDownload(url: String, mimeType: String, userAgent: String) {
eventBinder!!.download(url, mimeType, userAgent)
}
fun setup(binder: IWebViewEvents) {
if (eventBinder != null) return
eventBinder = binder
binder.ready(ctrlBinder)
}
sealed interface Command {
data class Load(val url: String) : Command
data object Finish : Command
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/main">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View File

@ -0,0 +1 @@
<resources></resources>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WebViewActivity" parent="Theme.AppCompat.DayNight">
<item name="android:windowActionBar">true</item>
<item name="android:windowNoTitle">false</item>
</style>
</resources>

1
example-downloader-plugin/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

View File

@ -0,0 +1,53 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.compose.compiler)
}
android {
val packageName = "app.revanced.manager.plugin.downloader.example"
namespace = packageName
compileSdk = 35
defaultConfig {
applicationId = packageName
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (project.hasProperty("signAsDebug")) {
signingConfig = signingConfigs.getByName("debug")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures.compose = true
}
dependencies {
implementation(libs.activity.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling)
implementation(libs.compose.material3)
compileOnly(project(":downloader-plugin"))
}

View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="app.revanced.manager.plugin.downloader" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
tools:targetApi="34">
<activity
android:name=".InteractionActivity"
android:exported="true"
android:permission="app.revanced.manager.permission.PLUGIN_HOST"
android:theme="@android:style/Theme.DeviceDefault" />
<meta-data
android:name="app.revanced.manager.plugin.downloader.class"
android:value="app.revanced.manager.plugin.downloader.example.ExamplePluginKt" />
</application>
</manifest>

View File

@ -0,0 +1,69 @@
@file:Suppress("Unused")
package app.revanced.manager.plugin.downloader.example
import android.app.Application
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Parcelable
import app.revanced.manager.plugin.downloader.Downloader
import app.revanced.manager.plugin.downloader.requestStartActivity
import app.revanced.manager.plugin.downloader.webview.WebViewDownloader
import kotlinx.parcelize.Parcelize
import kotlin.io.path.*
val apkMirrorDownloader = WebViewDownloader { packageName, version ->
with(Uri.Builder()) {
scheme("https")
authority("www.apkmirror.com")
mapOf(
"post_type" to "app_release",
"searchtype" to "apk",
"s" to (version?.let { "$packageName $it" } ?: packageName),
"bundles%5B%5D" to "apk_files" // bundles[]
).forEach { (key, value) ->
appendQueryParameter(key, value)
}
build().toString()
}
}
@Parcelize
class InstalledApp(val path: String) : Parcelable
private val application by lazy {
// Don't do this in a real plugin.
val clazz = Class.forName("android.app.ActivityThread")
val activityThread = clazz.getMethod("currentActivityThread")(null)
clazz.getMethod("getApplication")(activityThread) as Application
}
val installedAppDownloader = Downloader<InstalledApp> {
val pm = application.packageManager
get { packageName, version ->
val packageInfo = try {
pm.getPackageInfo(packageName, 0)
} catch (_: PackageManager.NameNotFoundException) {
return@get null
}
if (version != null && packageInfo.versionName != version) return@get null
requestStartActivity<InteractionActivity>()
InstalledApp(packageInfo.applicationInfo!!.sourceDir) to packageInfo.versionName
}
download { app ->
with(Path(app.path)) { inputStream() to fileSize() }
}
/*
download { app, outputStream ->
val path = Path(app.path)
reportSize(path.fileSize())
Files.copy(path, outputStream)
}*/
}

View File

@ -0,0 +1,65 @@
package app.revanced.manager.plugin.downloader.example
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.Modifier
class InteractionActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val isDarkTheme = isSystemInDarkTheme()
val colorScheme = if (isDarkTheme) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("User interaction example") }
)
}
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
Text("This is an example interaction.")
Row {
TextButton(
onClick = {
setResult(RESULT_CANCELED)
finish()
}
) {
Text("Cancel")
}
TextButton(
onClick = {
setResult(RESULT_OK)
finish()
}
) {
Text("Continue")
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Example Downloader Plugin</string>
</resources>

View File

@ -1,13 +1,14 @@
[versions] [versions]
ktx = "1.15.0" ktx = "1.15.0"
material3 = "1.3.1" material3 = "1.3.1"
ui-tooling = "1.7.5" ui-tooling = "1.7.6"
viewmodel-lifecycle = "2.8.7" viewmodel-lifecycle = "2.8.7"
splash-screen = "1.0.1" splash-screen = "1.0.1"
compose-activity = "1.9.3" activity = "1.9.3"
appcompat = "1.7.0"
preferences-datastore = "1.1.1" preferences-datastore = "1.1.1"
work-runtime = "2.10.0" work-runtime = "2.10.0"
compose-bom = "2024.10.01" compose-bom = "2024.12.01"
accompanist = "0.34.0" accompanist = "0.34.0"
placeholder = "1.1.2" placeholder = "1.1.2"
reorderable = "1.5.2" reorderable = "1.5.2"
@ -23,10 +24,11 @@ reimagined-navigation = "1.5.0"
ktor = "2.3.9" ktor = "2.3.9"
markdown-renderer = "0.22.0" markdown-renderer = "0.22.0"
fading-edges = "1.0.4" fading-edges = "1.0.4"
kotlin = "2.0.21" kotlin = "2.1.0"
android-gradle-plugin = "8.7.2" android-gradle-plugin = "8.7.3"
dev-tools-gradle-plugin = "2.0.21-1.0.27" dev-tools-gradle-plugin = "2.1.0-1.0.29"
about-libraries-gradle-plugin = "11.1.1" about-libraries-gradle-plugin = "11.1.1"
binary-compatibility-validator = "0.17.0"
coil = "2.6.0" coil = "2.6.0"
app-icon-loader-coil = "1.5.0" app-icon-loader-coil = "1.5.0"
skrapeit = "1.2.2" skrapeit = "1.2.2"
@ -43,9 +45,11 @@ androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx"
runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "viewmodel-lifecycle" } runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "viewmodel-lifecycle" }
runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "viewmodel-lifecycle" } runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "viewmodel-lifecycle" }
splash-screen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splash-screen" } splash-screen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splash-screen" }
compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "compose-activity" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" }
activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-runtime" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-runtime" }
preferences-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "preferences-datastore" } preferences-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "preferences-datastore" }
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
# Compose # Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
@ -135,9 +139,11 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons",
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
devtools = { id = "com.google.devtools.ksp", version.ref = "dev-tools-gradle-plugin" } devtools = { id = "com.google.devtools.ksp", version.ref = "dev-tools-gradle-plugin" }
about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries-gradle-plugin" } about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries-gradle-plugin" }
binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" }

View File

@ -26,3 +26,5 @@ dependencyResolutionManagement {
} }
rootProject.name = "ReVanced Manager" rootProject.name = "ReVanced Manager"
include(":app") include(":app")
include(":downloader-plugin")
include(":example-downloader-plugin")