From 2b426ecd62b7d35a47fd30b74618637dacbe3ab2 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Fri, 12 Jul 2024 15:25:34 +0200 Subject: [PATCH] feat: implement downloader plugin system --- app/build.gradle.kts | 4 + app/proguard-rules.pro | 8 + .../1.json | 30 +- app/src/main/AndroidManifest.xml | 11 +- .../revanced/manager/ManagerApplication.kt | 6 + .../revanced/manager/data/room/AppDatabase.kt | 8 +- .../room/plugins/TrustedDownloaderPlugin.kt | 11 + .../plugins/TrustedDownloaderPluginDao.kt | 17 ++ .../revanced/manager/di/RepositoryModule.kt | 1 + .../domain/manager/PreferencesManager.kt | 2 - .../repository/DownloadedAppRepository.kt | 41 ++- .../repository/DownloaderPluginRepository.kt | 135 +++++++++ .../manager/network/downloader/APKMirror.kt | 277 ------------------ .../network/downloader/AppDownloader.kt | 27 -- .../downloader/DownloaderPluginState.kt | 9 + .../downloader/LoadedDownloaderPlugin.kt | 11 + .../downloader/ParceledDownloaderApp.kt | 46 +++ .../manager/patcher/worker/PatcherWorker.kt | 10 +- .../ui/component/ExceptionViewerDialog.kt | 79 +++++ .../bundle/BundleInformationDialog.kt | 67 +---- .../ui/component/settings/SettingsListItem.kt | 25 +- .../revanced/manager/ui/model/SelectedApp.kt | 4 +- .../ui/screen/VersionSelectorScreen.kt | 193 ++++++++++-- .../settings/DownloadsSettingsScreen.kt | 206 +++++++++++-- .../ui/viewmodel/DownloadsViewModel.kt | 41 ++- .../ui/viewmodel/VersionSelectorViewModel.kt | 151 ++++------ .../main/java/app/revanced/manager/util/PM.kt | 42 ++- app/src/main/res/values/strings.xml | 18 +- build.gradle.kts | 6 + downloader-plugin/.gitignore | 1 + downloader-plugin/api/downloader-plugin.api | 33 +++ downloader-plugin/build.gradle.kts | 36 +++ downloader-plugin/consumer-rules.pro | 0 downloader-plugin/proguard-rules.pro | 21 ++ .../src/main/AndroidManifest.xml | 4 + .../plugin/downloader/DownloaderPlugin.kt | 53 ++++ .../manager/plugin/downloader/Utils.kt | 25 ++ example-downloader-plugin/.gitignore | 1 + example-downloader-plugin/build.gradle.kts | 43 +++ example-downloader-plugin/proguard-rules.pro | 21 ++ .../src/main/AndroidManifest.xml | 16 + .../example/DownloaderPluginImpl.kt | 58 ++++ .../res/drawable/ic_launcher_background.xml | 170 +++++++++++ .../res/drawable/ic_launcher_foreground.xml | 30 ++ .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + .../src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 10 +- settings.gradle.kts | 2 + 49 files changed, 1469 insertions(+), 556 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt create mode 100644 app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt create mode 100644 app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt delete mode 100644 app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt delete mode 100644 app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt create mode 100644 app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt create mode 100644 app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt create mode 100644 app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderApp.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt create mode 100644 downloader-plugin/.gitignore create mode 100644 downloader-plugin/api/downloader-plugin.api create mode 100644 downloader-plugin/build.gradle.kts create mode 100644 downloader-plugin/consumer-rules.pro create mode 100644 downloader-plugin/proguard-rules.pro create mode 100644 downloader-plugin/src/main/AndroidManifest.xml create mode 100644 downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/DownloaderPlugin.kt create mode 100644 downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Utils.kt create mode 100644 example-downloader-plugin/.gitignore create mode 100644 example-downloader-plugin/build.gradle.kts create mode 100644 example-downloader-plugin/proguard-rules.pro create mode 100644 example-downloader-plugin/src/main/AndroidManifest.xml create mode 100644 example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/DownloaderPluginImpl.kt create mode 100644 example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml create mode 100644 example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 example-downloader-plugin/src/main/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ee855884..1315999e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,6 +113,7 @@ dependencies { implementation(libs.splash.screen) implementation(libs.compose.activity) implementation(libs.paging.common.ktx) + implementation(libs.paging.compose) implementation(libs.work.runtime.ktx) implementation(libs.preferences.datastore) @@ -153,6 +154,9 @@ dependencies { implementation(libs.revanced.patcher) implementation(libs.revanced.library) + // Downloader plugins + implementation(project(":downloader-plugin")) + // Native processes implementation(libs.kotlin.process) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f284b52a..1b694947 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -49,6 +49,14 @@ -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 java.awt.** -dontwarn javax.** diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json index da13c490..e99d8430 100644 --- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "c0c780e55e10c9b095c004733c846b67", + "identityHash": "98837fd72fde0272894bce063c1095af", "entities": [ { "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": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c0c780e55e10c9b095c004733c846b67')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '98837fd72fde0272894bce063c1095af')" ] } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3acb1c04..8d16b0e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,8 @@ - - + @@ -17,12 +16,6 @@ tools:ignore="ScopedStorage" /> - - - - - - ?) -> Unit = {}, + suspend fun download( + plugin: DownloaderPlugin, + app: A, + onDownload: suspend (downloadProgress: Pair?) -> Unit, ): File { this.get(app.packageName, app.version)?.let { downloaded -> return getApkFileForApp(downloaded) @@ -35,13 +36,25 @@ class DownloadedAppRepository( val savePath = dir.resolve(relativePath).also { it.mkdirs() } 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( - packageName = app.packageName, - version = app.version, - directory = relativePath, - )) + onDownload(bytesReceived.megaBytes to bytesTotal.megaBytes) + } + ) + + plugin.download(app, parameters) + + dao.insert( + DownloadedApp( + packageName = app.packageName, + version = app.version, + directory = relativePath, + ) + ) } catch (e: Exception) { savePath.deleteRecursively() throw e @@ -60,4 +73,8 @@ class DownloadedAppRepository( dao.delete(downloadedApps) } + + private companion object { + val Int.megaBytes get() = div(100000).toFloat() / 10 + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt new file mode 100644 index 00000000..fc85cf0c --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt @@ -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()) + val pluginStates = _pluginStates.asStateFlow() + val loadedPluginsFlow = pluginStates.map { states -> + states.values.filterIsInstance().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 { + 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> + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt b/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt deleted file mode 100644 index 25365551..00000000 --- a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt +++ /dev/null @@ -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) = flow { - - // 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() - - 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?) -> 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 - } - -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt b/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt deleted file mode 100644 index dcefa26e..00000000 --- a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt +++ /dev/null @@ -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): Flow - - interface App : Parcelable { - val packageName: String - val version: String - - suspend fun download( - saveDirectory: File, - preferSplit: Boolean, - onDownload: suspend (downloadProgress: Pair?) -> Unit = {} - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt b/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt new file mode 100644 index 00000000..a72d60c7 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt new file mode 100644 index 00000000..9934a14d --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt @@ -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, + val classLoader: ClassLoader +) : DownloaderPlugin by instance \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderApp.kt b/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderApp.kt new file mode 100644 index 00000000..e4e40451 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderApp.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index bb91cf11..6c33f467 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -22,6 +22,7 @@ import app.revanced.manager.domain.installer.RootInstaller import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloadedAppRepository +import app.revanced.manager.domain.repository.DownloaderPluginRepository import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.worker.Worker import app.revanced.manager.domain.worker.WorkerRepository @@ -49,6 +50,7 @@ class PatcherWorker( private val workerRepository: WorkerRepository by inject() private val prefs: PreferencesManager by inject() private val keystoreManager: KeystoreManager by inject() + private val downloaderPluginRepository: DownloaderPluginRepository by inject() private val downloadedAppRepository: DownloadedAppRepository by inject() private val pm: PM by inject() private val fs: Filesystem by inject() @@ -143,10 +145,12 @@ class PatcherWorker( val inputFile = when (val selectedApp = args.input) { is SelectedApp.Download -> { + val (plugin, app) = downloaderPluginRepository.unwrapParceledApp(selectedApp.app) + downloadedAppRepository.download( - selectedApp.app, - prefs.preferSplits.get(), - onDownload = { args.downloadProgress.emit(it) } + plugin, + app, + onDownload = args.downloadProgress::emit ).also { args.setInputFile(it) updateProgress(state = State.COMPLETED) // Download APK diff --git a/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt new file mode 100644 index 00000000..1ceb9cef --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt @@ -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())) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt index 9a9573a5..1129abb1 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt @@ -1,21 +1,16 @@ package app.revanced.manager.ui.component.bundle -import android.content.Intent import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.outlined.DeleteOutline -import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.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.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,7 +19,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.window.Dialog 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.isDefault import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState -import app.revanced.manager.ui.component.ColumnWithScrollbar +import app.revanced.manager.ui.component.ExceptionViewerDialog import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -129,7 +123,7 @@ fun BundleInformationDialog( var showDialog by rememberSaveable { mutableStateOf(false) } - if (showDialog) BundleErrorViewerDialog( + if (showDialog) ExceptionViewerDialog( onDismiss = { showDialog = false }, text = remember(it) { it.stackTraceToString() } ) @@ -158,61 +152,4 @@ fun BundleInformationDialog( ) } } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BundleErrorViewerDialog(onDismiss: () -> Unit, text: String) { - val context = LocalContext.current - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties( - usePlatformDefaultWidth = false, - dismissOnBackPress = true - ) - ) { - Scaffold( - topBar = { - BundleTopBar( - title = stringResource(R.string.bundle_error), - onBackClick = onDismiss, - backIcon = { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.back) - ) - }, - actions = { - IconButton( - onClick = { - val sendIntent: Intent = Intent().apply { - action = Intent.ACTION_SEND - putExtra( - Intent.EXTRA_TEXT, - text - ) - type = "text/plain" - } - - val shareIntent = Intent.createChooser(sendIntent, null) - context.startActivity(shareIntent) - } - ) { - Icon( - Icons.Outlined.Share, - contentDescription = stringResource(R.string.share) - ) - } - } - ) - } - ) { paddingValues -> - ColumnWithScrollbar( - modifier = Modifier.padding(paddingValues) - ) { - Text(text, modifier = Modifier.horizontalScroll(rememberScrollState())) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt index 2d40dda7..7c680477 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt @@ -22,13 +22,36 @@ fun SettingsListItem( colors: ListItemColors = ListItemDefaults.colors(), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, -) = ListItem( +) = SettingsListItem( headlineContent = { Text( text = headlineContent, style = MaterialTheme.typography.titleLarge ) }, + modifier = modifier, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation +) + +@Composable +fun SettingsListItem( + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + overlineContent: @Composable (() -> Unit)? = null, + supportingContent: String? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), + tonalElevation: Dp = ListItemDefaults.Elevation, + shadowElevation: Dp = ListItemDefaults.Elevation, +) = ListItem( + headlineContent = headlineContent, modifier = modifier.then(Modifier.padding(horizontal = 8.dp)), overlineContent = overlineContent, supportingContent = { diff --git a/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt b/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt index 4e3e8807..9fa7a82f 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt @@ -1,7 +1,7 @@ package app.revanced.manager.ui.model import android.os.Parcelable -import app.revanced.manager.network.downloader.AppDownloader +import app.revanced.manager.network.downloader.ParceledDownloaderApp import kotlinx.parcelize.Parcelize import java.io.File @@ -10,7 +10,7 @@ sealed class SelectedApp : Parcelable { abstract val version: String @Parcelize - data class Download(override val packageName: String, override val version: String, val app: AppDownloader.App) : SelectedApp() + data class Download(override val packageName: String, override val version: String, val app: ParceledDownloaderApp) : SelectedApp() @Parcelize data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp() diff --git a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt index 69548d3c..08cc526c 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt @@ -6,21 +6,30 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.unit.dp 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.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.GroupHeader 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.viewmodel.VersionSelectorViewModel import app.revanced.manager.util.isScrollingUp +import app.revanced.manager.util.simpleMessage @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -48,21 +62,15 @@ fun VersionSelectorScreen( ) { val supportedVersions by viewModel.supportedVersions.collectAsStateWithLifecycle(emptyMap()) val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList()) + val downloadableVersions = viewModel.downloadableApps?.collectAsLazyPagingItems() - val list by remember { + val sortedDownloadedVersions by remember { derivedStateOf { - val apps = (downloadedVersions + viewModel.downloadableVersions) + downloadedVersions .distinctBy { it.version } .sortedWith( - compareByDescending { - it is SelectedApp.Local - }.thenByDescending { supportedVersions[it.version] } - .thenByDescending { it.version } + compareByDescending { 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 ) + 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() Scaffold( topBar = { AppTopBar( title = stringResource(R.string.select_version), + actions = { + IconButton(onClick = { showDownloaderSelectionDialog = true }) { + Icon(Icons.Filled.Download, stringResource(R.string.downloader_select)) + } + }, onBackClick = onBackClick, ) }, @@ -115,14 +146,14 @@ fun VersionSelectorScreen( } } - item { + if (sortedDownloadedVersions.isNotEmpty()) item { Row(Modifier.fillMaxWidth()) { - GroupHeader(stringResource(R.string.downloadable_versions)) + GroupHeader(stringResource(R.string.downloaded_versions)) } } items( - items = list, + items = sortedDownloadedVersions, key = { it.version } ) { SelectedAppItem( @@ -133,22 +164,53 @@ fun VersionSelectorScreen( ) } - if (viewModel.errorMessage != null) { + item { + Row(Modifier.fillMaxWidth()) { + GroupHeader(stringResource(R.string.downloadable_versions)) + } + } + if (downloadableVersions == null) { item { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(stringResource(R.string.error_occurred)) - Text( - text = viewModel.errorMessage!!, - modifier = Modifier.padding(horizontal = 15.dp) - ) + Text(stringResource(R.string.downloader_not_selected)) + } + } else { + (downloadableVersions.loadState.prepend as? LoadState.Error)?.let { errorState -> + item { + errorState.Render() } } - } else if (viewModel.isLoading) { - item { - LoadingIndicator() + + items( + count = downloadableVersions.itemCount, + key = downloadableVersions.itemKey { it.version } + ) { + val item = downloadableVersions[it]!! + + SelectedAppItem( + selectedApp = item, + selected = viewModel.selectedVersion == item, + onClick = { viewModel.select(item) }, + patchCount = supportedVersions[item.version] + ) + } + + val loadStates = arrayOf( + downloadableVersions.loadState.append, + downloadableVersions.loadState.refresh + ) + + if (loadStates.any { it is LoadState.Loading }) { + item { + 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() + } } } } @@ -193,4 +255,83 @@ fun SelectedAppItem( else this } ) +} + +@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, + 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)) + } + } + } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index 432c4808..3720e3a8 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -1,39 +1,61 @@ package app.revanced.manager.ui.screen.settings +import androidx.annotation.StringRes import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.pullToRefresh +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R +import app.revanced.manager.network.downloader.DownloaderPluginState +import app.revanced.manager.ui.component.AppLabel import app.revanced.manager.ui.component.AppTopBar -import app.revanced.manager.ui.component.ColumnWithScrollbar +import app.revanced.manager.ui.component.ExceptionViewerDialog import app.revanced.manager.ui.component.GroupHeader -import app.revanced.manager.ui.component.settings.BooleanItem +import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.viewmodel.DownloadsViewModel +import app.revanced.manager.util.PM import org.koin.androidx.compose.koinViewModel +import java.security.MessageDigest -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalStdlibApi::class) @Composable fun DownloadsSettingsScreen( onBackClick: () -> Unit, viewModel: DownloadsViewModel = koinViewModel() ) { - val prefs = viewModel.prefs - - val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(initialValue = emptyList()) + val pullRefreshState = rememberPullToRefreshState() + val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList()) + val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -41,8 +63,8 @@ fun DownloadsSettingsScreen( title = stringResource(R.string.downloads), onBackClick = onBackClick, actions = { - if (viewModel.selection.isNotEmpty()) { - IconButton(onClick = { viewModel.delete() }) { + if (viewModel.appSelection.isNotEmpty()) { + IconButton(onClick = { viewModel.deleteApps() }) { Icon(Icons.Default.Delete, stringResource(R.string.delete)) } } @@ -50,35 +72,179 @@ fun DownloadsSettingsScreen( ) } ) { paddingValues -> - ColumnWithScrollbar( + Box( + contentAlignment = Alignment.TopCenter, + modifier = Modifier + .padding(paddingValues) + .fillMaxWidth() + .zIndex(1f) + ) { + PullToRefreshDefaults.Indicator( + state = pullRefreshState, + isRefreshing = viewModel.isRefreshingPlugins + ) + } + + LazyColumnWithScrollbar( modifier = Modifier .fillMaxSize() .padding(paddingValues) + .pullToRefresh( + isRefreshing = viewModel.isRefreshingPlugins, + state = pullRefreshState, + onRefresh = viewModel::refreshPlugins + ) ) { - BooleanItem( - preference = prefs.preferSplits, - headline = R.string.prefer_splits, - description = R.string.prefer_splits_description, - ) + item { + GroupHeader(stringResource(R.string.downloader_plugins)) + } + pluginStates.forEach { (packageName, state) -> + item(key = packageName) { + var showDialog by rememberSaveable { + mutableStateOf(false) + } - GroupHeader(stringResource(R.string.downloaded_apps)) + fun dismiss() { + showDialog = false + } - downloadedApps.forEach { app -> - val selected = app in viewModel.selection + val packageInfo = + remember(packageName) { + viewModel.pm.getPackageInfo( + packageName, + 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() + } + ) + + is DownloaderPluginState.Failed -> ExceptionViewerDialog( + text = remember(state.throwable) { + state.throwable.stackTraceToString() + }, + onDismiss = ::dismiss + ) + + is DownloaderPluginState.Untrusted -> TrustDialog( + title = R.string.downloader_plugin_trust_dialog_title, + body = stringResource( + R.string.downloader_plugin_trust_dialog_body, + packageName, + signature + ), + onDismiss = ::dismiss, + onConfirm = { + viewModel.trustPlugin(packageInfo) + dismiss() + } + ) + } + } + + SettingsListItem( + modifier = Modifier.clickable { showDialog = true }, + headlineContent = { + AppLabel( + packageInfo = packageInfo, + style = MaterialTheme.typography.titleLarge + ) + }, + supportingContent = stringResource( + when (state) { + is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted + is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed + is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted + } + ), + trailingContent = { Text(packageInfo.versionName) } + ) + } + } + if (pluginStates.isEmpty()) { + item { + Text( + stringResource(R.string.downloader_no_plugins_installed), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + + item { + GroupHeader(stringResource(R.string.downloaded_apps)) + } + items(downloadedApps, key = { it.packageName to it.version }) { app -> + val selected = app in viewModel.appSelection SettingsListItem( - modifier = Modifier.clickable { viewModel.toggleItem(app) }, + modifier = Modifier.clickable { viewModel.toggleApp(app) }, headlineContent = app.packageName, leadingContent = (@Composable { Checkbox( checked = selected, - onCheckedChange = { viewModel.toggleItem(app) } + onCheckedChange = { viewModel.toggleApp(app) } ) - }).takeIf { viewModel.selection.isNotEmpty() }, + }).takeIf { viewModel.appSelection.isNotEmpty() }, supportingContent = app.version, tonalElevation = if (selected) 8.dp else 0.dp ) } + if (downloadedApps.isEmpty()) { + item { + Text( + stringResource(R.string.downloader_settings_no_apps), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } } } +} + +@Composable +private fun TrustDialog( + @StringRes title: Int, + body: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.continue_)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.dismiss)) + } + }, + title = { Text(stringResource(title)) }, + text = { Text(body) } + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt index 4688cf16..a7433d3c 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -1,10 +1,16 @@ package app.revanced.manager.ui.viewmodel +import android.content.pm.PackageInfo +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.revanced.manager.data.room.apps.downloaded.DownloadedApp import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloadedAppRepository +import app.revanced.manager.domain.repository.DownloaderPluginRepository +import app.revanced.manager.util.PM import app.revanced.manager.util.mutableStateSetOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -14,8 +20,10 @@ import kotlinx.coroutines.withContext class DownloadsViewModel( private val downloadedAppRepository: DownloadedAppRepository, - val prefs: PreferencesManager + private val downloaderPluginRepository: DownloaderPluginRepository, + val pm: PM ) : ViewModel() { + val downloaderPluginStates = downloaderPluginRepository.pluginStates val downloadedApps = downloadedAppRepository.getAll().map { downloadedApps -> downloadedApps.sortedWith( compareBy { @@ -23,24 +31,39 @@ class DownloadsViewModel( }.thenBy { it.version } ) } + val appSelection = mutableStateSetOf() - val selection = mutableStateSetOf() + var isRefreshingPlugins by mutableStateOf(false) + private set - fun toggleItem(downloadedApp: DownloadedApp) { - if (selection.contains(downloadedApp)) - selection.remove(downloadedApp) + fun toggleApp(downloadedApp: DownloadedApp) { + if (appSelection.contains(downloadedApp)) + appSelection.remove(downloadedApp) else - selection.add(downloadedApp) + appSelection.add(downloadedApp) } - fun delete() { + fun deleteApps() { viewModelScope.launch(NonCancellable) { - downloadedAppRepository.delete(selection) + downloadedAppRepository.delete(appSelection) withContext(Dispatchers.Main) { - selection.clear() + appSelection.clear() } } } + fun refreshPlugins() = viewModelScope.launch { + isRefreshingPlugins = true + downloaderPluginRepository.reload() + isRefreshingPlugins = false + } + + fun trustPlugin(packageInfo: PackageInfo) = viewModelScope.launch { + downloaderPluginRepository.trustPackage(packageInfo) + } + + fun revokePluginTrust(packageName: String) = viewModelScope.launch { + downloaderPluginRepository.revokeTrustForPackage(packageName) + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt index d9f73264..aaee8c5f 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt @@ -1,33 +1,34 @@ package app.revanced.manager.ui.viewmodel import android.content.pm.PackageInfo -import android.util.Log import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.cachedIn +import androidx.paging.map import app.revanced.manager.data.room.apps.installed.InstalledApp import app.revanced.manager.domain.installer.RootInstaller import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloadedAppRepository +import app.revanced.manager.domain.repository.DownloaderPluginRepository import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.PatchBundleRepository -import app.revanced.manager.network.downloader.APKMirror -import app.revanced.manager.network.downloader.AppDownloader +import app.revanced.manager.plugin.downloader.DownloaderPlugin +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.util.PM -import app.revanced.manager.util.mutableStateSetOf -import app.revanced.manager.util.simpleMessage -import app.revanced.manager.util.tag +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -37,46 +38,40 @@ class VersionSelectorViewModel( private val downloadedAppRepository: DownloadedAppRepository by inject() private val installedAppRepository: InstalledAppRepository by inject() private val patchBundleRepository: PatchBundleRepository by inject() + private val downloaderPluginRepository: DownloaderPluginRepository by inject() private val pm: PM by inject() - private val prefs: PreferencesManager by inject() - private val appDownloader: AppDownloader = APKMirror() val rootInstaller: RootInstaller by inject() var installedApp: Pair? by mutableStateOf(null) private set - var isLoading by mutableStateOf(true) - private set - var errorMessage: String? by mutableStateOf(null) - private set - var requiredVersion: String? by mutableStateOf(null) private set - var selectedVersion: SelectedApp? by mutableStateOf(null) private set private var nonSuggestedVersionDialogSubject by mutableStateOf(null) val showNonSuggestedVersionDialog by derivedStateOf { nonSuggestedVersionDialogSubject != null } - private val requiredVersionAsync = viewModelScope.async(Dispatchers.Default) { - if (!prefs.suggestedVersionSafeguard.get()) return@async null + private var suggestedVersion: String? = 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 -> - requiredVersionAsync.await()?.let { version -> - // It is mandatory to use the suggested version if the safeguard is enabled. - return@supportedVersions mapOf( - version to bundles - .asSequence() - .flatMap { (_, bundle) -> bundle.patches } - .flatMap { it.compatiblePackages.orEmpty() } - .filter { it.packageName == packageName } - .count { it.versions.isNullOrEmpty() || version in it.versions } - ) - } - var patchesWithoutVersions = 0 bundles.flatMap { (_, bundle) -> @@ -96,66 +91,48 @@ class VersionSelectorViewModel( } }.flowOn(Dispatchers.Default) - init { - viewModelScope.launch { - requiredVersion = requiredVersionAsync.await() - } - } + val hasInstalledPlugins = downloaderPluginRepository.pluginStates.map { it.isNotEmpty() } + val downloadersFlow = downloaderPluginRepository.loadedPluginsFlow - val downloadableVersions = mutableStateSetOf() - - val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps -> - downloadedApps.filter { it.packageName == packageName }.map { - SelectedApp.Local( - it.packageName, - it.version, - downloadedAppRepository.getApkFileForApp(it), - false - ) - } - } - - init { - viewModelScope.launch(Dispatchers.Main) { - val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) } - val installedAppDeferred = - async(Dispatchers.IO) { installedAppRepository.get(packageName) } - - installedApp = - packageInfo.await()?.let { - it to installedAppDeferred.await() - } - } - - viewModelScope.launch(Dispatchers.IO) { - try { - val compatibleVersions = supportedVersions.first() - - appDownloader.getAvailableVersions( - packageName, - compatibleVersions.keys - ).collect { - if (it.version in compatibleVersions || compatibleVersions.isEmpty()) { - downloadableVersions.add( - SelectedApp.Download( - packageName, - it.version, - it - ) - ) - } - } - - withContext(Dispatchers.Main) { - isLoading = false - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Log.e(tag, "Failed to load apps", e) - errorMessage = e.simpleMessage() + 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 -> + downloadedApps + .filter { it.packageName == packageName } + .map { + SelectedApp.Local( + it.packageName, + it.version, + downloadedAppRepository.getApkFileForApp(it), + false + ) + } + } + + fun selectDownloaderPlugin(plugin: LoadedDownloaderPlugin) { + downloaderPlugin = plugin } fun dismissNonSuggestedVersionDialog() { diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt index 21a60b97..340221ad 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -8,8 +8,9 @@ import android.content.Intent import android.content.pm.PackageInfo import android.content.pm.PackageInstaller 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.Signature import android.os.Build import android.os.Parcelable import androidx.compose.runtime.Immutable @@ -36,7 +37,7 @@ data class AppInfo( ) : Parcelable @SuppressLint("QueryPermissionsNeeded") -@Suppress("DEPRECATION") +@Suppress("Deprecation") class PM( private val app: Application, patchBundleRepository: PatchBundleRepository @@ -67,7 +68,7 @@ class PM( } val installedApps = scope.async { - app.packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES).map { packageInfo -> + getInstalledPackages().map { packageInfo -> AppInfo( packageInfo.packageName, 0, @@ -80,7 +81,7 @@ class PM( (compatibleApps.await() + installedApps.await()) .distinctBy { it.packageName } .sortedWith( - compareByDescending{ + compareByDescending { it.packageInfo != null && (it.patches ?: 0) > 0 }.thenByDescending { it.patches @@ -93,9 +94,24 @@ class PM( } }.flowOn(Dispatchers.IO) - fun getPackageInfo(packageName: String): PackageInfo? = + private fun getInstalledPackages(flags: Int = 0): List = + 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 { - 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) { null } @@ -113,6 +129,16 @@ class PM( return pkgInfo } + fun getSignatures(packageInfo: PackageInfo): Array { + 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() suspend fun installApp(apks: List) = withContext(Dispatchers.IO) { @@ -170,4 +196,8 @@ class PM( Intent(this, UninstallService::class.java), intentFlags ).intentSender + + companion object { + val signaturesFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else PackageManager.GET_SIGNATURES + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80743ccc..1619bb64 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -113,10 +113,14 @@ Resets patch options for all patches in a bundle Reset patch options Resets all patch options - Prefer split APK\'s - Prefer split APK\'s instead of full APK\'s - Prefer universal APK\'s - Prefer universal instead of arch-specific APK\'s + Plugins + Trusted + Failed + Untrusted + Trust plugin? + Revoke trust? + Package name: %1$s\nSignature (SHA-256): %2$s + No downloaded apps found Search apps… Loading… @@ -237,6 +241,12 @@ Already downloaded Select version Downloadable versions + Downloaded versions + Select downloader + No downloader selected + No downloadable versions found + No plugins installed. + No trusted plugins available for use. Check your settings. Already patched Filter diff --git a/build.gradle.kts b/build.gradle.kts index 89d27215..12a5ac9d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,10 @@ plugins { alias(libs.plugins.devtools) apply false alias(libs.plugins.kotlin.android) 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")) +} \ No newline at end of file diff --git a/downloader-plugin/.gitignore b/downloader-plugin/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/downloader-plugin/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/downloader-plugin/api/downloader-plugin.api b/downloader-plugin/api/downloader-plugin.api new file mode 100644 index 00000000..7f70954a --- /dev/null +++ b/downloader-plugin/api/downloader-plugin.api @@ -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 (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 (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 (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; +} + diff --git a/downloader-plugin/build.gradle.kts b/downloader-plugin/build.gradle.kts new file mode 100644 index 00000000..051baeff --- /dev/null +++ b/downloader-plugin/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/downloader-plugin/consumer-rules.pro b/downloader-plugin/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/downloader-plugin/proguard-rules.pro b/downloader-plugin/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/downloader-plugin/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/downloader-plugin/src/main/AndroidManifest.xml b/downloader-plugin/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/downloader-plugin/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/DownloaderPlugin.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/DownloaderPlugin.kt new file mode 100644 index 00000000..4981d1b7 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/DownloaderPlugin.kt @@ -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 { + 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?) -> Unit + ) +} + +typealias BytesReceived = Int +typealias BytesTotal = Int \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Utils.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Utils.kt new file mode 100644 index 00000000..5f63da00 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Utils.kt @@ -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 singlePagePagingSource(block: suspend () -> List): PagingSource = + object : PagingSource() { + override fun getRefreshKey(state: PagingState) = null + + override suspend fun load(params: LoadParams) = try { + LoadResult.Page( + block(), + nextKey = null, + prevKey = null + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LoadResult.Error(e) + } + } \ No newline at end of file diff --git a/example-downloader-plugin/.gitignore b/example-downloader-plugin/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/example-downloader-plugin/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/example-downloader-plugin/build.gradle.kts b/example-downloader-plugin/build.gradle.kts new file mode 100644 index 00000000..4130f96a --- /dev/null +++ b/example-downloader-plugin/build.gradle.kts @@ -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")) +} \ No newline at end of file diff --git a/example-downloader-plugin/proguard-rules.pro b/example-downloader-plugin/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/example-downloader-plugin/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/example-downloader-plugin/src/main/AndroidManifest.xml b/example-downloader-plugin/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e904cdff --- /dev/null +++ b/example-downloader-plugin/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/DownloaderPluginImpl.kt b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/DownloaderPluginImpl.kt new file mode 100644 index 00000000..9216a086 --- /dev/null +++ b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/DownloaderPluginImpl.kt @@ -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 { + 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 +} \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml b/example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml b/example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/values/strings.xml b/example-downloader-plugin/src/main/res/values/strings.xml new file mode 100644 index 00000000..4006549c --- /dev/null +++ b/example-downloader-plugin/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Example Downloader Plugin + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee400881..d8dd4f25 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] +kotlin = "1.9.22" ktx = "1.13.1" -material3 = "1.2.1" +material3 = "1.3.0-beta04" ui-tooling = "1.6.8" viewmodel-lifecycle = "2.8.3" splash-screen = "1.0.1" @@ -24,9 +25,9 @@ ktor = "2.3.9" markdown-renderer = "0.22.0" fading-edges = "1.0.4" androidGradlePlugin = "8.3.2" -kotlinGradlePlugin = "1.9.22" devToolsGradlePlugin = "1.9.22-1.0.17" aboutLibrariesGradlePlugin = "11.1.1" +binary-compatibility-validator = "0.15.1" coil = "2.6.0" app-icon-loader-coil = "1.5.0" 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" } 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-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } 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" } @@ -130,6 +132,8 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", [plugins] 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" } 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" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 79364a6e..043e6155 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,3 +26,5 @@ dependencyResolutionManagement { } rootProject.name = "ReVanced Manager" include(":app") +include(":downloader-plugin") +include(":example-downloader-plugin")