mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2024-11-10 01:01:56 +01:00
feat: implement downloader plugin system
This commit is contained in:
parent
36f864efbb
commit
2b426ecd62
49 changed files with 1469 additions and 556 deletions
|
@ -113,6 +113,7 @@ dependencies {
|
||||||
implementation(libs.splash.screen)
|
implementation(libs.splash.screen)
|
||||||
implementation(libs.compose.activity)
|
implementation(libs.compose.activity)
|
||||||
implementation(libs.paging.common.ktx)
|
implementation(libs.paging.common.ktx)
|
||||||
|
implementation(libs.paging.compose)
|
||||||
implementation(libs.work.runtime.ktx)
|
implementation(libs.work.runtime.ktx)
|
||||||
implementation(libs.preferences.datastore)
|
implementation(libs.preferences.datastore)
|
||||||
|
|
||||||
|
@ -153,6 +154,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)
|
||||||
|
|
||||||
|
|
8
app/proguard-rules.pro
vendored
8
app/proguard-rules.pro
vendored
|
@ -49,6 +49,14 @@
|
||||||
-keep class com.android.** {
|
-keep class com.android.** {
|
||||||
*;
|
*;
|
||||||
}
|
}
|
||||||
|
# These two are used by downloader plugins
|
||||||
|
-keep class app.revanced.manager.plugin.** {
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
-keep class androidx.paging.** {
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
|
||||||
-dontwarn com.google.auto.value.**
|
-dontwarn com.google.auto.value.**
|
||||||
-dontwarn java.awt.**
|
-dontwarn java.awt.**
|
||||||
-dontwarn javax.**
|
-dontwarn javax.**
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"identityHash": "c0c780e55e10c9b095c004733c846b67",
|
"identityHash": "98837fd72fde0272894bce063c1095af",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "patch_bundles",
|
"tableName": "patch_bundles",
|
||||||
|
@ -402,12 +402,38 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "trusted_downloader_plugins",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `signature` TEXT NOT NULL, PRIMARY KEY(`package_name`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "packageName",
|
||||||
|
"columnName": "package_name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "signature",
|
||||||
|
"columnName": "signature",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"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, 'c0c780e55e10c9b095c004733c846b67')"
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '98837fd72fde0272894bce063c1095af')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,9 +2,8 @@
|
||||||
<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"
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="ReservedSystemPermission" />
|
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 +16,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"
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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")
|
||||||
|
data class TrustedDownloaderPlugin(
|
||||||
|
@PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
|
||||||
|
@ColumnInfo(name = "signature") val signature: String
|
||||||
|
)
|
|
@ -0,0 +1,17 @@
|
||||||
|
package app.revanced.manager.data.room.plugins
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface TrustedDownloaderPluginDao {
|
||||||
|
@Query("SELECT signature FROM trusted_downloader_plugins WHERE package_name = :packageName")
|
||||||
|
suspend fun getTrustedSignature(packageName: String): String?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsertTrust(plugin: TrustedDownloaderPlugin)
|
||||||
|
|
||||||
|
@Query("DELETE FROM trusted_downloader_plugins WHERE package_name = :packageName")
|
||||||
|
suspend fun remove(packageName: String)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -19,8 +19,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)
|
||||||
|
|
||||||
|
|
|
@ -5,13 +5,14 @@ import android.content.Context
|
||||||
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.plugin.downloader.DownloaderPlugin
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class DownloadedAppRepository(
|
class DownloadedAppRepository(
|
||||||
app: Application,
|
app: Application,
|
||||||
db: AppDatabase
|
db: AppDatabase,
|
||||||
|
private val downloaderPluginRepository: DownloaderPluginRepository
|
||||||
) {
|
) {
|
||||||
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()
|
||||||
|
@ -21,10 +22,10 @@ class DownloadedAppRepository(
|
||||||
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 <A : DownloaderPlugin.App> download(
|
||||||
app: AppDownloader.App,
|
plugin: DownloaderPlugin<A>,
|
||||||
preferSplits: Boolean,
|
app: A,
|
||||||
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit = {},
|
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit,
|
||||||
): File {
|
): File {
|
||||||
this.get(app.packageName, app.version)?.let { downloaded ->
|
this.get(app.packageName, app.version)?.let { downloaded ->
|
||||||
return getApkFileForApp(downloaded)
|
return getApkFileForApp(downloaded)
|
||||||
|
@ -35,13 +36,25 @@ class DownloadedAppRepository(
|
||||||
val savePath = dir.resolve(relativePath).also { it.mkdirs() }
|
val savePath = dir.resolve(relativePath).also { it.mkdirs() }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
app.download(savePath, preferSplits, onDownload)
|
val parameters = DownloaderPlugin.DownloadParameters(
|
||||||
|
targetFile = savePath.resolve("base.apk"),
|
||||||
|
onDownloadProgress = { progress ->
|
||||||
|
val (bytesReceived, bytesTotal) = progress
|
||||||
|
?: return@DownloadParameters onDownload(null)
|
||||||
|
|
||||||
dao.insert(DownloadedApp(
|
onDownload(bytesReceived.megaBytes to bytesTotal.megaBytes)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin.download(app, parameters)
|
||||||
|
|
||||||
|
dao.insert(
|
||||||
|
DownloadedApp(
|
||||||
packageName = app.packageName,
|
packageName = app.packageName,
|
||||||
version = app.version,
|
version = app.version,
|
||||||
directory = relativePath,
|
directory = relativePath,
|
||||||
))
|
)
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
savePath.deleteRecursively()
|
savePath.deleteRecursively()
|
||||||
throw e
|
throw e
|
||||||
|
@ -60,4 +73,8 @@ class DownloadedAppRepository(
|
||||||
|
|
||||||
dao.delete(downloadedApps)
|
dao.delete(downloadedApps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val Int.megaBytes get() = div(100000).toFloat() / 10
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
package app.revanced.manager.domain.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.Signature
|
||||||
|
import android.util.Log
|
||||||
|
import app.revanced.manager.data.platform.Filesystem
|
||||||
|
import app.revanced.manager.data.room.AppDatabase
|
||||||
|
import app.revanced.manager.data.room.plugins.TrustedDownloaderPlugin
|
||||||
|
import app.revanced.manager.network.downloader.DownloaderPluginState
|
||||||
|
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
|
||||||
|
import app.revanced.manager.network.downloader.ParceledDownloaderApp
|
||||||
|
import app.revanced.manager.plugin.downloader.DownloaderPlugin
|
||||||
|
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.map
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class DownloaderPluginRepository(
|
||||||
|
private val pm: PM,
|
||||||
|
private val fs: Filesystem,
|
||||||
|
private val context: Context,
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reload() {
|
||||||
|
val pluginPackages =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
pm.getPackagesWithFeature(
|
||||||
|
PLUGIN_FEATURE,
|
||||||
|
flags = packageFlags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_pluginStates.value = pluginPackages.associate { it.packageName to loadPlugin(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unwrapParceledApp(app: ParceledDownloaderApp): Pair<LoadedDownloaderPlugin, DownloaderPlugin.App> {
|
||||||
|
val plugin =
|
||||||
|
(_pluginStates.value[app.pluginPackageName] as? DownloaderPluginState.Loaded)?.plugin
|
||||||
|
?: throw Exception("Downloader plugin with name ${app.pluginPackageName} is not available")
|
||||||
|
|
||||||
|
return plugin to app.unwrapWith(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadPlugin(packageInfo: PackageInfo): DownloaderPluginState {
|
||||||
|
try {
|
||||||
|
if (!verify(packageInfo)) return DownloaderPluginState.Untrusted
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Got exception while verifying plugin ${packageInfo.packageName}", e)
|
||||||
|
return DownloaderPluginState.Failed(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pluginParameters = DownloaderPlugin.Parameters(
|
||||||
|
context = context,
|
||||||
|
tempDirectory = fs.tempDir.resolve("dl_plugin_${packageInfo.packageName}")
|
||||||
|
.also(File::mkdir)
|
||||||
|
)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val pluginClassName =
|
||||||
|
packageInfo.applicationInfo.metaData.getString(METADATA_PLUGIN_CLASS)
|
||||||
|
?: throw Exception("Missing metadata attribute $METADATA_PLUGIN_CLASS")
|
||||||
|
val classLoader = PathClassLoader(
|
||||||
|
packageInfo.applicationInfo.sourceDir,
|
||||||
|
DownloaderPlugin::class.java.classLoader
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val downloaderPluginClass =
|
||||||
|
classLoader.loadClass(pluginClassName) as Class<DownloaderPlugin<DownloaderPlugin.App>>
|
||||||
|
|
||||||
|
val plugin = downloaderPluginClass
|
||||||
|
.getDeclaredConstructor(DownloaderPlugin.Parameters::class.java)
|
||||||
|
.newInstance(pluginParameters)
|
||||||
|
|
||||||
|
DownloaderPluginState.Loaded(
|
||||||
|
LoadedDownloaderPlugin(
|
||||||
|
packageInfo.packageName,
|
||||||
|
with(pm) { packageInfo.label() },
|
||||||
|
packageInfo.versionName,
|
||||||
|
plugin,
|
||||||
|
classLoader
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(tag, "Failed to load plugin ${packageInfo.packageName}", t)
|
||||||
|
DownloaderPluginState.Failed(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun trustPackage(packageInfo: PackageInfo) {
|
||||||
|
trustDao.upsertTrust(
|
||||||
|
TrustedDownloaderPlugin(
|
||||||
|
packageInfo.packageName,
|
||||||
|
pm.getSignatures(packageInfo).first().toCharsString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun revokeTrustForPackage(packageName: String) =
|
||||||
|
trustDao.remove(packageName).also { reload() }
|
||||||
|
|
||||||
|
private suspend fun verify(packageInfo: PackageInfo): Boolean {
|
||||||
|
val expectedSignature =
|
||||||
|
trustDao.getTrustedSignature(packageInfo.packageName)?.let(::Signature) ?: return false
|
||||||
|
|
||||||
|
return expectedSignature in pm.getSignatures(packageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PLUGIN_FEATURE = "app.revanced.manager.plugin.downloader"
|
||||||
|
const val METADATA_PLUGIN_CLASS = "app.revanced.manager.plugin.downloader.class"
|
||||||
|
|
||||||
|
val packageFlags = PackageManager.GET_META_DATA or PM.signaturesFlag
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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 = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package app.revanced.manager.network.downloader
|
||||||
|
|
||||||
|
import app.revanced.manager.plugin.downloader.DownloaderPlugin
|
||||||
|
|
||||||
|
class LoadedDownloaderPlugin(
|
||||||
|
val packageName: String,
|
||||||
|
val name: String,
|
||||||
|
val version: String,
|
||||||
|
private val instance: DownloaderPlugin<DownloaderPlugin.App>,
|
||||||
|
val classLoader: ClassLoader
|
||||||
|
) : DownloaderPlugin<DownloaderPlugin.App> by instance
|
|
@ -0,0 +1,46 @@
|
||||||
|
package app.revanced.manager.network.downloader
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
|
import app.revanced.manager.plugin.downloader.DownloaderPlugin
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
/**
|
||||||
|
* A parceled [DownloaderPlugin.App]. Instances of this class can be safely stored in a bundle without needing to set the [ClassLoader].
|
||||||
|
*/
|
||||||
|
class ParceledDownloaderApp private constructor(
|
||||||
|
val pluginPackageName: String,
|
||||||
|
private val bundle: Bundle
|
||||||
|
) : Parcelable {
|
||||||
|
constructor(plugin: LoadedDownloaderPlugin, app: DownloaderPlugin.App) : this(
|
||||||
|
plugin.packageName,
|
||||||
|
createBundle(app)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun unwrapWith(plugin: LoadedDownloaderPlugin): DownloaderPlugin.App {
|
||||||
|
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(APP_KEY, clazz)!! as DownloaderPlugin.App
|
||||||
|
} else @Suppress("DEPRECATION") bundle.getParcelable(APP_KEY)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CLASS_NAME_KEY = "class"
|
||||||
|
const val APP_KEY = "app"
|
||||||
|
|
||||||
|
fun createBundle(app: DownloaderPlugin.App) = Bundle().apply {
|
||||||
|
putParcelable(APP_KEY, app)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) putString(
|
||||||
|
CLASS_NAME_KEY,
|
||||||
|
app::class.java.canonicalName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,6 +22,7 @@ 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
|
||||||
|
@ -49,6 +50,7 @@ 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()
|
||||||
|
@ -143,10 +145,12 @@ class PatcherWorker(
|
||||||
|
|
||||||
val inputFile = when (val selectedApp = args.input) {
|
val inputFile = when (val selectedApp = args.input) {
|
||||||
is SelectedApp.Download -> {
|
is SelectedApp.Download -> {
|
||||||
|
val (plugin, app) = downloaderPluginRepository.unwrapParceledApp(selectedApp.app)
|
||||||
|
|
||||||
downloadedAppRepository.download(
|
downloadedAppRepository.download(
|
||||||
selectedApp.app,
|
plugin,
|
||||||
prefs.preferSplits.get(),
|
app,
|
||||||
onDownload = { args.downloadProgress.emit(it) }
|
onDownload = args.downloadProgress::emit
|
||||||
).also {
|
).also {
|
||||||
args.setInputFile(it)
|
args.setInputFile(it)
|
||||||
updateProgress(state = State.COMPLETED) // Download APK
|
updateProgress(state = State.COMPLETED) // Download APK
|
||||||
|
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.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.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
@ -24,7 +19,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.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
|
||||||
|
@ -35,7 +29,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)
|
||||||
|
@ -129,7 +123,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() }
|
||||||
)
|
)
|
||||||
|
@ -159,60 +153,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()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
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.ParceledDownloaderApp
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
@ -10,7 +10,7 @@ sealed class SelectedApp : Parcelable {
|
||||||
abstract val version: String
|
abstract 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 app: ParceledDownloaderApp) : SelectedApp()
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp()
|
data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp()
|
||||||
|
|
|
@ -6,21 +6,30 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
@ -28,8 +37,12 @@ import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.paging.LoadState
|
||||||
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
|
import androidx.paging.compose.itemKey
|
||||||
import app.revanced.manager.R
|
import app.revanced.manager.R
|
||||||
import app.revanced.manager.data.room.apps.installed.InstallType
|
import app.revanced.manager.data.room.apps.installed.InstallType
|
||||||
|
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
|
||||||
import app.revanced.manager.ui.component.AppTopBar
|
import app.revanced.manager.ui.component.AppTopBar
|
||||||
import app.revanced.manager.ui.component.GroupHeader
|
import app.revanced.manager.ui.component.GroupHeader
|
||||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||||
|
@ -38,6 +51,7 @@ import app.revanced.manager.ui.component.NonSuggestedVersionDialog
|
||||||
import app.revanced.manager.ui.model.SelectedApp
|
import app.revanced.manager.ui.model.SelectedApp
|
||||||
import app.revanced.manager.ui.viewmodel.VersionSelectorViewModel
|
import app.revanced.manager.ui.viewmodel.VersionSelectorViewModel
|
||||||
import app.revanced.manager.util.isScrollingUp
|
import app.revanced.manager.util.isScrollingUp
|
||||||
|
import app.revanced.manager.util.simpleMessage
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
@ -48,21 +62,15 @@ fun VersionSelectorScreen(
|
||||||
) {
|
) {
|
||||||
val supportedVersions by viewModel.supportedVersions.collectAsStateWithLifecycle(emptyMap())
|
val supportedVersions by viewModel.supportedVersions.collectAsStateWithLifecycle(emptyMap())
|
||||||
val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList())
|
val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList())
|
||||||
|
val downloadableVersions = viewModel.downloadableApps?.collectAsLazyPagingItems()
|
||||||
|
|
||||||
val list by remember {
|
val sortedDownloadedVersions by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val apps = (downloadedVersions + viewModel.downloadableVersions)
|
downloadedVersions
|
||||||
.distinctBy { it.version }
|
.distinctBy { it.version }
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
compareByDescending<SelectedApp> {
|
compareByDescending<SelectedApp> { supportedVersions[it.version] }.thenByDescending { it.version }
|
||||||
it is SelectedApp.Local
|
|
||||||
}.thenByDescending { supportedVersions[it.version] }
|
|
||||||
.thenByDescending { it.version }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
viewModel.requiredVersion?.let { requiredVersion ->
|
|
||||||
apps.filter { it.version == requiredVersion }
|
|
||||||
} ?: apps
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,11 +80,34 @@ fun VersionSelectorScreen(
|
||||||
onDismiss = viewModel::dismissNonSuggestedVersionDialog
|
onDismiss = viewModel::dismissNonSuggestedVersionDialog
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var showDownloaderSelectionDialog by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
if (showDownloaderSelectionDialog) {
|
||||||
|
val plugins by viewModel.downloadersFlow.collectAsStateWithLifecycle(emptyList())
|
||||||
|
val hasInstalledPlugins by viewModel.hasInstalledPlugins.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
|
DownloaderSelectionDialog(
|
||||||
|
plugins = plugins,
|
||||||
|
hasInstalledPlugins = hasInstalledPlugins,
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.selectDownloaderPlugin(it)
|
||||||
|
showDownloaderSelectionDialog = false
|
||||||
|
},
|
||||||
|
onDismiss = { showDownloaderSelectionDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
AppTopBar(
|
AppTopBar(
|
||||||
title = stringResource(R.string.select_version),
|
title = stringResource(R.string.select_version),
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { showDownloaderSelectionDialog = true }) {
|
||||||
|
Icon(Icons.Filled.Download, stringResource(R.string.downloader_select))
|
||||||
|
}
|
||||||
|
},
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
@ -115,14 +146,14 @@ fun VersionSelectorScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
if (sortedDownloadedVersions.isNotEmpty()) item {
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
GroupHeader(stringResource(R.string.downloadable_versions))
|
GroupHeader(stringResource(R.string.downloaded_versions))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items(
|
items(
|
||||||
items = list,
|
items = sortedDownloadedVersions,
|
||||||
key = { it.version }
|
key = { it.version }
|
||||||
) {
|
) {
|
||||||
SelectedAppItem(
|
SelectedAppItem(
|
||||||
|
@ -133,23 +164,54 @@ fun VersionSelectorScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewModel.errorMessage != null) {
|
|
||||||
item {
|
item {
|
||||||
Column(
|
Row(Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
GroupHeader(stringResource(R.string.downloadable_versions))
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
}
|
||||||
|
}
|
||||||
|
if (downloadableVersions == null) {
|
||||||
|
item {
|
||||||
|
Text(stringResource(R.string.downloader_not_selected))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(downloadableVersions.loadState.prepend as? LoadState.Error)?.let { errorState ->
|
||||||
|
item {
|
||||||
|
errorState.Render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(
|
||||||
|
count = downloadableVersions.itemCount,
|
||||||
|
key = downloadableVersions.itemKey { it.version }
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.error_occurred))
|
val item = downloadableVersions[it]!!
|
||||||
Text(
|
|
||||||
text = viewModel.errorMessage!!,
|
SelectedAppItem(
|
||||||
modifier = Modifier.padding(horizontal = 15.dp)
|
selectedApp = item,
|
||||||
|
selected = viewModel.selectedVersion == item,
|
||||||
|
onClick = { viewModel.select(item) },
|
||||||
|
patchCount = supportedVersions[item.version]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if (viewModel.isLoading) {
|
val loadStates = arrayOf(
|
||||||
|
downloadableVersions.loadState.append,
|
||||||
|
downloadableVersions.loadState.refresh
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loadStates.any { it is LoadState.Loading }) {
|
||||||
item {
|
item {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
}
|
}
|
||||||
|
} else if (downloadableVersions.itemCount == 0) {
|
||||||
|
item { Text(stringResource(R.string.downloader_no_versions)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStates.firstNotNullOfOrNull { it as? LoadState.Error }?.let { errorState ->
|
||||||
|
item {
|
||||||
|
errorState.Render()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -194,3 +256,82 @@ fun SelectedAppItem(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LoadState.Error.Render() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
val message =
|
||||||
|
remember(error) { error.simpleMessage().orEmpty() }
|
||||||
|
Text(stringResource(R.string.error_occurred))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
modifier = Modifier.padding(horizontal = 15.dp)
|
||||||
|
)
|
||||||
|
Text(error.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DownloaderSelectionDialog(
|
||||||
|
plugins: List<LoadedDownloaderPlugin>,
|
||||||
|
hasInstalledPlugins: Boolean,
|
||||||
|
onConfirm: (LoadedDownloaderPlugin) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
var selectedPackageName: String? by rememberSaveable {
|
||||||
|
mutableStateOf(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = selectedPackageName != null,
|
||||||
|
onClick = { onConfirm(plugins.single { it.packageName == selectedPackageName }) }
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.select))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.downloader_select))
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Filled.Download, null)
|
||||||
|
},
|
||||||
|
// TODO: fix dialog header centering issue
|
||||||
|
// textHorizontalPadding = PaddingValues(horizontal = if (plugins.isNotEmpty()) 0.dp else 24.dp),
|
||||||
|
text = {
|
||||||
|
LazyColumn {
|
||||||
|
items(plugins, key = { it.packageName }) {
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable { selectedPackageName = it.packageName },
|
||||||
|
headlineContent = { Text(it.name) },
|
||||||
|
leadingContent = {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedPackageName == it.packageName,
|
||||||
|
onClick = { selectedPackageName = it.packageName }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plugins.isEmpty()) {
|
||||||
|
item {
|
||||||
|
val resource =
|
||||||
|
if (hasInstalledPlugins) R.string.downloader_no_plugins_available else R.string.downloader_no_plugins_installed
|
||||||
|
|
||||||
|
Text(stringResource(resource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,39 +1,61 @@
|
||||||
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.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
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.settings.BooleanItem
|
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||||
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 app.revanced.manager.util.PM
|
||||||
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 +63,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 +72,179 @@ 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,
|
||||||
|
flags = PM.signaturesFlag
|
||||||
|
)
|
||||||
|
} ?: return@item
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
val signature =
|
||||||
|
remember(packageInfo) {
|
||||||
|
val androidSignature =
|
||||||
|
viewModel.pm.getSignatures(packageInfo).first()
|
||||||
|
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(packageInfo)
|
||||||
|
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 {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
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) }
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,10 +1,16 @@
|
||||||
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.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 +20,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 +31,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(packageInfo: PackageInfo) = viewModelScope.launch {
|
||||||
|
downloaderPluginRepository.trustPackage(packageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun revokePluginTrust(packageName: String) = viewModelScope.launch {
|
||||||
|
downloaderPluginRepository.revokeTrustForPackage(packageName)
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,33 +1,34 @@
|
||||||
package app.revanced.manager.ui.viewmodel
|
package app.revanced.manager.ui.viewmodel
|
||||||
|
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.paging.Pager
|
||||||
|
import androidx.paging.cachedIn
|
||||||
|
import androidx.paging.map
|
||||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||||
import app.revanced.manager.domain.installer.RootInstaller
|
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.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.repository.PatchBundleRepository
|
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||||
import app.revanced.manager.network.downloader.APKMirror
|
import app.revanced.manager.plugin.downloader.DownloaderPlugin
|
||||||
import app.revanced.manager.network.downloader.AppDownloader
|
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
|
||||||
|
import app.revanced.manager.network.downloader.ParceledDownloaderApp
|
||||||
import app.revanced.manager.ui.model.SelectedApp
|
import app.revanced.manager.ui.model.SelectedApp
|
||||||
import app.revanced.manager.util.PM
|
import app.revanced.manager.util.PM
|
||||||
import app.revanced.manager.util.mutableStateSetOf
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import app.revanced.manager.util.simpleMessage
|
|
||||||
import app.revanced.manager.util.tag
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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
|
||||||
|
|
||||||
|
@ -37,46 +38,40 @@ class VersionSelectorViewModel(
|
||||||
private val downloadedAppRepository: DownloadedAppRepository by inject()
|
private val downloadedAppRepository: DownloadedAppRepository by inject()
|
||||||
private val installedAppRepository: InstalledAppRepository by inject()
|
private val installedAppRepository: InstalledAppRepository by inject()
|
||||||
private val patchBundleRepository: PatchBundleRepository by inject()
|
private val patchBundleRepository: PatchBundleRepository by inject()
|
||||||
|
private val downloaderPluginRepository: DownloaderPluginRepository by inject()
|
||||||
private val pm: PM by inject()
|
private val pm: PM by inject()
|
||||||
private val prefs: PreferencesManager by inject()
|
|
||||||
private val appDownloader: AppDownloader = APKMirror()
|
|
||||||
val rootInstaller: RootInstaller by inject()
|
val rootInstaller: RootInstaller by inject()
|
||||||
|
|
||||||
var installedApp: Pair<PackageInfo, InstalledApp?>? by mutableStateOf(null)
|
var installedApp: Pair<PackageInfo, InstalledApp?>? by mutableStateOf(null)
|
||||||
private set
|
private set
|
||||||
var isLoading by mutableStateOf(true)
|
|
||||||
private set
|
|
||||||
var errorMessage: String? by mutableStateOf(null)
|
|
||||||
private set
|
|
||||||
|
|
||||||
var requiredVersion: String? by mutableStateOf(null)
|
var requiredVersion: String? by mutableStateOf(null)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var selectedVersion: SelectedApp? by mutableStateOf(null)
|
var selectedVersion: SelectedApp? by mutableStateOf(null)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp?>(null)
|
private var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp?>(null)
|
||||||
val showNonSuggestedVersionDialog by derivedStateOf { nonSuggestedVersionDialogSubject != null }
|
val showNonSuggestedVersionDialog by derivedStateOf { nonSuggestedVersionDialogSubject != null }
|
||||||
|
|
||||||
private val requiredVersionAsync = viewModelScope.async(Dispatchers.Default) {
|
private var suggestedVersion: String? = null
|
||||||
if (!prefs.suggestedVersionSafeguard.get()) return@async null
|
|
||||||
|
|
||||||
patchBundleRepository.suggestedVersions.first()[packageName]
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
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 {
|
||||||
|
suggestedVersion = patchBundleRepository.suggestedVersions.first()[packageName]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedVersions = patchBundleRepository.bundles.map supportedVersions@{ bundles ->
|
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
|
var patchesWithoutVersions = 0
|
||||||
|
|
||||||
bundles.flatMap { (_, bundle) ->
|
bundles.flatMap { (_, bundle) ->
|
||||||
|
@ -96,16 +91,37 @@ class VersionSelectorViewModel(
|
||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.Default)
|
}.flowOn(Dispatchers.Default)
|
||||||
|
|
||||||
init {
|
val hasInstalledPlugins = downloaderPluginRepository.pluginStates.map { it.isNotEmpty() }
|
||||||
viewModelScope.launch {
|
val downloadersFlow = downloaderPluginRepository.loadedPluginsFlow
|
||||||
requiredVersion = requiredVersionAsync.await()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadableVersions = mutableStateSetOf<SelectedApp.Download>()
|
private var downloaderPlugin: LoadedDownloaderPlugin? by mutableStateOf(null)
|
||||||
|
val downloadableApps by derivedStateOf {
|
||||||
|
downloaderPlugin?.let { plugin ->
|
||||||
|
Pager(
|
||||||
|
config = plugin.pagingConfig
|
||||||
|
) {
|
||||||
|
plugin.createPagingSource(
|
||||||
|
DownloaderPlugin.SearchParameters(
|
||||||
|
packageName,
|
||||||
|
suggestedVersion
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}.flow.map { pagingData ->
|
||||||
|
pagingData.map {
|
||||||
|
SelectedApp.Download(
|
||||||
|
it.packageName,
|
||||||
|
it.version,
|
||||||
|
ParceledDownloaderApp(plugin, it)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}?.flowOn(Dispatchers.Default)?.cachedIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps ->
|
val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps ->
|
||||||
downloadedApps.filter { it.packageName == packageName }.map {
|
downloadedApps
|
||||||
|
.filter { it.packageName == packageName }
|
||||||
|
.map {
|
||||||
SelectedApp.Local(
|
SelectedApp.Local(
|
||||||
it.packageName,
|
it.packageName,
|
||||||
it.version,
|
it.version,
|
||||||
|
@ -115,47 +131,8 @@ class VersionSelectorViewModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
fun selectDownloaderPlugin(plugin: LoadedDownloaderPlugin) {
|
||||||
viewModelScope.launch(Dispatchers.Main) {
|
downloaderPlugin = plugin
|
||||||
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() {
|
fun dismissNonSuggestedVersionDialog() {
|
||||||
|
|
|
@ -8,8 +8,9 @@ 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 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
|
||||||
|
@ -36,7 +37,7 @@ data class AppInfo(
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|
||||||
@SuppressLint("QueryPermissionsNeeded")
|
@SuppressLint("QueryPermissionsNeeded")
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("Deprecation")
|
||||||
class PM(
|
class PM(
|
||||||
private val app: Application,
|
private val app: Application,
|
||||||
patchBundleRepository: PatchBundleRepository
|
patchBundleRepository: PatchBundleRepository
|
||||||
|
@ -67,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,
|
||||||
|
@ -93,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, flags: Int = 0) =
|
||||||
|
getInstalledPackages(PackageManager.GET_CONFIGURATIONS or flags)
|
||||||
|
.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
|
||||||
}
|
}
|
||||||
|
@ -113,6 +129,16 @@ class PM(
|
||||||
return pkgInfo
|
return pkgInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getSignatures(packageInfo: PackageInfo): Array<Signature> {
|
||||||
|
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
packageInfo.signingInfo.apkContentsSigners
|
||||||
|
else packageInfo.signatures
|
||||||
|
|
||||||
|
if (signatures.isEmpty()) throw Exception("Signature information was not queried")
|
||||||
|
|
||||||
|
return signatures
|
||||||
|
}
|
||||||
|
|
||||||
fun PackageInfo.label() = this.applicationInfo.loadLabel(app.packageManager).toString()
|
fun PackageInfo.label() = this.applicationInfo.loadLabel(app.packageManager).toString()
|
||||||
|
|
||||||
suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) {
|
suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) {
|
||||||
|
@ -170,4 +196,8 @@ class PM(
|
||||||
Intent(this, UninstallService::class.java),
|
Intent(this, UninstallService::class.java),
|
||||||
intentFlags
|
intentFlags
|
||||||
).intentSender
|
).intentSender
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val signaturesFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -113,10 +113,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</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>
|
||||||
|
@ -237,6 +241,12 @@
|
||||||
<string name="already_downloaded">Already downloaded</string>
|
<string name="already_downloaded">Already downloaded</string>
|
||||||
<string name="select_version">Select version</string>
|
<string name="select_version">Select version</string>
|
||||||
<string name="downloadable_versions">Downloadable versions</string>
|
<string name="downloadable_versions">Downloadable versions</string>
|
||||||
|
<string name="downloaded_versions">Downloaded versions</string>
|
||||||
|
<string name="downloader_select">Select downloader</string>
|
||||||
|
<string name="downloader_not_selected">No downloader selected</string>
|
||||||
|
<string name="downloader_no_versions">No downloadable versions found</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>
|
||||||
|
|
|
@ -3,4 +3,10 @@ plugins {
|
||||||
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.about.libraries) apply false
|
alias(libs.plugins.about.libraries) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.binary.compatibility.validator)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiValidation {
|
||||||
|
ignoredProjects.addAll(listOf("app", "example-downloader-plugin"))
|
||||||
}
|
}
|
1
downloader-plugin/.gitignore
vendored
Normal file
1
downloader-plugin/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/build
|
33
downloader-plugin/api/downloader-plugin.api
Normal file
33
downloader-plugin/api/downloader-plugin.api
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
public abstract interface class app/revanced/manager/plugin/downloader/DownloaderPlugin {
|
||||||
|
public abstract fun createPagingSource (Lapp/revanced/manager/plugin/downloader/DownloaderPlugin$SearchParameters;)Landroidx/paging/PagingSource;
|
||||||
|
public abstract fun download (Lapp/revanced/manager/plugin/downloader/DownloaderPlugin$App;Lapp/revanced/manager/plugin/downloader/DownloaderPlugin$DownloadParameters;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||||
|
public abstract fun getPagingConfig ()Landroidx/paging/PagingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract interface class app/revanced/manager/plugin/downloader/DownloaderPlugin$App : android/os/Parcelable {
|
||||||
|
public abstract fun getPackageName ()Ljava/lang/String;
|
||||||
|
public abstract fun getVersion ()Ljava/lang/String;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class app/revanced/manager/plugin/downloader/DownloaderPlugin$DownloadParameters {
|
||||||
|
public fun <init> (Ljava/io/File;Lkotlin/jvm/functions/Function2;)V
|
||||||
|
public final fun getOnDownloadProgress ()Lkotlin/jvm/functions/Function2;
|
||||||
|
public final fun getTargetFile ()Ljava/io/File;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class app/revanced/manager/plugin/downloader/DownloaderPlugin$Parameters {
|
||||||
|
public fun <init> (Landroid/content/Context;Ljava/io/File;)V
|
||||||
|
public final fun getContext ()Landroid/content/Context;
|
||||||
|
public final fun getTempDirectory ()Ljava/io/File;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class app/revanced/manager/plugin/downloader/DownloaderPlugin$SearchParameters {
|
||||||
|
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
|
||||||
|
public final fun getPackageName ()Ljava/lang/String;
|
||||||
|
public final fun getVersionHint ()Ljava/lang/String;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class app/revanced/manager/plugin/downloader/UtilsKt {
|
||||||
|
public static final fun singlePagePagingSource (Lkotlin/jvm/functions/Function1;)Landroidx/paging/PagingSource;
|
||||||
|
}
|
||||||
|
|
36
downloader-plugin/build.gradle.kts
Normal file
36
downloader-plugin/build.gradle.kts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.revanced.manager.downloader_plugin"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(libs.paging.common.ktx)
|
||||||
|
}
|
0
downloader-plugin/consumer-rules.pro
Normal file
0
downloader-plugin/consumer-rules.pro
Normal file
21
downloader-plugin/proguard-rules.pro
vendored
Normal file
21
downloader-plugin/proguard-rules.pro
vendored
Normal 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
|
4
downloader-plugin/src/main/AndroidManifest.xml
Normal file
4
downloader-plugin/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
</manifest>
|
|
@ -0,0 +1,53 @@
|
||||||
|
package app.revanced.manager.plugin.downloader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.paging.PagingConfig
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Suppress("Unused")
|
||||||
|
/**
|
||||||
|
* The main interface for downloader plugins.
|
||||||
|
* Implementors must have a public constructor that takes exactly one argument of type [DownloaderPlugin.Parameters].
|
||||||
|
*/
|
||||||
|
interface DownloaderPlugin<A : DownloaderPlugin.App> {
|
||||||
|
val pagingConfig: PagingConfig
|
||||||
|
fun createPagingSource(parameters: SearchParameters): PagingSource<*, A>
|
||||||
|
suspend fun download(app: A, parameters: DownloadParameters)
|
||||||
|
|
||||||
|
interface App : Parcelable {
|
||||||
|
val packageName: String
|
||||||
|
val version: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin constructor parameters.
|
||||||
|
*
|
||||||
|
* @param context An Android [Context].
|
||||||
|
* @param tempDirectory The temporary directory belonging to this [DownloaderPlugin].
|
||||||
|
*/
|
||||||
|
class Parameters(val context: Context, val tempDirectory: File)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application pager parameters.
|
||||||
|
*
|
||||||
|
* @param packageName The package name to search for.
|
||||||
|
* @param versionHint The preferred version to search for. It is not mandatory to respect this parameter.
|
||||||
|
*/
|
||||||
|
class SearchParameters(val packageName: String, val versionHint: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parameters for downloading apps.
|
||||||
|
*
|
||||||
|
* @param targetFile The location where the downloaded APK should be saved.
|
||||||
|
* @param onDownloadProgress A callback for reporting download progress.
|
||||||
|
*/
|
||||||
|
class DownloadParameters(
|
||||||
|
val targetFile: File,
|
||||||
|
val onDownloadProgress: suspend (progress: Pair<BytesReceived, BytesTotal>?) -> Unit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias BytesReceived = Int
|
||||||
|
typealias BytesTotal = Int
|
|
@ -0,0 +1,25 @@
|
||||||
|
package app.revanced.manager.plugin.downloader
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.paging.PagingState
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a [PagingSource] that loads one page containing the return value of [block].
|
||||||
|
*/
|
||||||
|
fun <A : DownloaderPlugin.App> singlePagePagingSource(block: suspend () -> List<A>): PagingSource<Nothing, A> =
|
||||||
|
object : PagingSource<Nothing, A>() {
|
||||||
|
override fun getRefreshKey(state: PagingState<Nothing, A>) = null
|
||||||
|
|
||||||
|
override suspend fun load(params: LoadParams<Nothing>) = try {
|
||||||
|
LoadResult.Page(
|
||||||
|
block(),
|
||||||
|
nextKey = null,
|
||||||
|
prevKey = null
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LoadResult.Error(e)
|
||||||
|
}
|
||||||
|
}
|
1
example-downloader-plugin/.gitignore
vendored
Normal file
1
example-downloader-plugin/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/build
|
43
example-downloader-plugin/build.gradle.kts
Normal file
43
example-downloader-plugin/build.gradle.kts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
id("kotlin-parcelize")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.revanced.manager.plugin.downloader.example"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "app.revanced.manager.plugin.downloader.example"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(project(":downloader-plugin"))
|
||||||
|
}
|
21
example-downloader-plugin/proguard-rules.pro
vendored
Normal file
21
example-downloader-plugin/proguard-rules.pro
vendored
Normal 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
|
16
example-downloader-plugin/src/main/AndroidManifest.xml
Normal file
16
example-downloader-plugin/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?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">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="app.revanced.manager.plugin.downloader.class"
|
||||||
|
android:value="app.revanced.manager.plugin.downloader.example.DownloaderPluginImpl" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
|
@ -0,0 +1,58 @@
|
||||||
|
package app.revanced.manager.plugin.downloader.example
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.paging.PagingConfig
|
||||||
|
import app.revanced.manager.plugin.downloader.DownloaderPlugin
|
||||||
|
import app.revanced.manager.plugin.downloader.singlePagePagingSource
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
|
||||||
|
@Suppress("Unused", "MemberVisibilityCanBePrivate")
|
||||||
|
class DownloaderPluginImpl(downloaderPluginParameters: DownloaderPlugin.Parameters) :
|
||||||
|
DownloaderPlugin<DownloaderPluginImpl.AppImpl> {
|
||||||
|
private val pm = downloaderPluginParameters.context.packageManager
|
||||||
|
|
||||||
|
private fun getPackageInfo(packageName: String) = try {
|
||||||
|
pm.getPackageInfo(packageName, 0)
|
||||||
|
} catch (_: PackageManager.NameNotFoundException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override val pagingConfig = PagingConfig(pageSize = 1)
|
||||||
|
|
||||||
|
override fun createPagingSource(parameters: DownloaderPlugin.SearchParameters) =
|
||||||
|
singlePagePagingSource {
|
||||||
|
val impl = withContext(Dispatchers.IO) { getPackageInfo(parameters.packageName) }?.let {
|
||||||
|
AppImpl(
|
||||||
|
parameters.packageName,
|
||||||
|
it.versionName,
|
||||||
|
it.applicationInfo.sourceDir
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
listOfNotNull(impl)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun download(
|
||||||
|
app: AppImpl, parameters: DownloaderPlugin.DownloadParameters
|
||||||
|
) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Files.copy(
|
||||||
|
Path(app.apkPath),
|
||||||
|
parameters.targetFile.toPath(),
|
||||||
|
StandardCopyOption.REPLACE_EXISTING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
class AppImpl(
|
||||||
|
override val packageName: String,
|
||||||
|
override val version: String,
|
||||||
|
internal val apkPath: String
|
||||||
|
) : DownloaderPlugin.App
|
||||||
|
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Example Downloader Plugin</string>
|
||||||
|
</resources>
|
|
@ -1,6 +1,7 @@
|
||||||
[versions]
|
[versions]
|
||||||
|
kotlin = "1.9.22"
|
||||||
ktx = "1.13.1"
|
ktx = "1.13.1"
|
||||||
material3 = "1.2.1"
|
material3 = "1.3.0-beta04"
|
||||||
ui-tooling = "1.6.8"
|
ui-tooling = "1.6.8"
|
||||||
viewmodel-lifecycle = "2.8.3"
|
viewmodel-lifecycle = "2.8.3"
|
||||||
splash-screen = "1.0.1"
|
splash-screen = "1.0.1"
|
||||||
|
@ -24,9 +25,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"
|
||||||
androidGradlePlugin = "8.3.2"
|
androidGradlePlugin = "8.3.2"
|
||||||
kotlinGradlePlugin = "1.9.22"
|
|
||||||
devToolsGradlePlugin = "1.9.22-1.0.17"
|
devToolsGradlePlugin = "1.9.22-1.0.17"
|
||||||
aboutLibrariesGradlePlugin = "11.1.1"
|
aboutLibrariesGradlePlugin = "11.1.1"
|
||||||
|
binary-compatibility-validator = "0.15.1"
|
||||||
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"
|
||||||
|
@ -44,6 +45,7 @@ runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-comp
|
||||||
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" }
|
compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "compose-activity" }
|
||||||
paging-common-ktx = { group = "androidx.paging", name = "paging-common-ktx", version.ref = "paging" }
|
paging-common-ktx = { group = "androidx.paging", name = "paging-common-ktx", version.ref = "paging" }
|
||||||
|
paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
|
||||||
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" }
|
||||||
|
|
||||||
|
@ -130,6 +132,8 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons",
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinGradlePlugin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
devtools = { id = "com.google.devtools.ksp", version.ref = "devToolsGradlePlugin" }
|
devtools = { id = "com.google.devtools.ksp", version.ref = "devToolsGradlePlugin" }
|
||||||
about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibrariesGradlePlugin" }
|
about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibrariesGradlePlugin" }
|
||||||
|
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
|
||||||
|
binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" }
|
|
@ -26,3 +26,5 @@ dependencyResolutionManagement {
|
||||||
}
|
}
|
||||||
rootProject.name = "ReVanced Manager"
|
rootProject.name = "ReVanced Manager"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":downloader-plugin")
|
||||||
|
include(":example-downloader-plugin")
|
||||||
|
|
Loading…
Reference in a new issue