feat: implement downloader plugin system

This commit is contained in:
Ax333l 2024-07-12 15:25:34 +02:00
parent 36f864efbb
commit 2b426ecd62
No known key found for this signature in database
GPG key ID: D2B4D85271127D23
49 changed files with 1469 additions and 556 deletions

View file

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

View file

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

View file

@ -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')"
]
}
}

View file

@ -2,9 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="ReservedSystemPermission" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@ -17,12 +16,6 @@
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
</queries>
<application
android:name=".ManagerApplication"
android:allowBackup="true"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,8 +19,6 @@ class PreferencesManager(
val keystoreCommonName = stringPreference("keystore_cn", KeystoreManager.DEFAULT)
val keystorePass = stringPreference("keystore_pass", KeystoreManager.DEFAULT)
val preferSplits = booleanPreference("prefer_splits", false)
val firstLaunch = booleanPreference("first_launch", true)
val managerAutoUpdates = booleanPreference("manager_auto_updates", false)

View file

@ -5,13 +5,14 @@ import android.content.Context
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.apps.downloaded.DownloadedApp
import app.revanced.manager.network.downloader.AppDownloader
import app.revanced.manager.plugin.downloader.DownloaderPlugin
import kotlinx.coroutines.flow.distinctUntilChanged
import java.io.File
class DownloadedAppRepository(
app: Application,
db: AppDatabase
db: AppDatabase,
private val downloaderPluginRepository: DownloaderPluginRepository
) {
private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE)
private val dao = db.downloadedAppDao()
@ -21,10 +22,10 @@ class DownloadedAppRepository(
fun getApkFileForApp(app: DownloadedApp): File = getApkFileForDir(dir.resolve(app.directory))
private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first()
suspend fun download(
app: AppDownloader.App,
preferSplits: Boolean,
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit = {},
suspend fun <A : DownloaderPlugin.App> download(
plugin: DownloaderPlugin<A>,
app: A,
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> 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
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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

View file

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

View file

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

View file

@ -0,0 +1,79 @@
package app.revanced.manager.ui.component
import android.content.Intent
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import app.revanced.manager.R
import app.revanced.manager.ui.component.bundle.BundleTopBar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExceptionViewerDialog(text: String, onDismiss: () -> Unit) {
val context = LocalContext.current
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true
)
) {
Scaffold(
topBar = {
BundleTopBar(
title = stringResource(R.string.bundle_error),
onBackClick = onDismiss,
backIcon = {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
stringResource(R.string.back)
)
},
actions = {
IconButton(
onClick = {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(
Intent.EXTRA_TEXT,
text
)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
}
) {
Icon(
Icons.Outlined.Share,
contentDescription = stringResource(R.string.share)
)
}
}
)
}
) { paddingValues ->
ColumnWithScrollbar(
modifier = Modifier.padding(paddingValues)
) {
Text(text, modifier = Modifier.horizontalScroll(rememberScrollState()))
}
}
}
}

View file

@ -1,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()))
}
}
}
}

View file

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

View file

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

View file

@ -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<SelectedApp> {
it is SelectedApp.Local
}.thenByDescending { supportedVersions[it.version] }
.thenByDescending { it.version }
compareByDescending<SelectedApp> { 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<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))
}
}
}
}
)
}

View file

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

View file

@ -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<DownloadedApp> {
@ -23,24 +31,39 @@ class DownloadsViewModel(
}.thenBy { it.version }
)
}
val appSelection = mutableStateSetOf<DownloadedApp>()
val selection = mutableStateSetOf<DownloadedApp>()
var isRefreshingPlugins by mutableStateOf(false)
private set
fun toggleItem(downloadedApp: DownloadedApp) {
if (selection.contains(downloadedApp))
selection.remove(downloadedApp)
fun toggleApp(downloadedApp: DownloadedApp) {
if (appSelection.contains(downloadedApp))
appSelection.remove(downloadedApp)
else
selection.add(downloadedApp)
appSelection.add(downloadedApp)
}
fun delete() {
fun deleteApps() {
viewModelScope.launch(NonCancellable) {
downloadedAppRepository.delete(selection)
downloadedAppRepository.delete(appSelection)
withContext(Dispatchers.Main) {
selection.clear()
appSelection.clear()
}
}
}
fun refreshPlugins() = viewModelScope.launch {
isRefreshingPlugins = true
downloaderPluginRepository.reload()
isRefreshingPlugins = false
}
fun trustPlugin(packageInfo: PackageInfo) = viewModelScope.launch {
downloaderPluginRepository.trustPackage(packageInfo)
}
fun revokePluginTrust(packageName: String) = viewModelScope.launch {
downloaderPluginRepository.revokeTrustForPackage(packageName)
}
}

View file

@ -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<PackageInfo, InstalledApp?>? by mutableStateOf(null)
private set
var isLoading by mutableStateOf(true)
private set
var errorMessage: String? by mutableStateOf(null)
private set
var requiredVersion: String? by mutableStateOf(null)
private set
var selectedVersion: SelectedApp? by mutableStateOf(null)
private set
private var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp?>(null)
val showNonSuggestedVersionDialog by derivedStateOf { nonSuggestedVersionDialogSubject != null }
private val requiredVersionAsync = viewModelScope.async(Dispatchers.Default) {
if (!prefs.suggestedVersionSafeguard.get()) return@async null
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<SelectedApp.Download>()
val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps ->
downloadedApps.filter { it.packageName == packageName }.map {
SelectedApp.Local(
it.packageName,
it.version,
downloadedAppRepository.getApkFileForApp(it),
false
)
}
}
init {
viewModelScope.launch(Dispatchers.Main) {
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
val installedAppDeferred =
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
installedApp =
packageInfo.await()?.let {
it to installedAppDeferred.await()
}
}
viewModelScope.launch(Dispatchers.IO) {
try {
val compatibleVersions = supportedVersions.first()
appDownloader.getAvailableVersions(
packageName,
compatibleVersions.keys
).collect {
if (it.version in compatibleVersions || compatibleVersions.isEmpty()) {
downloadableVersions.add(
SelectedApp.Download(
packageName,
it.version,
it
)
)
}
}
withContext(Dispatchers.Main) {
isLoading = false
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Log.e(tag, "Failed to load apps", e)
errorMessage = e.simpleMessage()
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() {

View file

@ -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<AppInfo>{
compareByDescending<AppInfo> {
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<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 {
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<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()
suspend fun installApp(apks: List<File>) = 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
}
}

View file

@ -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_all">Reset 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="prefer_splits_description">Prefer split APK\'s instead of full APK\'s</string>
<string name="prefer_universal">Prefer universal APK\'s</string>
<string name="prefer_universal_description">Prefer universal instead of arch-specific APK\'s</string>
<string name="downloader_plugins">Plugins</string>
<string name="downloader_plugin_state_trusted">Trusted</string>
<string name="downloader_plugin_state_failed">Failed</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="loading_body">Loading…</string>
@ -237,6 +241,12 @@
<string name="already_downloaded">Already downloaded</string>
<string name="select_version">Select version</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="patch_selector_sheet_filter_title">Filter</string>

View file

@ -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"))
}

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

@ -0,0 +1 @@
/build

View 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;
}

View 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)
}

View file

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

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

View file

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

View file

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

View file

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

@ -0,0 +1 @@
/build

View 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"))
}

View file

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

View file

@ -0,0 +1,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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,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" }

View file

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