mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2024-11-10 01:01:56 +01:00
feat: implement downloader plugin system
This commit is contained in:
parent
36f864efbb
commit
2b426ecd62
49 changed files with 1469 additions and 556 deletions
|
@ -113,6 +113,7 @@ dependencies {
|
|||
implementation(libs.splash.screen)
|
||||
implementation(libs.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)
|
||||
|
||||
|
|
8
app/proguard-rules.pro
vendored
8
app/proguard-rules.pro
vendored
|
@ -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.**
|
||||
|
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package app.revanced.manager.data.room.plugins
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "trusted_downloader_plugins")
|
||||
data class TrustedDownloaderPlugin(
|
||||
@PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
|
||||
@ColumnInfo(name = "signature") val signature: String
|
||||
)
|
|
@ -0,0 +1,17 @@
|
|||
package app.revanced.manager.data.room.plugins
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
|
||||
@Dao
|
||||
interface TrustedDownloaderPluginDao {
|
||||
@Query("SELECT signature FROM trusted_downloader_plugins WHERE package_name = :packageName")
|
||||
suspend fun getTrustedSignature(packageName: String): String?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertTrust(plugin: TrustedDownloaderPlugin)
|
||||
|
||||
@Query("DELETE FROM trusted_downloader_plugins WHERE package_name = :packageName")
|
||||
suspend fun remove(packageName: String)
|
||||
}
|
|
@ -22,6 +22,7 @@ val repositoryModule = module {
|
|||
// It is best to load patch bundles ASAP
|
||||
createdAtStart()
|
||||
}
|
||||
singleOf(::DownloaderPluginRepository)
|
||||
singleOf(::WorkerRepository)
|
||||
singleOf(::DownloadedAppRepository)
|
||||
singleOf(::InstalledAppRepository)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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(
|
||||
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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
package app.revanced.manager.domain.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.Signature
|
||||
import android.util.Log
|
||||
import app.revanced.manager.data.platform.Filesystem
|
||||
import app.revanced.manager.data.room.AppDatabase
|
||||
import app.revanced.manager.data.room.plugins.TrustedDownloaderPlugin
|
||||
import app.revanced.manager.network.downloader.DownloaderPluginState
|
||||
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
|
||||
import app.revanced.manager.network.downloader.ParceledDownloaderApp
|
||||
import app.revanced.manager.plugin.downloader.DownloaderPlugin
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.tag
|
||||
import dalvik.system.PathClassLoader
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
class DownloaderPluginRepository(
|
||||
private val pm: PM,
|
||||
private val fs: Filesystem,
|
||||
private val context: Context,
|
||||
db: AppDatabase
|
||||
) {
|
||||
private val trustDao = db.trustedDownloaderPluginDao()
|
||||
private val _pluginStates = MutableStateFlow(emptyMap<String, DownloaderPluginState>())
|
||||
val pluginStates = _pluginStates.asStateFlow()
|
||||
val loadedPluginsFlow = pluginStates.map { states ->
|
||||
states.values.filterIsInstance<DownloaderPluginState.Loaded>().map { it.plugin }
|
||||
}
|
||||
|
||||
suspend fun reload() {
|
||||
val pluginPackages =
|
||||
withContext(Dispatchers.IO) {
|
||||
pm.getPackagesWithFeature(
|
||||
PLUGIN_FEATURE,
|
||||
flags = packageFlags
|
||||
)
|
||||
}
|
||||
|
||||
_pluginStates.value = pluginPackages.associate { it.packageName to loadPlugin(it) }
|
||||
}
|
||||
|
||||
fun unwrapParceledApp(app: ParceledDownloaderApp): Pair<LoadedDownloaderPlugin, DownloaderPlugin.App> {
|
||||
val plugin =
|
||||
(_pluginStates.value[app.pluginPackageName] as? DownloaderPluginState.Loaded)?.plugin
|
||||
?: throw Exception("Downloader plugin with name ${app.pluginPackageName} is not available")
|
||||
|
||||
return plugin to app.unwrapWith(plugin)
|
||||
}
|
||||
|
||||
private suspend fun loadPlugin(packageInfo: PackageInfo): DownloaderPluginState {
|
||||
try {
|
||||
if (!verify(packageInfo)) return DownloaderPluginState.Untrusted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Got exception while verifying plugin ${packageInfo.packageName}", e)
|
||||
return DownloaderPluginState.Failed(e)
|
||||
}
|
||||
|
||||
val pluginParameters = DownloaderPlugin.Parameters(
|
||||
context = context,
|
||||
tempDirectory = fs.tempDir.resolve("dl_plugin_${packageInfo.packageName}")
|
||||
.also(File::mkdir)
|
||||
)
|
||||
|
||||
return try {
|
||||
val pluginClassName =
|
||||
packageInfo.applicationInfo.metaData.getString(METADATA_PLUGIN_CLASS)
|
||||
?: throw Exception("Missing metadata attribute $METADATA_PLUGIN_CLASS")
|
||||
val classLoader = PathClassLoader(
|
||||
packageInfo.applicationInfo.sourceDir,
|
||||
DownloaderPlugin::class.java.classLoader
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val downloaderPluginClass =
|
||||
classLoader.loadClass(pluginClassName) as Class<DownloaderPlugin<DownloaderPlugin.App>>
|
||||
|
||||
val plugin = downloaderPluginClass
|
||||
.getDeclaredConstructor(DownloaderPlugin.Parameters::class.java)
|
||||
.newInstance(pluginParameters)
|
||||
|
||||
DownloaderPluginState.Loaded(
|
||||
LoadedDownloaderPlugin(
|
||||
packageInfo.packageName,
|
||||
with(pm) { packageInfo.label() },
|
||||
packageInfo.versionName,
|
||||
plugin,
|
||||
classLoader
|
||||
)
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
Log.e(tag, "Failed to load plugin ${packageInfo.packageName}", t)
|
||||
DownloaderPluginState.Failed(t)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun trustPackage(packageInfo: PackageInfo) {
|
||||
trustDao.upsertTrust(
|
||||
TrustedDownloaderPlugin(
|
||||
packageInfo.packageName,
|
||||
pm.getSignatures(packageInfo).first().toCharsString()
|
||||
)
|
||||
)
|
||||
reload()
|
||||
}
|
||||
|
||||
suspend fun revokeTrustForPackage(packageName: String) =
|
||||
trustDao.remove(packageName).also { reload() }
|
||||
|
||||
private suspend fun verify(packageInfo: PackageInfo): Boolean {
|
||||
val expectedSignature =
|
||||
trustDao.getTrustedSignature(packageInfo.packageName)?.let(::Signature) ?: return false
|
||||
|
||||
return expectedSignature in pm.getSignatures(packageInfo)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PLUGIN_FEATURE = "app.revanced.manager.plugin.downloader"
|
||||
const val METADATA_PLUGIN_CLASS = "app.revanced.manager.plugin.downloader.class"
|
||||
|
||||
val packageFlags = PackageManager.GET_META_DATA or PM.signaturesFlag
|
||||
}
|
||||
}
|
|
@ -1,277 +0,0 @@
|
|||
package app.revanced.manager.network.downloader
|
||||
|
||||
import android.os.Build.SUPPORTED_ABIS
|
||||
import app.revanced.manager.network.service.HttpService
|
||||
import io.ktor.client.plugins.onDownload
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.client.request.url
|
||||
import it.skrape.selects.html5.a
|
||||
import it.skrape.selects.html5.div
|
||||
import it.skrape.selects.html5.form
|
||||
import it.skrape.selects.html5.h5
|
||||
import it.skrape.selects.html5.input
|
||||
import it.skrape.selects.html5.p
|
||||
import it.skrape.selects.html5.span
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
class APKMirror : AppDownloader, KoinComponent {
|
||||
private val httpClient: HttpService = get()
|
||||
|
||||
enum class APKType {
|
||||
APK,
|
||||
BUNDLE
|
||||
}
|
||||
|
||||
data class Variant(
|
||||
val apkType: APKType,
|
||||
val arch: String,
|
||||
val link: String
|
||||
)
|
||||
|
||||
private suspend fun getAppLink(packageName: String): String {
|
||||
val searchResults = httpClient.getHtml { url("$APK_MIRROR/?post_type=app_release&searchtype=app&s=$packageName") }
|
||||
.div {
|
||||
withId = "content"
|
||||
findFirst {
|
||||
div {
|
||||
withClass = "listWidget"
|
||||
findAll {
|
||||
|
||||
find {
|
||||
it.children.first().text.contains(packageName)
|
||||
}!!.children.mapNotNull {
|
||||
if (it.classNames.isEmpty()) {
|
||||
it.h5 {
|
||||
withClass = "appRowTitle"
|
||||
findFirst {
|
||||
a {
|
||||
findFirst {
|
||||
attribute("href")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else null
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return searchResults.find { url ->
|
||||
httpClient.getHtml { url(APK_MIRROR + url) }
|
||||
.div {
|
||||
withId = "primary"
|
||||
findFirst {
|
||||
div {
|
||||
withClass = "tab-buttons"
|
||||
findFirst {
|
||||
div {
|
||||
withClass = "tab-button-positioning"
|
||||
findFirst {
|
||||
children.any {
|
||||
it.attribute("href") == "https://play.google.com/store/apps/details?id=$packageName"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: throw Exception("App isn't available for download")
|
||||
}
|
||||
|
||||
override fun getAvailableVersions(packageName: String, versionFilter: Set<String>) = flow<AppDownloader.App> {
|
||||
|
||||
// We have to hardcode some apps since there are multiple apps with that package name
|
||||
val appCategory = when (packageName) {
|
||||
"com.google.android.apps.youtube.music" -> "youtube-music"
|
||||
"com.google.android.youtube" -> "youtube"
|
||||
else -> getAppLink(packageName).split("/")[3]
|
||||
}
|
||||
|
||||
var page = 1
|
||||
|
||||
val versions = mutableListOf<String>()
|
||||
|
||||
while (
|
||||
if (versionFilter.isNotEmpty())
|
||||
versions.size < versionFilter.size && page <= 7
|
||||
else
|
||||
page <= 1
|
||||
) {
|
||||
httpClient.getHtml {
|
||||
url("$APK_MIRROR/uploads/page/$page/")
|
||||
parameter("appcategory", appCategory)
|
||||
}.div {
|
||||
withClass = "widget_appmanager_recentpostswidget"
|
||||
findFirst {
|
||||
div {
|
||||
withClass = "listWidget"
|
||||
findFirst {
|
||||
children.mapNotNull { element ->
|
||||
if (element.className.isEmpty()) {
|
||||
|
||||
APKMirrorApp(
|
||||
packageName = packageName,
|
||||
version = element.div {
|
||||
withClass = "infoSlide"
|
||||
findFirst {
|
||||
p {
|
||||
findFirst {
|
||||
span {
|
||||
withClass = "infoSlide-value"
|
||||
findFirst {
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
if (it in versionFilter)
|
||||
versions.add(it)
|
||||
},
|
||||
downloadLink = element.findFirst {
|
||||
a {
|
||||
withClass = "downloadLink"
|
||||
findFirst {
|
||||
attribute("href")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onEach { version -> emit(version) }
|
||||
|
||||
page++
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
private class APKMirrorApp(
|
||||
override val packageName: String,
|
||||
override val version: String,
|
||||
private val downloadLink: String,
|
||||
) : AppDownloader.App, KoinComponent {
|
||||
@IgnoredOnParcel private val httpClient: HttpService by inject()
|
||||
|
||||
override suspend fun download(
|
||||
saveDirectory: File,
|
||||
preferSplit: Boolean,
|
||||
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit
|
||||
) {
|
||||
val variants = httpClient.getHtml { url(APK_MIRROR + downloadLink) }
|
||||
.div {
|
||||
withClass = "variants-table"
|
||||
findFirst { // list of variants
|
||||
children.drop(1).map {
|
||||
Variant(
|
||||
apkType = it.div {
|
||||
findFirst {
|
||||
span {
|
||||
findFirst {
|
||||
enumValueOf(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
arch = it.div {
|
||||
findSecond {
|
||||
text
|
||||
}
|
||||
},
|
||||
link = it.div {
|
||||
findFirst {
|
||||
a {
|
||||
findFirst {
|
||||
attribute("href")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val orderedAPKTypes = mutableListOf(APKType.APK, APKType.BUNDLE)
|
||||
.also { if (preferSplit) it.reverse() }
|
||||
|
||||
val variant = orderedAPKTypes.firstNotNullOfOrNull { apkType ->
|
||||
supportedArches.firstNotNullOfOrNull { arch ->
|
||||
variants.find { it.arch == arch && it.apkType == apkType }
|
||||
}
|
||||
} ?: throw Exception("No compatible variant found")
|
||||
|
||||
if (variant.apkType == APKType.BUNDLE) throw Exception("Split apks are not supported yet") // TODO
|
||||
|
||||
val downloadPage = httpClient.getHtml { url(APK_MIRROR + variant.link) }
|
||||
.a {
|
||||
withClass = "downloadButton"
|
||||
findFirst {
|
||||
attribute("href")
|
||||
}
|
||||
}
|
||||
|
||||
val downloadLink = httpClient.getHtml { url(APK_MIRROR + downloadPage) }
|
||||
.form {
|
||||
withId = "filedownload"
|
||||
findFirst {
|
||||
val apkLink = attribute("action")
|
||||
val id = input {
|
||||
withAttribute = "name" to "id"
|
||||
findFirst {
|
||||
attribute("value")
|
||||
}
|
||||
}
|
||||
val key = input {
|
||||
withAttribute = "name" to "key"
|
||||
findFirst {
|
||||
attribute("value")
|
||||
}
|
||||
}
|
||||
"$apkLink?id=$id&key=$key"
|
||||
}
|
||||
}
|
||||
|
||||
val targetFile = saveDirectory.resolve("base.apk")
|
||||
|
||||
try {
|
||||
httpClient.download(targetFile) {
|
||||
url(APK_MIRROR + downloadLink)
|
||||
onDownload { bytesSentTotal, contentLength ->
|
||||
onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10))
|
||||
}
|
||||
}
|
||||
|
||||
if (variant.apkType == APKType.BUNDLE) {
|
||||
// TODO: Extract temp.zip
|
||||
|
||||
targetFile.delete()
|
||||
}
|
||||
} finally {
|
||||
onDownload(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val APK_MIRROR = "https://www.apkmirror.com"
|
||||
|
||||
val supportedArches = listOf("universal", "noarch") + SUPPORTED_ABIS
|
||||
}
|
||||
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
package app.revanced.manager.network.downloader
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.io.File
|
||||
|
||||
interface AppDownloader {
|
||||
|
||||
/**
|
||||
* Returns all downloadable apps.
|
||||
*
|
||||
* @param packageName The package name of the app.
|
||||
* @param versionFilter A set of versions to filter.
|
||||
*/
|
||||
fun getAvailableVersions(packageName: String, versionFilter: Set<String>): Flow<App>
|
||||
|
||||
interface App : Parcelable {
|
||||
val packageName: String
|
||||
val version: String
|
||||
|
||||
suspend fun download(
|
||||
saveDirectory: File,
|
||||
preferSplit: Boolean,
|
||||
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit = {}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package app.revanced.manager.network.downloader
|
||||
|
||||
sealed interface DownloaderPluginState {
|
||||
data object Untrusted : DownloaderPluginState
|
||||
|
||||
data class Loaded(val plugin: LoadedDownloaderPlugin) : DownloaderPluginState
|
||||
|
||||
data class Failed(val throwable: Throwable) : DownloaderPluginState
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package app.revanced.manager.network.downloader
|
||||
|
||||
import app.revanced.manager.plugin.downloader.DownloaderPlugin
|
||||
|
||||
class LoadedDownloaderPlugin(
|
||||
val packageName: String,
|
||||
val name: String,
|
||||
val version: String,
|
||||
private val instance: DownloaderPlugin<DownloaderPlugin.App>,
|
||||
val classLoader: ClassLoader
|
||||
) : DownloaderPlugin<DownloaderPlugin.App> by instance
|
|
@ -0,0 +1,46 @@
|
|||
package app.revanced.manager.network.downloader
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import app.revanced.manager.plugin.downloader.DownloaderPlugin
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
/**
|
||||
* A parceled [DownloaderPlugin.App]. Instances of this class can be safely stored in a bundle without needing to set the [ClassLoader].
|
||||
*/
|
||||
class ParceledDownloaderApp private constructor(
|
||||
val pluginPackageName: String,
|
||||
private val bundle: Bundle
|
||||
) : Parcelable {
|
||||
constructor(plugin: LoadedDownloaderPlugin, app: DownloaderPlugin.App) : this(
|
||||
plugin.packageName,
|
||||
createBundle(app)
|
||||
)
|
||||
|
||||
fun unwrapWith(plugin: LoadedDownloaderPlugin): DownloaderPlugin.App {
|
||||
bundle.classLoader = plugin.classLoader
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val className = bundle.getString(CLASS_NAME_KEY)!!
|
||||
val clazz = plugin.classLoader.loadClass(className)
|
||||
|
||||
bundle.getParcelable(APP_KEY, clazz)!! as DownloaderPlugin.App
|
||||
} else @Suppress("DEPRECATION") bundle.getParcelable(APP_KEY)!!
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CLASS_NAME_KEY = "class"
|
||||
const val APP_KEY = "app"
|
||||
|
||||
fun createBundle(app: DownloaderPlugin.App) = Bundle().apply {
|
||||
putParcelable(APP_KEY, app)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) putString(
|
||||
CLASS_NAME_KEY,
|
||||
app::class.java.canonicalName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ import app.revanced.manager.domain.installer.RootInstaller
|
|||
import app.revanced.manager.domain.manager.KeystoreManager
|
||||
import app.revanced.manager.domain.manager.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
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
package app.revanced.manager.ui.component
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Share
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.ui.component.bundle.BundleTopBar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExceptionViewerDialog(text: String, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true
|
||||
)
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BundleTopBar(
|
||||
title = stringResource(R.string.bundle_error),
|
||||
onBackClick = onDismiss,
|
||||
backIcon = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
stringResource(R.string.back)
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
text
|
||||
)
|
||||
type = "text/plain"
|
||||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Share,
|
||||
contentDescription = stringResource(R.string.share)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
ColumnWithScrollbar(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
Text(text, modifier = Modifier.horizontalScroll(rememberScrollState()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,21 +1,16 @@
|
|||
package app.revanced.manager.ui.component.bundle
|
||||
|
||||
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() }
|
||||
)
|
||||
|
@ -159,60 +153,3 @@ fun BundleInformationDialog(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BundleErrorViewerDialog(onDismiss: () -> Unit, text: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true
|
||||
)
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BundleTopBar(
|
||||
title = stringResource(R.string.bundle_error),
|
||||
onBackClick = onDismiss,
|
||||
backIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
text
|
||||
)
|
||||
type = "text/plain"
|
||||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Share,
|
||||
contentDescription = stringResource(R.string.share)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
ColumnWithScrollbar(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
Text(text, modifier = Modifier.horizontalScroll(rememberScrollState()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,13 +22,36 @@ fun SettingsListItem(
|
|||
colors: ListItemColors = ListItemDefaults.colors(),
|
||||
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 = {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,23 +164,54 @@ fun VersionSelectorScreen(
|
|||
)
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
GroupHeader(stringResource(R.string.downloadable_versions))
|
||||
}
|
||||
}
|
||||
if (downloadableVersions == null) {
|
||||
item {
|
||||
Text(stringResource(R.string.downloader_not_selected))
|
||||
}
|
||||
} else {
|
||||
(downloadableVersions.loadState.prepend as? LoadState.Error)?.let { errorState ->
|
||||
item {
|
||||
errorState.Render()
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
count = downloadableVersions.itemCount,
|
||||
key = downloadableVersions.itemKey { it.version }
|
||||
) {
|
||||
Text(stringResource(R.string.error_occurred))
|
||||
Text(
|
||||
text = viewModel.errorMessage!!,
|
||||
modifier = Modifier.padding(horizontal = 15.dp)
|
||||
val item = downloadableVersions[it]!!
|
||||
|
||||
SelectedAppItem(
|
||||
selectedApp = item,
|
||||
selected = viewModel.selectedVersion == item,
|
||||
onClick = { viewModel.select(item) },
|
||||
patchCount = supportedVersions[item.version]
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (viewModel.isLoading) {
|
||||
|
||||
val loadStates = arrayOf(
|
||||
downloadableVersions.loadState.append,
|
||||
downloadableVersions.loadState.refresh
|
||||
)
|
||||
|
||||
if (loadStates.any { it is LoadState.Loading }) {
|
||||
item {
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else if (downloadableVersions.itemCount == 0) {
|
||||
item { Text(stringResource(R.string.downloader_no_versions)) }
|
||||
}
|
||||
|
||||
loadStates.firstNotNullOfOrNull { it as? LoadState.Error }?.let { errorState ->
|
||||
item {
|
||||
errorState.Render()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -194,3 +256,82 @@ fun SelectedAppItem(
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadState.Error.Render() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val message =
|
||||
remember(error) { error.simpleMessage().orEmpty() }
|
||||
Text(stringResource(R.string.error_occurred))
|
||||
Text(
|
||||
text = message,
|
||||
modifier = Modifier.padding(horizontal = 15.dp)
|
||||
)
|
||||
Text(error.stackTraceToString())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloaderSelectionDialog(
|
||||
plugins: List<LoadedDownloaderPlugin>,
|
||||
hasInstalledPlugins: Boolean,
|
||||
onConfirm: (LoadedDownloaderPlugin) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var selectedPackageName: String? by rememberSaveable {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = selectedPackageName != null,
|
||||
onClick = { onConfirm(plugins.single { it.packageName == selectedPackageName }) }
|
||||
) {
|
||||
Text(stringResource(R.string.select))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.downloader_select))
|
||||
},
|
||||
icon = {
|
||||
Icon(Icons.Filled.Download, null)
|
||||
},
|
||||
// TODO: fix dialog header centering issue
|
||||
// textHorizontalPadding = PaddingValues(horizontal = if (plugins.isNotEmpty()) 0.dp else 24.dp),
|
||||
text = {
|
||||
LazyColumn {
|
||||
items(plugins, key = { it.packageName }) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { selectedPackageName = it.packageName },
|
||||
headlineContent = { Text(it.name) },
|
||||
leadingContent = {
|
||||
RadioButton(
|
||||
selected = selectedPackageName == it.packageName,
|
||||
onClick = { selectedPackageName = it.packageName }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (plugins.isEmpty()) {
|
||||
item {
|
||||
val resource =
|
||||
if (hasInstalledPlugins) R.string.downloader_no_plugins_available else R.string.downloader_no_plugins_installed
|
||||
|
||||
Text(stringResource(resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
|
@ -1,39 +1,61 @@
|
|||
package app.revanced.manager.ui.screen.settings
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
showDialog = false
|
||||
}
|
||||
|
||||
val packageInfo =
|
||||
remember(packageName) {
|
||||
viewModel.pm.getPackageInfo(
|
||||
packageName,
|
||||
flags = PM.signaturesFlag
|
||||
)
|
||||
} ?: return@item
|
||||
|
||||
if (showDialog) {
|
||||
val signature =
|
||||
remember(packageInfo) {
|
||||
val androidSignature =
|
||||
viewModel.pm.getSignatures(packageInfo).first()
|
||||
val hash = MessageDigest.getInstance("SHA-256")
|
||||
.digest(androidSignature.toByteArray())
|
||||
hash.toHexString(format = HexFormat.UpperCase)
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is DownloaderPluginState.Loaded -> TrustDialog(
|
||||
title = R.string.downloader_plugin_revoke_trust_dialog_title,
|
||||
body = stringResource(
|
||||
R.string.downloader_plugin_trust_dialog_body,
|
||||
packageName,
|
||||
signature
|
||||
),
|
||||
onDismiss = ::dismiss,
|
||||
onConfirm = {
|
||||
viewModel.revokePluginTrust(packageName)
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
|
||||
GroupHeader(stringResource(R.string.downloaded_apps))
|
||||
is DownloaderPluginState.Failed -> ExceptionViewerDialog(
|
||||
text = remember(state.throwable) {
|
||||
state.throwable.stackTraceToString()
|
||||
},
|
||||
onDismiss = ::dismiss
|
||||
)
|
||||
|
||||
downloadedApps.forEach { app ->
|
||||
val selected = app in viewModel.selection
|
||||
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 { viewModel.toggleItem(app) },
|
||||
modifier = Modifier.clickable { showDialog = true },
|
||||
headlineContent = {
|
||||
AppLabel(
|
||||
packageInfo = packageInfo,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
supportingContent = stringResource(
|
||||
when (state) {
|
||||
is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted
|
||||
is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed
|
||||
is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted
|
||||
}
|
||||
),
|
||||
trailingContent = { Text(packageInfo.versionName) }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (pluginStates.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.downloader_no_plugins_installed),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
GroupHeader(stringResource(R.string.downloaded_apps))
|
||||
}
|
||||
items(downloadedApps, key = { it.packageName to it.version }) { app ->
|
||||
val selected = app in viewModel.appSelection
|
||||
|
||||
SettingsListItem(
|
||||
modifier = Modifier.clickable { viewModel.toggleApp(app) },
|
||||
headlineContent = app.packageName,
|
||||
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) }
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,16 +91,37 @@ 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>()
|
||||
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 {
|
||||
downloadedApps
|
||||
.filter { it.packageName == packageName }
|
||||
.map {
|
||||
SelectedApp.Local(
|
||||
it.packageName,
|
||||
it.version,
|
||||
|
@ -115,47 +131,8 @@ class VersionSelectorViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
|
||||
val installedAppDeferred =
|
||||
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
|
||||
|
||||
installedApp =
|
||||
packageInfo.await()?.let {
|
||||
it to installedAppDeferred.await()
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val compatibleVersions = supportedVersions.first()
|
||||
|
||||
appDownloader.getAvailableVersions(
|
||||
packageName,
|
||||
compatibleVersions.keys
|
||||
).collect {
|
||||
if (it.version in compatibleVersions || compatibleVersions.isEmpty()) {
|
||||
downloadableVersions.add(
|
||||
SelectedApp.Download(
|
||||
packageName,
|
||||
it.version,
|
||||
it
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isLoading = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Log.e(tag, "Failed to load apps", e)
|
||||
errorMessage = e.simpleMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun selectDownloaderPlugin(plugin: LoadedDownloaderPlugin) {
|
||||
downloaderPlugin = plugin
|
||||
}
|
||||
|
||||
fun dismissNonSuggestedVersionDialog() {
|
||||
|
|
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
1
downloader-plugin/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
33
downloader-plugin/api/downloader-plugin.api
Normal file
33
downloader-plugin/api/downloader-plugin.api
Normal file
|
@ -0,0 +1,33 @@
|
|||
public abstract interface class app/revanced/manager/plugin/downloader/DownloaderPlugin {
|
||||
public abstract fun createPagingSource (Lapp/revanced/manager/plugin/downloader/DownloaderPlugin$SearchParameters;)Landroidx/paging/PagingSource;
|
||||
public abstract fun download (Lapp/revanced/manager/plugin/downloader/DownloaderPlugin$App;Lapp/revanced/manager/plugin/downloader/DownloaderPlugin$DownloadParameters;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
public abstract fun getPagingConfig ()Landroidx/paging/PagingConfig;
|
||||
}
|
||||
|
||||
public abstract interface class app/revanced/manager/plugin/downloader/DownloaderPlugin$App : android/os/Parcelable {
|
||||
public abstract fun getPackageName ()Ljava/lang/String;
|
||||
public abstract fun getVersion ()Ljava/lang/String;
|
||||
}
|
||||
|
||||
public final class app/revanced/manager/plugin/downloader/DownloaderPlugin$DownloadParameters {
|
||||
public fun <init> (Ljava/io/File;Lkotlin/jvm/functions/Function2;)V
|
||||
public final fun getOnDownloadProgress ()Lkotlin/jvm/functions/Function2;
|
||||
public final fun getTargetFile ()Ljava/io/File;
|
||||
}
|
||||
|
||||
public final class app/revanced/manager/plugin/downloader/DownloaderPlugin$Parameters {
|
||||
public fun <init> (Landroid/content/Context;Ljava/io/File;)V
|
||||
public final fun getContext ()Landroid/content/Context;
|
||||
public final fun getTempDirectory ()Ljava/io/File;
|
||||
}
|
||||
|
||||
public final class app/revanced/manager/plugin/downloader/DownloaderPlugin$SearchParameters {
|
||||
public fun <init> (Ljava/lang/String;Ljava/lang/String;)V
|
||||
public final fun getPackageName ()Ljava/lang/String;
|
||||
public final fun getVersionHint ()Ljava/lang/String;
|
||||
}
|
||||
|
||||
public final class app/revanced/manager/plugin/downloader/UtilsKt {
|
||||
public static final fun singlePagePagingSource (Lkotlin/jvm/functions/Function1;)Landroidx/paging/PagingSource;
|
||||
}
|
||||
|
36
downloader-plugin/build.gradle.kts
Normal file
36
downloader-plugin/build.gradle.kts
Normal file
|
@ -0,0 +1,36 @@
|
|||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.revanced.manager.downloader_plugin"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.paging.common.ktx)
|
||||
}
|
0
downloader-plugin/consumer-rules.pro
Normal file
0
downloader-plugin/consumer-rules.pro
Normal file
21
downloader-plugin/proguard-rules.pro
vendored
Normal file
21
downloader-plugin/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
4
downloader-plugin/src/main/AndroidManifest.xml
Normal file
4
downloader-plugin/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
|
@ -0,0 +1,53 @@
|
|||
package app.revanced.manager.plugin.downloader
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingSource
|
||||
import java.io.File
|
||||
|
||||
@Suppress("Unused")
|
||||
/**
|
||||
* The main interface for downloader plugins.
|
||||
* Implementors must have a public constructor that takes exactly one argument of type [DownloaderPlugin.Parameters].
|
||||
*/
|
||||
interface DownloaderPlugin<A : DownloaderPlugin.App> {
|
||||
val pagingConfig: PagingConfig
|
||||
fun createPagingSource(parameters: SearchParameters): PagingSource<*, A>
|
||||
suspend fun download(app: A, parameters: DownloadParameters)
|
||||
|
||||
interface App : Parcelable {
|
||||
val packageName: String
|
||||
val version: String
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin constructor parameters.
|
||||
*
|
||||
* @param context An Android [Context].
|
||||
* @param tempDirectory The temporary directory belonging to this [DownloaderPlugin].
|
||||
*/
|
||||
class Parameters(val context: Context, val tempDirectory: File)
|
||||
|
||||
/**
|
||||
* The application pager parameters.
|
||||
*
|
||||
* @param packageName The package name to search for.
|
||||
* @param versionHint The preferred version to search for. It is not mandatory to respect this parameter.
|
||||
*/
|
||||
class SearchParameters(val packageName: String, val versionHint: String?)
|
||||
|
||||
/**
|
||||
* The parameters for downloading apps.
|
||||
*
|
||||
* @param targetFile The location where the downloaded APK should be saved.
|
||||
* @param onDownloadProgress A callback for reporting download progress.
|
||||
*/
|
||||
class DownloadParameters(
|
||||
val targetFile: File,
|
||||
val onDownloadProgress: suspend (progress: Pair<BytesReceived, BytesTotal>?) -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
typealias BytesReceived = Int
|
||||
typealias BytesTotal = Int
|
|
@ -0,0 +1,25 @@
|
|||
package app.revanced.manager.plugin.downloader
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* Creates a [PagingSource] that loads one page containing the return value of [block].
|
||||
*/
|
||||
fun <A : DownloaderPlugin.App> singlePagePagingSource(block: suspend () -> List<A>): PagingSource<Nothing, A> =
|
||||
object : PagingSource<Nothing, A>() {
|
||||
override fun getRefreshKey(state: PagingState<Nothing, A>) = null
|
||||
|
||||
override suspend fun load(params: LoadParams<Nothing>) = try {
|
||||
LoadResult.Page(
|
||||
block(),
|
||||
nextKey = null,
|
||||
prevKey = null
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LoadResult.Error(e)
|
||||
}
|
||||
}
|
1
example-downloader-plugin/.gitignore
vendored
Normal file
1
example-downloader-plugin/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/build
|
43
example-downloader-plugin/build.gradle.kts
Normal file
43
example-downloader-plugin/build.gradle.kts
Normal file
|
@ -0,0 +1,43 @@
|
|||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.revanced.manager.plugin.downloader.example"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "app.revanced.manager.plugin.downloader.example"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
|
||||
if (project.hasProperty("signAsDebug")) {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(project(":downloader-plugin"))
|
||||
}
|
21
example-downloader-plugin/proguard-rules.pro
vendored
Normal file
21
example-downloader-plugin/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
16
example-downloader-plugin/src/main/AndroidManifest.xml
Normal file
16
example-downloader-plugin/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature android:name="app.revanced.manager.plugin.downloader" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
tools:targetApi="34">
|
||||
|
||||
<meta-data
|
||||
android:name="app.revanced.manager.plugin.downloader.class"
|
||||
android:value="app.revanced.manager.plugin.downloader.example.DownloaderPluginImpl" />
|
||||
</application>
|
||||
</manifest>
|
|
@ -0,0 +1,58 @@
|
|||
package app.revanced.manager.plugin.downloader.example
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.paging.PagingConfig
|
||||
import app.revanced.manager.plugin.downloader.DownloaderPlugin
|
||||
import app.revanced.manager.plugin.downloader.singlePagePagingSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import kotlin.io.path.Path
|
||||
|
||||
@Suppress("Unused", "MemberVisibilityCanBePrivate")
|
||||
class DownloaderPluginImpl(downloaderPluginParameters: DownloaderPlugin.Parameters) :
|
||||
DownloaderPlugin<DownloaderPluginImpl.AppImpl> {
|
||||
private val pm = downloaderPluginParameters.context.packageManager
|
||||
|
||||
private fun getPackageInfo(packageName: String) = try {
|
||||
pm.getPackageInfo(packageName, 0)
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
override val pagingConfig = PagingConfig(pageSize = 1)
|
||||
|
||||
override fun createPagingSource(parameters: DownloaderPlugin.SearchParameters) =
|
||||
singlePagePagingSource {
|
||||
val impl = withContext(Dispatchers.IO) { getPackageInfo(parameters.packageName) }?.let {
|
||||
AppImpl(
|
||||
parameters.packageName,
|
||||
it.versionName,
|
||||
it.applicationInfo.sourceDir
|
||||
)
|
||||
}
|
||||
|
||||
listOfNotNull(impl)
|
||||
}
|
||||
|
||||
override suspend fun download(
|
||||
app: AppImpl, parameters: DownloaderPlugin.DownloadParameters
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Files.copy(
|
||||
Path(app.apkPath),
|
||||
parameters.targetFile.toPath(),
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class AppImpl(
|
||||
override val packageName: String,
|
||||
override val version: String,
|
||||
internal val apkPath: String
|
||||
) : DownloaderPlugin.App
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
|
@ -0,0 +1,30 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<string name="app_name">Example Downloader Plugin</string>
|
||||
</resources>
|
|
@ -1,6 +1,7 @@
|
|||
[versions]
|
||||
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" }
|
|
@ -26,3 +26,5 @@ dependencyResolutionManagement {
|
|||
}
|
||||
rootProject.name = "ReVanced Manager"
|
||||
include(":app")
|
||||
include(":downloader-plugin")
|
||||
include(":example-downloader-plugin")
|
||||
|
|
Loading…
Reference in a new issue