mirror of
https://github.com/revanced/revanced-manager.git
synced 2025-06-12 12:47:37 +02:00
feat: Add downloader plugin system (#2041)
This commit is contained in:
@ -113,9 +113,10 @@ dependencies {
|
||||
implementation(libs.runtime.ktx)
|
||||
implementation(libs.runtime.compose)
|
||||
implementation(libs.splash.screen)
|
||||
implementation(libs.compose.activity)
|
||||
implementation(libs.activity.compose)
|
||||
implementation(libs.work.runtime.ktx)
|
||||
implementation(libs.preferences.datastore)
|
||||
implementation(libs.appcompat)
|
||||
|
||||
// Compose
|
||||
implementation(platform(libs.compose.bom))
|
||||
@ -155,6 +156,9 @@ dependencies {
|
||||
implementation(libs.revanced.patcher)
|
||||
implementation(libs.revanced.library)
|
||||
|
||||
// Downloader plugins
|
||||
implementation(project(":downloader-plugin"))
|
||||
|
||||
// Native processes
|
||||
implementation(libs.kotlin.process)
|
||||
|
||||
@ -196,7 +200,7 @@ dependencies {
|
||||
// EnumUtil
|
||||
implementation(libs.enumutil)
|
||||
ksp(libs.enumutil.ksp)
|
||||
|
||||
|
||||
// Reorderable lists
|
||||
implementation(libs.reorderable)
|
||||
|
||||
|
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
@ -49,6 +49,10 @@
|
||||
-keep class com.android.** {
|
||||
*;
|
||||
}
|
||||
-keep class app.revanced.manager.plugin.** {
|
||||
*;
|
||||
}
|
||||
|
||||
-dontwarn com.google.auto.value.**
|
||||
-dontwarn java.awt.**
|
||||
-dontwarn javax.**
|
||||
|
@ -2,7 +2,7 @@
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "c385297c07ea54804dc8526c388f706d",
|
||||
"identityHash": "d0119047505da435972c5247181de675",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "patch_bundles",
|
||||
@ -144,7 +144,7 @@
|
||||
},
|
||||
{
|
||||
"tableName": "downloaded_app",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, PRIMARY KEY(`package_name`, `version`))",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `version` TEXT NOT NULL, `directory` TEXT NOT NULL, `last_used` INTEGER NOT NULL, PRIMARY KEY(`package_name`, `version`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
@ -163,6 +163,12 @@
|
||||
"columnName": "directory",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUsed",
|
||||
"columnName": "last_used",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
@ -386,12 +392,38 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "trusted_downloader_plugins",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `signature` BLOB NOT NULL, PRIMARY KEY(`package_name`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"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, 'c385297c07ea54804dc8526c388f706d')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd0119047505da435972c5247181de675')"
|
||||
]
|
||||
}
|
||||
}
|
@ -2,9 +2,16 @@
|
||||
<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" />
|
||||
<permission
|
||||
android:name="app.revanced.manager.permission.PLUGIN_HOST"
|
||||
android:protectionLevel="signature"
|
||||
android:label="@string/plugin_host_permission_label"
|
||||
android:description="@string/plugin_host_permission_description"
|
||||
/>
|
||||
|
||||
<uses-permission android:name="app.revanced.manager.permission.PLUGIN_HOST" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
@ -17,12 +24,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"
|
||||
@ -47,6 +48,8 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".plugin.downloader.webview.WebViewActivity" android:exported="false" android:theme="@style/Theme.WebViewActivity" />
|
||||
|
||||
<service android:name=".service.InstallService" />
|
||||
<service android:name=".service.UninstallService" />
|
||||
|
||||
|
@ -3,10 +3,12 @@ package app.revanced.manager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import app.revanced.manager.ui.destination.Destination
|
||||
import app.revanced.manager.ui.destination.SettingsDestination
|
||||
import app.revanced.manager.ui.screen.AppSelectorScreen
|
||||
@ -15,11 +17,11 @@ import app.revanced.manager.ui.screen.InstalledAppInfoScreen
|
||||
import app.revanced.manager.ui.screen.PatcherScreen
|
||||
import app.revanced.manager.ui.screen.SelectedAppInfoScreen
|
||||
import app.revanced.manager.ui.screen.SettingsScreen
|
||||
import app.revanced.manager.ui.screen.VersionSelectorScreen
|
||||
import app.revanced.manager.ui.theme.ReVancedManagerTheme
|
||||
import app.revanced.manager.ui.theme.Theme
|
||||
import app.revanced.manager.ui.viewmodel.MainViewModel
|
||||
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
|
||||
import app.revanced.manager.util.EventEffect
|
||||
import dev.olshevski.navigation.reimagined.AnimatedNavHost
|
||||
import dev.olshevski.navigation.reimagined.NavBackHandler
|
||||
import dev.olshevski.navigation.reimagined.navigate
|
||||
@ -35,6 +37,8 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
enableEdgeToEdge()
|
||||
installSplashScreen()
|
||||
|
||||
val vm: MainViewModel = getAndroidViewModel()
|
||||
@ -52,6 +56,10 @@ class MainActivity : ComponentActivity() {
|
||||
rememberNavController<Destination>(startDestination = Destination.Dashboard)
|
||||
NavBackHandler(navController)
|
||||
|
||||
EventEffect(vm.appSelectFlow) { app ->
|
||||
navController.navigate(Destination.SelectedApplicationInfo(app))
|
||||
}
|
||||
|
||||
AnimatedNavHost(
|
||||
controller = navController
|
||||
) { destination ->
|
||||
@ -59,9 +67,12 @@ class MainActivity : ComponentActivity() {
|
||||
is Destination.Dashboard -> DashboardScreen(
|
||||
onSettingsClick = { navController.navigate(Destination.Settings()) },
|
||||
onAppSelectorClick = { navController.navigate(Destination.AppSelector) },
|
||||
onUpdateClick = { navController.navigate(
|
||||
Destination.Settings(SettingsDestination.Update())
|
||||
) },
|
||||
onUpdateClick = {
|
||||
navController.navigate(Destination.Settings(SettingsDestination.Update()))
|
||||
},
|
||||
onDownloaderPluginClick = {
|
||||
navController.navigate(Destination.Settings(SettingsDestination.Downloads))
|
||||
},
|
||||
onAppClick = { installedApp ->
|
||||
navController.navigate(
|
||||
Destination.InstalledApplicationInfo(
|
||||
@ -72,14 +83,7 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
|
||||
is Destination.InstalledApplicationInfo -> InstalledAppInfoScreen(
|
||||
onPatchClick = { packageName, patchSelection ->
|
||||
navController.navigate(
|
||||
Destination.VersionSelector(
|
||||
packageName,
|
||||
patchSelection
|
||||
)
|
||||
)
|
||||
},
|
||||
onPatchClick = vm::selectApp,
|
||||
onBackClick = { navController.pop() },
|
||||
viewModel = getComposeViewModel { parametersOf(destination.installedApp) }
|
||||
)
|
||||
@ -90,35 +94,11 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
|
||||
is Destination.AppSelector -> AppSelectorScreen(
|
||||
onAppClick = { navController.navigate(Destination.VersionSelector(it)) },
|
||||
onStorageClick = {
|
||||
navController.navigate(
|
||||
Destination.SelectedApplicationInfo(
|
||||
it
|
||||
)
|
||||
)
|
||||
},
|
||||
onSelect = vm::selectApp,
|
||||
onStorageSelect = vm::selectApp,
|
||||
onBackClick = { navController.pop() }
|
||||
)
|
||||
|
||||
is Destination.VersionSelector -> VersionSelectorScreen(
|
||||
onBackClick = { navController.pop() },
|
||||
onAppClick = { selectedApp ->
|
||||
navController.navigate(
|
||||
Destination.SelectedApplicationInfo(
|
||||
selectedApp,
|
||||
destination.patchSelection,
|
||||
)
|
||||
)
|
||||
},
|
||||
viewModel = getComposeViewModel {
|
||||
parametersOf(
|
||||
destination.packageName,
|
||||
destination.patchSelection
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
is Destination.SelectedApplicationInfo -> SelectedAppInfoScreen(
|
||||
onPatchClick = { app, patches, options ->
|
||||
navController.navigate(
|
||||
|
@ -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()
|
||||
|
@ -12,4 +12,5 @@ data class DownloadedApp(
|
||||
@ColumnInfo(name = "package_name") val packageName: String,
|
||||
@ColumnInfo(name = "version") val version: String,
|
||||
@ColumnInfo(name = "directory") val directory: File,
|
||||
@ColumnInfo(name = "last_used") val lastUsed: Long = System.currentTimeMillis()
|
||||
)
|
@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
@ -14,8 +15,11 @@ interface DownloadedAppDao {
|
||||
@Query("SELECT * FROM downloaded_app WHERE package_name = :packageName AND version = :version")
|
||||
suspend fun get(packageName: String, version: String): DownloadedApp?
|
||||
|
||||
@Insert
|
||||
suspend fun insert(downloadedApp: DownloadedApp)
|
||||
@Upsert
|
||||
suspend fun upsert(downloadedApp: DownloadedApp)
|
||||
|
||||
@Query("UPDATE downloaded_app SET last_used = :newValue WHERE package_name = :packageName AND version = :version")
|
||||
suspend fun markUsed(packageName: String, version: String, newValue: Long = System.currentTimeMillis())
|
||||
|
||||
@Delete
|
||||
suspend fun delete(downloadedApps: Collection<DownloadedApp>)
|
||||
|
@ -0,0 +1,11 @@
|
||||
package app.revanced.manager.data.room.plugins
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "trusted_downloader_plugins")
|
||||
class TrustedDownloaderPlugin(
|
||||
@PrimaryKey @ColumnInfo(name = "package_name") val packageName: String,
|
||||
@ColumnInfo(name = "signature") val signature: ByteArray
|
||||
)
|
@ -0,0 +1,22 @@
|
||||
package app.revanced.manager.data.room.plugins
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
|
||||
@Dao
|
||||
interface TrustedDownloaderPluginDao {
|
||||
@Query("SELECT signature FROM trusted_downloader_plugins WHERE package_name = :packageName")
|
||||
suspend fun getTrustedSignature(packageName: String): ByteArray?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertTrust(plugin: TrustedDownloaderPlugin)
|
||||
|
||||
@Query("DELETE FROM trusted_downloader_plugins WHERE package_name = :packageName")
|
||||
suspend fun remove(packageName: String)
|
||||
|
||||
@Transaction
|
||||
@Query("DELETE FROM trusted_downloader_plugins WHERE package_name IN (:packageNames)")
|
||||
suspend fun removeAll(packageNames: Set<String>)
|
||||
}
|
@ -22,6 +22,7 @@ val repositoryModule = module {
|
||||
// It is best to load patch bundles ASAP
|
||||
createdAtStart()
|
||||
}
|
||||
singleOf(::DownloaderPluginRepository)
|
||||
singleOf(::WorkerRepository)
|
||||
singleOf(::DownloadedAppRepository)
|
||||
singleOf(::InstalledAppRepository)
|
||||
|
@ -12,7 +12,6 @@ val viewModelModule = module {
|
||||
viewModelOf(::SettingsViewModel)
|
||||
viewModelOf(::AdvancedSettingsViewModel)
|
||||
viewModelOf(::AppSelectorViewModel)
|
||||
viewModelOf(::VersionSelectorViewModel)
|
||||
viewModelOf(::PatcherViewModel)
|
||||
viewModelOf(::UpdateViewModel)
|
||||
viewModelOf(::ChangelogsViewModel)
|
||||
|
@ -18,8 +18,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)
|
||||
val showManagerUpdateDialogOnLaunch = booleanPreference("show_manager_update_dialog_on_launch", true)
|
||||
@ -28,4 +26,6 @@ class PreferencesManager(
|
||||
val disableSelectionWarning = booleanPreference("disable_selection_warning", false)
|
||||
val disableUniversalPatchWarning = booleanPreference("disable_universal_patch_warning", false)
|
||||
val suggestedVersionSafeguard = booleanPreference("suggested_version_safeguard", true)
|
||||
|
||||
val acknowledgedDownloaderPlugins = stringSetPreference("acknowledged_downloader_plugins", emptySet())
|
||||
}
|
||||
|
@ -26,6 +26,9 @@ abstract class BasePreferencesManager(private val context: Context, name: String
|
||||
protected fun stringPreference(key: String, default: String) =
|
||||
StringPreference(dataStore, key, default)
|
||||
|
||||
protected fun stringSetPreference(key: String, default: Set<String>) =
|
||||
StringSetPreference(dataStore, key, default)
|
||||
|
||||
protected fun booleanPreference(key: String, default: Boolean) =
|
||||
BooleanPreference(dataStore, key, default)
|
||||
|
||||
@ -52,6 +55,10 @@ class EditorContext(private val prefs: MutablePreferences) {
|
||||
var <T> Preference<T>.value
|
||||
get() = prefs.run { read() }
|
||||
set(value) = prefs.run { write(value) }
|
||||
|
||||
operator fun Preference<Set<String>>.plusAssign(value: String) = prefs.run {
|
||||
write(read() + value)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Preference<T>(
|
||||
@ -65,10 +72,12 @@ abstract class Preference<T>(
|
||||
|
||||
suspend fun get() = flow.first()
|
||||
fun getBlocking() = runBlocking { get() }
|
||||
|
||||
@Composable
|
||||
fun getAsState() = flow.collectAsStateWithLifecycle(initialValue = remember {
|
||||
getBlocking()
|
||||
})
|
||||
|
||||
suspend fun update(value: T) = dataStore.editor {
|
||||
this@Preference.value = value
|
||||
}
|
||||
@ -108,6 +117,14 @@ class StringPreference(
|
||||
override val key = stringPreferencesKey(key)
|
||||
}
|
||||
|
||||
class StringSetPreference(
|
||||
dataStore: DataStore<Preferences>,
|
||||
key: String,
|
||||
default: Set<String>
|
||||
) : BasePreference<Set<String>>(dataStore, default) {
|
||||
override val key = stringSetPreferencesKey(key)
|
||||
}
|
||||
|
||||
class BooleanPreference(
|
||||
dataStore: DataStore<Preferences>,
|
||||
key: String,
|
||||
|
@ -2,56 +2,126 @@ package app.revanced.manager.domain.repository
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
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.network.downloader.LoadedDownloaderPlugin
|
||||
import app.revanced.manager.plugin.downloader.OutputDownloadScope
|
||||
import app.revanced.manager.util.PM
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import java.io.File
|
||||
import java.io.FilterOutputStream
|
||||
import java.nio.file.StandardOpenOption
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.io.path.outputStream
|
||||
|
||||
class DownloadedAppRepository(
|
||||
app: Application,
|
||||
db: AppDatabase
|
||||
private val app: Application,
|
||||
db: AppDatabase,
|
||||
private val pm: PM
|
||||
) {
|
||||
private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE)
|
||||
private val dao = db.downloadedAppDao()
|
||||
|
||||
fun getAll() = dao.getAllApps().distinctUntilChanged()
|
||||
|
||||
fun getApkFileForApp(app: DownloadedApp): File = getApkFileForDir(dir.resolve(app.directory))
|
||||
fun getApkFileForApp(app: DownloadedApp): File =
|
||||
getApkFileForDir(dir.resolve(app.directory))
|
||||
|
||||
private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first()
|
||||
|
||||
suspend fun download(
|
||||
app: AppDownloader.App,
|
||||
preferSplits: Boolean,
|
||||
onDownload: suspend (downloadProgress: Pair<Float, Float>?) -> Unit = {},
|
||||
plugin: LoadedDownloaderPlugin,
|
||||
data: Parcelable,
|
||||
expectedPackageName: String,
|
||||
expectedVersion: String?,
|
||||
onDownload: suspend (downloadProgress: Pair<Long, Long?>) -> Unit,
|
||||
): File {
|
||||
this.get(app.packageName, app.version)?.let { downloaded ->
|
||||
return getApkFileForApp(downloaded)
|
||||
}
|
||||
|
||||
// Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here.
|
||||
val relativePath = File(generateUid().toString())
|
||||
val savePath = dir.resolve(relativePath).also { it.mkdirs() }
|
||||
val saveDir = dir.resolve(relativePath).also { it.mkdirs() }
|
||||
val targetFile = saveDir.resolve("base.apk").toPath()
|
||||
|
||||
try {
|
||||
app.download(savePath, preferSplits, onDownload)
|
||||
val downloadSize = AtomicLong(0)
|
||||
val downloadedBytes = AtomicLong(0)
|
||||
|
||||
dao.insert(DownloadedApp(
|
||||
packageName = app.packageName,
|
||||
version = app.version,
|
||||
directory = relativePath,
|
||||
))
|
||||
channelFlow {
|
||||
val scope = object : OutputDownloadScope {
|
||||
override val pluginPackageName = plugin.packageName
|
||||
override val hostPackageName = app.packageName
|
||||
override suspend fun reportSize(size: Long) {
|
||||
require(size > 0) { "Size must be greater than zero" }
|
||||
require(
|
||||
downloadSize.compareAndSet(
|
||||
0,
|
||||
size
|
||||
)
|
||||
) { "Download size has already been set" }
|
||||
send(downloadedBytes.get() to size)
|
||||
}
|
||||
}
|
||||
|
||||
fun emitProgress(bytes: Long) {
|
||||
val newValue = downloadedBytes.addAndGet(bytes)
|
||||
val totalSize = downloadSize.get()
|
||||
if (totalSize < 1) return
|
||||
trySend(newValue to totalSize).getOrThrow()
|
||||
}
|
||||
|
||||
targetFile.outputStream(StandardOpenOption.CREATE_NEW).buffered().use {
|
||||
val stream = object : FilterOutputStream(it) {
|
||||
override fun write(b: Int) = out.write(b).also { emitProgress(1) }
|
||||
|
||||
override fun write(b: ByteArray?, off: Int, len: Int) =
|
||||
out.write(b, off, len).also {
|
||||
emitProgress(
|
||||
(len - off).toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
plugin.download(scope, data, stream)
|
||||
}
|
||||
}
|
||||
.conflate()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.collect { (downloaded, size) -> onDownload(downloaded to size) }
|
||||
|
||||
if (downloadedBytes.get() < 1) error("Downloader did not download anything.")
|
||||
val pkgInfo =
|
||||
pm.getPackageInfo(targetFile.toFile()) ?: error("Downloaded APK file is invalid")
|
||||
if (pkgInfo.packageName != expectedPackageName) error("Downloaded APK has the wrong package name. Expected: $expectedPackageName, Actual: ${pkgInfo.packageName}")
|
||||
if (expectedVersion != null && pkgInfo.versionName != expectedVersion) error("Downloaded APK has the wrong version. Expected: $expectedVersion, Actual: ${pkgInfo.versionName}")
|
||||
|
||||
// Delete the previous copy (if present).
|
||||
dao.get(pkgInfo.packageName, pkgInfo.versionName!!)?.directory?.let {
|
||||
if (!dir.resolve(it).deleteRecursively()) throw Exception("Failed to delete existing directory")
|
||||
}
|
||||
dao.upsert(
|
||||
DownloadedApp(
|
||||
packageName = pkgInfo.packageName,
|
||||
version = pkgInfo.versionName!!,
|
||||
directory = relativePath,
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
savePath.deleteRecursively()
|
||||
saveDir.deleteRecursively()
|
||||
throw e
|
||||
}
|
||||
|
||||
// Return the Apk file.
|
||||
return getApkFileForDir(savePath)
|
||||
return getApkFileForDir(saveDir)
|
||||
}
|
||||
|
||||
suspend fun get(packageName: String, version: String) = dao.get(packageName, version)
|
||||
suspend fun get(packageName: String, version: String, markUsed: Boolean = false) =
|
||||
dao.get(packageName, version)?.also {
|
||||
if (markUsed) dao.markUsed(packageName, version)
|
||||
}
|
||||
|
||||
suspend fun delete(downloadedApps: Collection<DownloadedApp>) {
|
||||
downloadedApps.forEach {
|
||||
|
@ -0,0 +1,168 @@
|
||||
package app.revanced.manager.domain.repository
|
||||
|
||||
import android.app.Application
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import app.revanced.manager.data.room.AppDatabase
|
||||
import app.revanced.manager.data.room.plugins.TrustedDownloaderPlugin
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.network.downloader.DownloaderPluginState
|
||||
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
|
||||
import app.revanced.manager.network.downloader.ParceledDownloaderData
|
||||
import app.revanced.manager.plugin.downloader.DownloaderBuilder
|
||||
import app.revanced.manager.plugin.downloader.PluginHostApi
|
||||
import app.revanced.manager.plugin.downloader.Scope
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.tag
|
||||
import dalvik.system.PathClassLoader
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
@OptIn(PluginHostApi::class)
|
||||
class DownloaderPluginRepository(
|
||||
private val pm: PM,
|
||||
private val prefs: PreferencesManager,
|
||||
private val app: Application,
|
||||
db: AppDatabase
|
||||
) {
|
||||
private val trustDao = db.trustedDownloaderPluginDao()
|
||||
private val _pluginStates = MutableStateFlow(emptyMap<String, DownloaderPluginState>())
|
||||
val pluginStates = _pluginStates.asStateFlow()
|
||||
val loadedPluginsFlow = pluginStates.map { states ->
|
||||
states.values.filterIsInstance<DownloaderPluginState.Loaded>().map { it.plugin }
|
||||
}
|
||||
|
||||
private val acknowledgedDownloaderPlugins = prefs.acknowledgedDownloaderPlugins
|
||||
private val installedPluginPackageNames = MutableStateFlow(emptySet<String>())
|
||||
val newPluginPackageNames = combine(
|
||||
installedPluginPackageNames,
|
||||
acknowledgedDownloaderPlugins.flow
|
||||
) { installed, acknowledged ->
|
||||
installed subtract acknowledged
|
||||
}
|
||||
|
||||
suspend fun reload() {
|
||||
val plugins =
|
||||
withContext(Dispatchers.IO) {
|
||||
pm.getPackagesWithFeature(PLUGIN_FEATURE)
|
||||
.associate { it.packageName to loadPlugin(it.packageName) }
|
||||
}
|
||||
|
||||
_pluginStates.value = plugins
|
||||
installedPluginPackageNames.value = plugins.keys
|
||||
|
||||
val acknowledgedPlugins = acknowledgedDownloaderPlugins.get()
|
||||
val uninstalledPlugins = acknowledgedPlugins subtract installedPluginPackageNames.value
|
||||
if (uninstalledPlugins.isNotEmpty()) {
|
||||
Log.d(tag, "Uninstalled plugins: ${uninstalledPlugins.joinToString(", ")}")
|
||||
acknowledgedDownloaderPlugins.update(acknowledgedPlugins subtract uninstalledPlugins)
|
||||
trustDao.removeAll(uninstalledPlugins)
|
||||
}
|
||||
}
|
||||
|
||||
fun unwrapParceledData(data: ParceledDownloaderData): Pair<LoadedDownloaderPlugin, Parcelable> {
|
||||
val plugin =
|
||||
(_pluginStates.value[data.pluginPackageName] as? DownloaderPluginState.Loaded)?.plugin
|
||||
?: throw Exception("Downloader plugin with name ${data.pluginPackageName} is not available")
|
||||
|
||||
return plugin to data.unwrapWith(plugin)
|
||||
}
|
||||
|
||||
private suspend fun loadPlugin(packageName: String): DownloaderPluginState {
|
||||
try {
|
||||
if (!verify(packageName)) return DownloaderPluginState.Untrusted
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Got exception while verifying plugin $packageName", e)
|
||||
return DownloaderPluginState.Failed(e)
|
||||
}
|
||||
|
||||
return try {
|
||||
val packageInfo = pm.getPackageInfo(packageName, flags = PackageManager.GET_META_DATA)!!
|
||||
val className = packageInfo.applicationInfo!!.metaData.getString(METADATA_PLUGIN_CLASS)
|
||||
?: throw Exception("Missing metadata attribute $METADATA_PLUGIN_CLASS")
|
||||
|
||||
val classLoader =
|
||||
PathClassLoader(packageInfo.applicationInfo!!.sourceDir, app.classLoader)
|
||||
val pluginContext = app.createPackageContext(packageName, 0)
|
||||
|
||||
val downloader = classLoader
|
||||
.loadClass(className)
|
||||
.getDownloaderBuilder()
|
||||
.build(
|
||||
scopeImpl = object : Scope {
|
||||
override val hostPackageName = app.packageName
|
||||
override val pluginPackageName = pluginContext.packageName
|
||||
},
|
||||
context = pluginContext
|
||||
)
|
||||
|
||||
DownloaderPluginState.Loaded(
|
||||
LoadedDownloaderPlugin(
|
||||
packageName,
|
||||
with(pm) { packageInfo.label() },
|
||||
packageInfo.versionName!!,
|
||||
downloader.get,
|
||||
downloader.download,
|
||||
classLoader
|
||||
)
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (t: Throwable) {
|
||||
Log.e(tag, "Failed to load plugin $packageName", t)
|
||||
DownloaderPluginState.Failed(t)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun trustPackage(packageName: String) {
|
||||
trustDao.upsertTrust(
|
||||
TrustedDownloaderPlugin(
|
||||
packageName,
|
||||
pm.getSignature(packageName).toByteArray()
|
||||
)
|
||||
)
|
||||
|
||||
reload()
|
||||
prefs.edit {
|
||||
acknowledgedDownloaderPlugins += packageName
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun revokeTrustForPackage(packageName: String) =
|
||||
trustDao.remove(packageName).also { reload() }
|
||||
|
||||
suspend fun acknowledgeAllNewPlugins() =
|
||||
acknowledgedDownloaderPlugins.update(installedPluginPackageNames.value)
|
||||
|
||||
private suspend fun verify(packageName: String): Boolean {
|
||||
val expectedSignature =
|
||||
trustDao.getTrustedSignature(packageName) ?: return false
|
||||
|
||||
return pm.hasSignature(packageName, expectedSignature)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PLUGIN_FEATURE = "app.revanced.manager.plugin.downloader"
|
||||
const val METADATA_PLUGIN_CLASS = "app.revanced.manager.plugin.downloader.class"
|
||||
|
||||
const val PUBLIC_STATIC = Modifier.PUBLIC or Modifier.STATIC
|
||||
val Int.isPublicStatic get() = (this and PUBLIC_STATIC) == PUBLIC_STATIC
|
||||
val Class<*>.isDownloaderBuilder get() = DownloaderBuilder::class.java.isAssignableFrom(this)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun Class<*>.getDownloaderBuilder() =
|
||||
declaredMethods
|
||||
.firstOrNull { it.modifiers.isPublicStatic && it.returnType.isDownloaderBuilder && it.parameterTypes.isEmpty() }
|
||||
?.let { it(null) as DownloaderBuilder<Parcelable> }
|
||||
?: throw Exception("Could not find a valid downloader implementation in class $canonicalName")
|
||||
}
|
||||
}
|
@ -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,15 @@
|
||||
package app.revanced.manager.network.downloader
|
||||
|
||||
import android.os.Parcelable
|
||||
import app.revanced.manager.plugin.downloader.OutputDownloadScope
|
||||
import app.revanced.manager.plugin.downloader.GetScope
|
||||
import java.io.OutputStream
|
||||
|
||||
class LoadedDownloaderPlugin(
|
||||
val packageName: String,
|
||||
val name: String,
|
||||
val version: String,
|
||||
val get: suspend GetScope.(packageName: String, version: String?) -> Pair<Parcelable, String?>?,
|
||||
val download: suspend OutputDownloadScope.(data: Parcelable, outputStream: OutputStream) -> Unit,
|
||||
val classLoader: ClassLoader
|
||||
)
|
@ -0,0 +1,45 @@
|
||||
package app.revanced.manager.network.downloader
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
/**
|
||||
* A container for [Parcelable] data returned from downloaders. Instances of this class can be safely stored in a bundle without needing to set the [ClassLoader].
|
||||
*/
|
||||
class ParceledDownloaderData private constructor(
|
||||
val pluginPackageName: String,
|
||||
private val bundle: Bundle
|
||||
) : Parcelable {
|
||||
constructor(plugin: LoadedDownloaderPlugin, data: Parcelable) : this(
|
||||
plugin.packageName,
|
||||
createBundle(data)
|
||||
)
|
||||
|
||||
fun unwrapWith(plugin: LoadedDownloaderPlugin): Parcelable {
|
||||
bundle.classLoader = plugin.classLoader
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val className = bundle.getString(CLASS_NAME_KEY)!!
|
||||
val clazz = plugin.classLoader.loadClass(className)
|
||||
|
||||
bundle.getParcelable(DATA_KEY, clazz)!! as Parcelable
|
||||
} else @Suppress("Deprecation") bundle.getParcelable(DATA_KEY)!!
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CLASS_NAME_KEY = "class"
|
||||
const val DATA_KEY = "data"
|
||||
|
||||
fun createBundle(data: Parcelable) = Bundle().apply {
|
||||
putParcelable(DATA_KEY, data)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) putString(
|
||||
CLASS_NAME_KEY,
|
||||
data::class.java.canonicalName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -33,15 +33,14 @@ data class PatchInfo(
|
||||
fun compatibleWith(packageName: String) =
|
||||
compatiblePackages?.any { it.packageName == packageName } ?: true
|
||||
|
||||
fun supportsVersion(packageName: String, versionName: String): Boolean {
|
||||
fun supports(packageName: String, versionName: String?): Boolean {
|
||||
val packages = compatiblePackages ?: return true // Universal patch
|
||||
|
||||
return packages.any { pkg ->
|
||||
if (pkg.packageName != packageName) {
|
||||
return@any false
|
||||
}
|
||||
if (pkg.packageName != packageName) return@any false
|
||||
if (pkg.versions == null) return@any true
|
||||
|
||||
pkg.versions == null || pkg.versions.contains(versionName)
|
||||
versionName != null && versionName in pkg.versions
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
package app.revanced.manager.patcher.worker
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
@ -9,9 +10,11 @@ import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
@ -22,26 +25,35 @@ import app.revanced.manager.domain.installer.RootInstaller
|
||||
import app.revanced.manager.domain.manager.KeystoreManager
|
||||
import app.revanced.manager.domain.manager.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
|
||||
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
|
||||
import app.revanced.manager.patcher.logger.Logger
|
||||
import app.revanced.manager.patcher.runtime.CoroutineRuntime
|
||||
import app.revanced.manager.patcher.runtime.ProcessRuntime
|
||||
import app.revanced.manager.plugin.downloader.GetScope
|
||||
import app.revanced.manager.plugin.downloader.PluginHostApi
|
||||
import app.revanced.manager.plugin.downloader.UserInteractionException
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.ui.model.State
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import app.revanced.manager.util.tag
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.io.File
|
||||
|
||||
typealias ProgressEventHandler = (name: String?, state: State?, message: String?) -> Unit
|
||||
|
||||
@OptIn(PluginHostApi::class)
|
||||
class PatcherWorker(
|
||||
context: Context,
|
||||
parameters: WorkerParameters
|
||||
@ -49,20 +61,22 @@ 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()
|
||||
private val installedAppRepository: InstalledAppRepository by inject()
|
||||
private val rootInstaller: RootInstaller by inject()
|
||||
|
||||
data class Args(
|
||||
class Args(
|
||||
val input: SelectedApp,
|
||||
val output: String,
|
||||
val selectedPatches: PatchSelection,
|
||||
val options: Options,
|
||||
val logger: Logger,
|
||||
val downloadProgress: MutableStateFlow<Pair<Float, Float>?>,
|
||||
val downloadProgress: MutableStateFlow<Pair<Long, Long?>?>,
|
||||
val patchesProgress: MutableStateFlow<Pair<Int, Int>>,
|
||||
val handleStartActivityRequest: suspend (LoadedDownloaderPlugin, Intent) -> ActivityResult,
|
||||
val setInputFile: (File) -> Unit,
|
||||
val onProgress: ProgressEventHandler
|
||||
) {
|
||||
@ -141,16 +155,57 @@ class PatcherWorker(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun download(plugin: LoadedDownloaderPlugin, data: Parcelable) =
|
||||
downloadedAppRepository.download(
|
||||
plugin,
|
||||
data,
|
||||
args.packageName,
|
||||
args.input.version,
|
||||
onDownload = args.downloadProgress::emit
|
||||
).also {
|
||||
args.setInputFile(it)
|
||||
updateProgress(state = State.COMPLETED) // Download APK
|
||||
}
|
||||
|
||||
val inputFile = when (val selectedApp = args.input) {
|
||||
is SelectedApp.Download -> {
|
||||
downloadedAppRepository.download(
|
||||
selectedApp.app,
|
||||
prefs.preferSplits.get(),
|
||||
onDownload = { args.downloadProgress.emit(it) }
|
||||
).also {
|
||||
args.setInputFile(it)
|
||||
updateProgress(state = State.COMPLETED) // Download APK
|
||||
}
|
||||
val (plugin, data) = downloaderPluginRepository.unwrapParceledData(selectedApp.data)
|
||||
|
||||
download(plugin, data)
|
||||
}
|
||||
|
||||
is SelectedApp.Search -> {
|
||||
downloaderPluginRepository.loadedPluginsFlow.first()
|
||||
.firstNotNullOfOrNull { plugin ->
|
||||
try {
|
||||
val getScope = object : GetScope {
|
||||
override val pluginPackageName = plugin.packageName
|
||||
override val hostPackageName = applicationContext.packageName
|
||||
override suspend fun requestStartActivity(intent: Intent): Intent? {
|
||||
val result = args.handleStartActivityRequest(plugin, intent)
|
||||
return when (result.resultCode) {
|
||||
Activity.RESULT_OK -> result.data
|
||||
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
|
||||
else -> throw UserInteractionException.Activity.NotCompleted(
|
||||
result.resultCode,
|
||||
result.data
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
plugin.get(
|
||||
getScope,
|
||||
selectedApp.packageName,
|
||||
selectedApp.version
|
||||
)
|
||||
}?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version }
|
||||
} catch (e: UserInteractionException.Activity.NotCompleted) {
|
||||
throw e
|
||||
} catch (_: UserInteractionException) {
|
||||
null
|
||||
}?.let { (data, _) -> download(plugin, data) }
|
||||
} ?: throw Exception("App is not available.")
|
||||
}
|
||||
|
||||
is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) }
|
||||
@ -184,7 +239,10 @@ class PatcherWorker(
|
||||
Log.i(tag, "Patching succeeded".logFmt())
|
||||
Result.success()
|
||||
} catch (e: ProcessRuntime.RemoteFailureException) {
|
||||
Log.e(tag, "An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt())
|
||||
Log.e(
|
||||
tag,
|
||||
"An exception occurred in the remote process while patching. ${e.originalStackTrace}".logFmt()
|
||||
)
|
||||
updateProgress(state = State.FAILED, message = e.originalStackTrace)
|
||||
Result.failure()
|
||||
} catch (e: Exception) {
|
||||
|
@ -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,13 +1,15 @@
|
||||
package app.revanced.manager.ui.component
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarColors
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@ -27,29 +29,38 @@ fun SearchView(
|
||||
placeholder: (@Composable () -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val colors = SearchBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
dividerColor = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = onQueryChange,
|
||||
onSearch = {
|
||||
keyboardController?.hide()
|
||||
},
|
||||
active = true,
|
||||
onActiveChange = onActiveChange,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester),
|
||||
placeholder = placeholder,
|
||||
leadingIcon = {
|
||||
IconButton({ onActiveChange(false) }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = query,
|
||||
onQueryChange = onQueryChange,
|
||||
onSearch = {
|
||||
keyboardController?.hide()
|
||||
},
|
||||
expanded = true,
|
||||
onExpandedChange = onActiveChange,
|
||||
placeholder = placeholder,
|
||||
leadingIcon = {
|
||||
IconButton(onClick = { onActiveChange(false) }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
stringResource(R.string.back)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
expanded = true,
|
||||
onExpandedChange = onActiveChange,
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
colors = colors,
|
||||
content = content
|
||||
)
|
||||
|
||||
|
@ -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.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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
|
||||
@ -26,7 +21,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.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)
|
||||
@ -119,7 +114,7 @@ fun BundleInformationDialog(
|
||||
var showDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
if (showDialog) BundleErrorViewerDialog(
|
||||
if (showDialog) ExceptionViewerDialog(
|
||||
onDismiss = { showDialog = false },
|
||||
text = remember(it) { it.stackTraceToString() }
|
||||
)
|
||||
@ -148,61 +143,4 @@ fun BundleInformationDialog(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BundleErrorViewerDialog(onDismiss: () -> Unit, text: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnBackPress = true
|
||||
)
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BundleTopBar(
|
||||
title = stringResource(R.string.bundle_error),
|
||||
onBackClick = onDismiss,
|
||||
backIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back)
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
text
|
||||
)
|
||||
type = "text/plain"
|
||||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.Share,
|
||||
contentDescription = stringResource(R.string.share)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
ColumnWithScrollbar(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
Text(text, modifier = Modifier.horizontalScroll(rememberScrollState()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -43,6 +43,7 @@ import app.revanced.manager.ui.component.LoadingIndicator
|
||||
import app.revanced.manager.ui.model.State
|
||||
import app.revanced.manager.ui.model.Step
|
||||
import app.revanced.manager.ui.model.StepCategory
|
||||
import java.util.Locale
|
||||
import kotlin.math.floor
|
||||
|
||||
// Credits: https://github.com/Aliucord/AliucordManager/blob/main/app/src/main/kotlin/com/aliucord/manager/ui/component/installer/InstallGroup.kt
|
||||
@ -134,7 +135,7 @@ fun SubStep(
|
||||
name: String,
|
||||
state: State,
|
||||
message: String? = null,
|
||||
downloadProgress: Pair<Float, Float>? = null
|
||||
downloadProgress: Pair<Long, Long?>? = null
|
||||
) {
|
||||
var messageExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
@ -180,7 +181,7 @@ fun SubStep(
|
||||
} else {
|
||||
downloadProgress?.let { (current, total) ->
|
||||
Text(
|
||||
"$current/$total MB",
|
||||
if (total != null) "${current.megaBytes}/${total.megaBytes} MB" else "${current.megaBytes} MB",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
@ -199,7 +200,7 @@ fun SubStep(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StepIcon(state: State, progress: Pair<Float, Float>? = null, size: Dp) {
|
||||
fun StepIcon(state: State, progress: Pair<Long, Long?>? = null, size: Dp) {
|
||||
val strokeWidth = Dp(floor(size.value / 10) + 1)
|
||||
|
||||
when (state) {
|
||||
@ -233,8 +234,15 @@ fun StepIcon(state: State, progress: Pair<Float, Float>? = null, size: Dp) {
|
||||
contentDescription = description
|
||||
}
|
||||
},
|
||||
progress = { progress?.let { (current, total) -> current / total } },
|
||||
progress = {
|
||||
progress?.let { (current, total) ->
|
||||
if (total == null) return@let null
|
||||
current / total
|
||||
}?.toFloat()
|
||||
},
|
||||
strokeWidth = strokeWidth
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val Long.megaBytes get() = "%.1f".format(locale = Locale.ROOT, toDouble() / 1_000_000)
|
@ -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 = {
|
||||
|
@ -22,9 +22,6 @@ sealed interface Destination : Parcelable {
|
||||
@Parcelize
|
||||
data class Settings(val startDestination: SettingsDestination = SettingsDestination.Settings) : Destination
|
||||
|
||||
@Parcelize
|
||||
data class VersionSelector(val packageName: String, val patchSelection: PatchSelection? = null) : Destination
|
||||
|
||||
@Parcelize
|
||||
data class SelectedApplicationInfo(val selectedApp: SelectedApp, val patchSelection: PatchSelection? = null) : Destination
|
||||
|
||||
|
@ -13,7 +13,4 @@ sealed interface SelectedAppInfoDestination : Parcelable {
|
||||
|
||||
@Parcelize
|
||||
data class PatchesSelector(val app: SelectedApp, val currentSelection: PatchSelection?, val options: @RawValue Options) : SelectedAppInfoDestination
|
||||
|
||||
@Parcelize
|
||||
data object VersionSelector: SelectedAppInfoDestination
|
||||
}
|
@ -49,7 +49,7 @@ data class BundleInfo(
|
||||
bundle.uid to patches
|
||||
}
|
||||
|
||||
fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String) =
|
||||
fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String?) =
|
||||
sources.flatMapLatestAndCombine(
|
||||
combiner = { it.filterNotNull() }
|
||||
) { source ->
|
||||
@ -64,7 +64,7 @@ data class BundleInfo(
|
||||
bundle.patches.filter { it.compatibleWith(packageName) }.forEach {
|
||||
val targetList = when {
|
||||
it.compatiblePackages == null -> universal
|
||||
it.supportsVersion(
|
||||
it.supports(
|
||||
packageName,
|
||||
version
|
||||
) -> supported
|
||||
|
@ -19,5 +19,5 @@ data class Step(
|
||||
val category: StepCategory,
|
||||
val state: State = State.WAITING,
|
||||
val message: String? = null,
|
||||
val downloadProgress: StateFlow<Pair<Float, Float>?>? = null
|
||||
val downloadProgress: StateFlow<Pair<Long, Long?>?>? = null
|
||||
)
|
@ -1,20 +1,35 @@
|
||||
package app.revanced.manager.ui.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import app.revanced.manager.network.downloader.AppDownloader
|
||||
import app.revanced.manager.network.downloader.ParceledDownloaderData
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.File
|
||||
|
||||
sealed class SelectedApp : Parcelable {
|
||||
abstract val packageName: String
|
||||
abstract val version: String
|
||||
sealed interface SelectedApp : Parcelable {
|
||||
val packageName: String
|
||||
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 data: ParceledDownloaderData
|
||||
) : SelectedApp
|
||||
|
||||
@Parcelize
|
||||
data class Local(override val packageName: String, override val version: String, val file: File, val temporary: Boolean) : SelectedApp()
|
||||
data class Search(override val packageName: String, override val version: String?) : SelectedApp
|
||||
|
||||
@Parcelize
|
||||
data class Installed(override val packageName: String, override val version: String) : SelectedApp()
|
||||
data class Local(
|
||||
override val packageName: String,
|
||||
override val version: String,
|
||||
val file: File,
|
||||
val temporary: Boolean
|
||||
) : SelectedApp
|
||||
|
||||
@Parcelize
|
||||
data class Installed(
|
||||
override val packageName: String,
|
||||
override val version: String
|
||||
) : SelectedApp
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -33,19 +32,20 @@ import app.revanced.manager.ui.component.SearchView
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.ui.viewmodel.AppSelectorViewModel
|
||||
import app.revanced.manager.util.APK_MIMETYPE
|
||||
import app.revanced.manager.util.EventEffect
|
||||
import app.revanced.manager.util.transparentListItemColors
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppSelectorScreen(
|
||||
onAppClick: (packageName: String) -> Unit,
|
||||
onStorageClick: (SelectedApp.Local) -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
onStorageSelect: (SelectedApp.Local) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
vm: AppSelectorViewModel = koinViewModel()
|
||||
) {
|
||||
SideEffect {
|
||||
vm.onStorageClick = onStorageClick
|
||||
EventEffect(flow = vm.storageSelectionFlow) {
|
||||
onStorageSelect(it)
|
||||
}
|
||||
|
||||
val pickApkLauncher =
|
||||
@ -75,7 +75,7 @@ fun AppSelectorScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (search) {
|
||||
if (search)
|
||||
SearchView(
|
||||
query = filterText,
|
||||
onQueryChange = { filterText = it },
|
||||
@ -83,15 +83,15 @@ fun AppSelectorScreen(
|
||||
placeholder = { Text(stringResource(R.string.search_apps)) }
|
||||
) {
|
||||
if (appList.isNotEmpty() && filterText.isNotEmpty()) {
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumnWithScrollbar(modifier = Modifier.fillMaxSize()) {
|
||||
items(
|
||||
items = filteredAppList,
|
||||
key = { it.packageName }
|
||||
) { app ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onAppClick(app.packageName) },
|
||||
modifier = Modifier.clickable {
|
||||
onSelect(app.packageName)
|
||||
},
|
||||
leadingContent = {
|
||||
AppIcon(
|
||||
app.packageInfo,
|
||||
@ -125,17 +125,18 @@ fun AppSelectorScreen(
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Search,
|
||||
contentDescription = stringResource(R.string.search),
|
||||
modifier = Modifier.size(64.dp)
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.type_anything),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@ -184,7 +185,9 @@ fun AppSelectorScreen(
|
||||
key = { it.packageName }
|
||||
) { app ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onAppClick(app.packageName) },
|
||||
modifier = Modifier.clickable {
|
||||
onSelect(app.packageName)
|
||||
},
|
||||
leadingContent = { AppIcon(app.packageInfo, null, Modifier.size(36.dp)) },
|
||||
headlineContent = {
|
||||
AppLabel(
|
||||
|
@ -5,7 +5,7 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
@ -49,17 +49,21 @@ enum class DashboardPage(
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
vm: DashboardViewModel = koinViewModel(),
|
||||
onAppSelectorClick: () -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onUpdateClick: () -> Unit,
|
||||
onDownloaderPluginClick: () -> Unit,
|
||||
onAppClick: (InstalledApp) -> Unit
|
||||
) {
|
||||
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } }
|
||||
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
|
||||
val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle(
|
||||
false
|
||||
)
|
||||
val androidContext = LocalContext.current
|
||||
val composableScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(
|
||||
@ -237,6 +241,20 @@ fun DashboardScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
if (showNewDownloaderPluginsNotification) {
|
||||
{
|
||||
NotificationCard(
|
||||
text = stringResource(R.string.new_downloader_plugins_notification),
|
||||
icon = Icons.Outlined.Download,
|
||||
modifier = Modifier.clickable(onClick = onDownloaderPluginClick),
|
||||
actions = {
|
||||
TextButton(onClick = vm::ignoreNewDownloaderPlugins) {
|
||||
Text(stringResource(R.string.dismiss))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else null
|
||||
)
|
||||
|
||||
|
@ -41,13 +41,12 @@ import app.revanced.manager.ui.component.ColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.SegmentedButton
|
||||
import app.revanced.manager.ui.component.settings.SettingsListItem
|
||||
import app.revanced.manager.ui.viewmodel.InstalledAppInfoViewModel
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import app.revanced.manager.util.toast
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun InstalledAppInfoScreen(
|
||||
onPatchClick: (packageName: String, patchSelection: PatchSelection) -> Unit,
|
||||
onPatchClick: (packageName: String) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
viewModel: InstalledAppInfoViewModel
|
||||
) {
|
||||
@ -134,9 +133,7 @@ fun InstalledAppInfoScreen(
|
||||
icon = Icons.Outlined.Update,
|
||||
text = stringResource(R.string.repatch),
|
||||
onClick = {
|
||||
viewModel.appliedPatches?.let {
|
||||
onPatchClick(viewModel.installedApp.originalPackageName, it)
|
||||
}
|
||||
onPatchClick(viewModel.installedApp.originalPackageName)
|
||||
},
|
||||
enabled = viewModel.installedApp.installType != InstallType.MOUNT || viewModel.rootInstaller.hasRootAccess()
|
||||
)
|
||||
|
@ -2,6 +2,7 @@ package app.revanced.manager.ui.screen
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.*
|
||||
@ -38,6 +39,7 @@ import app.revanced.manager.ui.model.State
|
||||
import app.revanced.manager.ui.model.StepCategory
|
||||
import app.revanced.manager.ui.viewmodel.PatcherViewModel
|
||||
import app.revanced.manager.util.APK_MIMETYPE
|
||||
import app.revanced.manager.util.EventEffect
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -86,6 +88,38 @@ fun PatcherScreen(
|
||||
if (vm.installerStatusDialogModel.packageInstallerStatus != null)
|
||||
InstallerStatusDialog(vm.installerStatusDialogModel)
|
||||
|
||||
val activityLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
onResult = vm::handleActivityResult
|
||||
)
|
||||
EventEffect(flow = vm.launchActivityFlow) { intent ->
|
||||
activityLauncher.launch(intent)
|
||||
}
|
||||
|
||||
vm.activityPromptDialog?.let { title ->
|
||||
AlertDialog(
|
||||
onDismissRequest = vm::rejectInteraction,
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = vm::allowInteraction
|
||||
) {
|
||||
Text(stringResource(R.string.continue_))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = vm::rejectInteraction
|
||||
) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Text(stringResource(R.string.plugin_activity_dialog_body))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
|
@ -1,6 +1,5 @@
|
||||
package app.revanced.manager.ui.screen
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
@ -49,7 +48,7 @@ import app.revanced.manager.util.isScrollingUp
|
||||
import app.revanced.manager.util.transparentListItemColors
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PatchesSelectorScreen(
|
||||
onSave: (PatchSelection?, Options) -> Unit,
|
||||
@ -137,7 +136,7 @@ fun PatchesSelectorScreen(
|
||||
|
||||
if (vm.compatibleVersions.isNotEmpty())
|
||||
UnsupportedPatchDialog(
|
||||
appVersion = vm.appVersion,
|
||||
appVersion = vm.appVersion ?: stringResource(R.string.any_version),
|
||||
supportedVersions = vm.compatibleVersions,
|
||||
onDismissRequest = vm::dismissDialogs
|
||||
)
|
||||
@ -146,7 +145,7 @@ fun PatchesSelectorScreen(
|
||||
}
|
||||
if (showUnsupportedPatchesDialog)
|
||||
UnsupportedPatchesDialog(
|
||||
appVersion = vm.appVersion,
|
||||
appVersion = vm.appVersion ?: stringResource(R.string.any_version),
|
||||
onDismissRequest = { showUnsupportedPatchesDialog = false }
|
||||
)
|
||||
|
||||
@ -204,15 +203,15 @@ fun PatchesSelectorScreen(
|
||||
when {
|
||||
// Open unsupported dialog if the patch is not supported
|
||||
!supported -> vm.openUnsupportedDialog(patch)
|
||||
|
||||
|
||||
// Show selection warning if enabled
|
||||
vm.selectionWarningEnabled -> showSelectionWarning = true
|
||||
|
||||
|
||||
// Set pending universal patch action if the universal patch warning is enabled and there are no compatible packages
|
||||
vm.universalPatchWarningEnabled && patch.compatiblePackages == null -> {
|
||||
vm.pendingUniversalPatchAction = { vm.togglePatch(uid, patch) }
|
||||
}
|
||||
|
||||
|
||||
// Toggle the patch otherwise
|
||||
else -> vm.togglePatch(uid, patch)
|
||||
}
|
||||
@ -275,7 +274,11 @@ fun PatchesSelectorScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.patches_selected, selectedPatchCount, availablePatchCount),
|
||||
title = stringResource(
|
||||
R.string.patches_selected,
|
||||
selectedPatchCount,
|
||||
availablePatchCount
|
||||
),
|
||||
onBackClick = onBackClick,
|
||||
actions = {
|
||||
IconButton(onClick = vm::reset) {
|
||||
@ -436,7 +439,7 @@ private fun PatchItem(
|
||||
selected: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
supported: Boolean = true
|
||||
) = ListItem (
|
||||
) = ListItem(
|
||||
modifier = Modifier
|
||||
.let { if (!supported) it.alpha(0.5f) else it }
|
||||
.clickable(onClick = onToggle)
|
||||
@ -457,6 +460,7 @@ private fun PatchItem(
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
@ -1,10 +1,14 @@
|
||||
package app.revanced.manager.ui.screen
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
|
||||
import androidx.compose.material.icons.filled.AutoFixHigh
|
||||
@ -19,22 +23,31 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.data.room.apps.installed.InstallType
|
||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
|
||||
import app.revanced.manager.ui.component.AlertDialogExtended
|
||||
import app.revanced.manager.ui.component.AppInfo
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.ColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.LoadingIndicator
|
||||
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
|
||||
import app.revanced.manager.ui.destination.SelectedAppInfoDestination
|
||||
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
|
||||
import app.revanced.manager.ui.viewmodel.SelectedAppInfoViewModel
|
||||
import app.revanced.manager.util.EventEffect
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import app.revanced.manager.util.enabled
|
||||
import app.revanced.manager.util.toast
|
||||
import app.revanced.manager.util.transparentListItemColors
|
||||
import dev.olshevski.navigation.reimagined.*
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SelectedAppInfoScreen(
|
||||
onPatchClick: (SelectedApp, PatchSelection, Options) -> Unit,
|
||||
@ -61,56 +74,137 @@ fun SelectedAppInfoScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
onResult = vm::handlePluginActivityResult
|
||||
)
|
||||
EventEffect(flow = vm.launchActivityFlow) { intent ->
|
||||
launcher.launch(intent)
|
||||
}
|
||||
|
||||
val navController =
|
||||
rememberNavController<SelectedAppInfoDestination>(startDestination = SelectedAppInfoDestination.Main)
|
||||
|
||||
NavBackHandler(controller = navController)
|
||||
|
||||
AnimatedNavHost(controller = navController) { destination ->
|
||||
val error by vm.errorFlow.collectAsStateWithLifecycle(null)
|
||||
when (destination) {
|
||||
is SelectedAppInfoDestination.Main -> SelectedAppInfoScreen(
|
||||
onPatchClick = patchClick@{
|
||||
if (selectedPatchCount == 0) {
|
||||
context.toast(context.getString(R.string.no_patches_selected))
|
||||
|
||||
return@patchClick
|
||||
}
|
||||
onPatchClick(
|
||||
vm.selectedApp,
|
||||
patches,
|
||||
vm.getOptionsFiltered(bundles)
|
||||
is SelectedAppInfoDestination.Main -> Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.app_info),
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
},
|
||||
onPatchSelectorClick = {
|
||||
navController.navigate(
|
||||
SelectedAppInfoDestination.PatchesSelector(
|
||||
vm.selectedApp,
|
||||
vm.getCustomPatches(
|
||||
bundles,
|
||||
allowIncompatiblePatches
|
||||
),
|
||||
vm.options
|
||||
floatingActionButton = {
|
||||
if (error != null) return@Scaffold
|
||||
|
||||
HapticExtendedFloatingActionButton(
|
||||
text = { Text(stringResource(R.string.patch)) },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.AutoFixHigh,
|
||||
stringResource(R.string.patch)
|
||||
)
|
||||
},
|
||||
onClick = patchClick@{
|
||||
if (selectedPatchCount == 0) {
|
||||
context.toast(context.getString(R.string.no_patches_selected))
|
||||
|
||||
return@patchClick
|
||||
}
|
||||
onPatchClick(
|
||||
vm.selectedApp,
|
||||
patches,
|
||||
vm.getOptionsFiltered(bundles)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
val plugins by vm.plugins.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
if (vm.showSourceSelector) {
|
||||
val requiredVersion by vm.requiredVersion.collectAsStateWithLifecycle(null)
|
||||
|
||||
AppSourceSelectorDialog(
|
||||
plugins = plugins,
|
||||
installedApp = vm.installedAppData,
|
||||
searchApp = SelectedApp.Search(
|
||||
vm.packageName,
|
||||
vm.desiredVersion
|
||||
),
|
||||
activeSearchJob = vm.activePluginAction,
|
||||
hasRoot = vm.hasRoot,
|
||||
onDismissRequest = vm::dismissSourceSelector,
|
||||
onSelectPlugin = vm::searchUsingPlugin,
|
||||
requiredVersion = requiredVersion,
|
||||
onSelect = {
|
||||
vm.selectedApp = it
|
||||
vm.dismissSourceSelector()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
AppInfo(vm.selectedAppInfo, placeholderLabel = packageName) {
|
||||
Text(
|
||||
version ?: stringResource(R.string.selected_app_meta_any_version),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
)
|
||||
},
|
||||
onVersionSelectorClick = {
|
||||
navController.navigate(SelectedAppInfoDestination.VersionSelector)
|
||||
},
|
||||
onBackClick = onBackClick,
|
||||
selectedPatchCount = selectedPatchCount,
|
||||
packageName = packageName,
|
||||
version = version,
|
||||
packageInfo = vm.selectedAppInfo,
|
||||
)
|
||||
}
|
||||
|
||||
is SelectedAppInfoDestination.VersionSelector -> VersionSelectorScreen(
|
||||
onBackClick = navController::pop,
|
||||
onAppClick = {
|
||||
vm.selectedApp = it
|
||||
navController.pop()
|
||||
},
|
||||
viewModel = koinViewModel { parametersOf(packageName) }
|
||||
)
|
||||
PageItem(
|
||||
R.string.patch_selector_item,
|
||||
stringResource(
|
||||
R.string.patch_selector_item_description,
|
||||
selectedPatchCount
|
||||
),
|
||||
onClick = {
|
||||
navController.navigate(
|
||||
SelectedAppInfoDestination.PatchesSelector(
|
||||
vm.selectedApp,
|
||||
vm.getCustomPatches(
|
||||
bundles,
|
||||
allowIncompatiblePatches
|
||||
),
|
||||
vm.options
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
PageItem(
|
||||
R.string.apk_source_selector_item,
|
||||
when (val app = vm.selectedApp) {
|
||||
is SelectedApp.Search -> stringResource(R.string.apk_source_auto)
|
||||
is SelectedApp.Installed -> stringResource(R.string.apk_source_installed)
|
||||
is SelectedApp.Download -> stringResource(
|
||||
R.string.apk_source_downloader,
|
||||
plugins.find { it.packageName == app.data.pluginPackageName }?.name
|
||||
?: app.data.pluginPackageName
|
||||
)
|
||||
|
||||
is SelectedApp.Local -> stringResource(R.string.apk_source_local)
|
||||
},
|
||||
onClick = {
|
||||
vm.showSourceSelector()
|
||||
}
|
||||
)
|
||||
error?.let {
|
||||
Text(
|
||||
stringResource(it.resourceId),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(horizontal = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SelectedAppInfoDestination.PatchesSelector -> PatchesSelectorScreen(
|
||||
onSave = { patches, options ->
|
||||
@ -132,65 +226,6 @@ fun SelectedAppInfoScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SelectedAppInfoScreen(
|
||||
onPatchClick: () -> Unit,
|
||||
onPatchSelectorClick: () -> Unit,
|
||||
onVersionSelectorClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
selectedPatchCount: Int,
|
||||
packageName: String,
|
||||
version: String,
|
||||
packageInfo: PackageInfo?,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.app_info),
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
HapticExtendedFloatingActionButton(
|
||||
text = { Text(stringResource(R.string.patch)) },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.AutoFixHigh,
|
||||
stringResource(R.string.patch)
|
||||
)
|
||||
},
|
||||
onClick = onPatchClick
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
ColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
AppInfo(packageInfo, placeholderLabel = packageName) {
|
||||
Text(
|
||||
stringResource(R.string.selected_app_meta, version),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
PageItem(
|
||||
R.string.patch_selector_item,
|
||||
stringResource(R.string.patch_selector_item_description, selectedPatchCount),
|
||||
onPatchSelectorClick
|
||||
)
|
||||
PageItem(
|
||||
R.string.version_selector_item,
|
||||
stringResource(R.string.version_selector_item_description, version),
|
||||
onVersionSelectorClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PageItem(@StringRes title: Int, description: String, onClick: () -> Unit) {
|
||||
ListItem(
|
||||
@ -215,4 +250,89 @@ private fun PageItem(@StringRes title: Int, description: String, onClick: () ->
|
||||
Icon(Icons.AutoMirrored.Outlined.ArrowRight, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppSourceSelectorDialog(
|
||||
plugins: List<LoadedDownloaderPlugin>,
|
||||
installedApp: Pair<SelectedApp.Installed, InstalledApp?>?,
|
||||
searchApp: SelectedApp.Search,
|
||||
activeSearchJob: String?,
|
||||
hasRoot: Boolean,
|
||||
requiredVersion: String?,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelectPlugin: (LoadedDownloaderPlugin) -> Unit,
|
||||
onSelect: (SelectedApp) -> Unit,
|
||||
) {
|
||||
val canSelect = activeSearchJob == null
|
||||
|
||||
AlertDialogExtended(
|
||||
onDismissRequest = onDismissRequest,
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(R.string.app_source_dialog_title)) },
|
||||
textHorizontalPadding = PaddingValues(horizontal = 0.dp),
|
||||
text = {
|
||||
LazyColumn {
|
||||
item(key = "auto") {
|
||||
val hasPlugins = plugins.isNotEmpty()
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.clickable(enabled = canSelect && hasPlugins) { onSelect(searchApp) }
|
||||
.enabled(hasPlugins),
|
||||
headlineContent = { Text(stringResource(R.string.app_source_dialog_option_auto)) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (hasPlugins)
|
||||
stringResource(R.string.app_source_dialog_option_auto_description)
|
||||
else
|
||||
stringResource(R.string.app_source_dialog_option_auto_unavailable)
|
||||
)
|
||||
},
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
}
|
||||
|
||||
installedApp?.let { (app, meta) ->
|
||||
item(key = "installed") {
|
||||
val (usable, text) = when {
|
||||
// Mounted apps must be unpatched before patching, which cannot be done without root access.
|
||||
meta?.installType == InstallType.MOUNT && !hasRoot -> false to stringResource(
|
||||
R.string.app_source_dialog_option_installed_no_root
|
||||
)
|
||||
// Patching already patched apps is not allowed because patches expect unpatched apps.
|
||||
meta?.installType == InstallType.DEFAULT -> false to stringResource(R.string.already_patched)
|
||||
// Version does not match suggested version.
|
||||
requiredVersion != null && app.version != requiredVersion -> false to stringResource(
|
||||
R.string.app_source_dialog_option_installed_version_not_suggested,
|
||||
app.version
|
||||
)
|
||||
|
||||
else -> true to app.version
|
||||
}
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.clickable(enabled = canSelect && usable) { onSelect(app) }
|
||||
.enabled(usable),
|
||||
headlineContent = { Text(stringResource(R.string.installed)) },
|
||||
supportingContent = { Text(text) },
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(plugins, key = { "plugin_${it.packageName}" }) { plugin ->
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(enabled = canSelect) { onSelectPlugin(plugin) },
|
||||
headlineContent = { Text(plugin.name) },
|
||||
trailingContent = (@Composable { LoadingIndicator() }).takeIf { activeSearchJob == plugin.packageName },
|
||||
colors = transparentListItemColors
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
@ -1,201 +0,0 @@
|
||||
package app.revanced.manager.ui.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.data.room.apps.installed.InstallType
|
||||
import app.revanced.manager.ui.component.AppTopBar
|
||||
import app.revanced.manager.ui.component.GroupHeader
|
||||
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.LoadingIndicator
|
||||
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
|
||||
import app.revanced.manager.ui.component.haptics.HapticRadioButton
|
||||
import app.revanced.manager.ui.component.NonSuggestedVersionDialog
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.ui.viewmodel.VersionSelectorViewModel
|
||||
import app.revanced.manager.util.isScrollingUp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VersionSelectorScreen(
|
||||
onBackClick: () -> Unit,
|
||||
onAppClick: (SelectedApp) -> Unit,
|
||||
viewModel: VersionSelectorViewModel
|
||||
) {
|
||||
val supportedVersions by viewModel.supportedVersions.collectAsStateWithLifecycle(emptyMap())
|
||||
val downloadedVersions by viewModel.downloadedVersions.collectAsStateWithLifecycle(emptyList())
|
||||
|
||||
val list by remember {
|
||||
derivedStateOf {
|
||||
val apps = (downloadedVersions + viewModel.downloadableVersions)
|
||||
.distinctBy { it.version }
|
||||
.sortedWith(
|
||||
compareByDescending<SelectedApp> {
|
||||
it is SelectedApp.Local
|
||||
}.thenByDescending { supportedVersions[it.version] }
|
||||
.thenByDescending { it.version }
|
||||
)
|
||||
|
||||
viewModel.requiredVersion?.let { requiredVersion ->
|
||||
apps.filter { it.version == requiredVersion }
|
||||
} ?: apps
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.showNonSuggestedVersionDialog)
|
||||
NonSuggestedVersionDialog(
|
||||
suggestedVersion = viewModel.requiredVersion.orEmpty(),
|
||||
onDismiss = viewModel::dismissNonSuggestedVersionDialog
|
||||
)
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.select_version),
|
||||
onBackClick = onBackClick,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
HapticExtendedFloatingActionButton(
|
||||
text = { Text(stringResource(R.string.select_version)) },
|
||||
icon = {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
stringResource(R.string.select_version)
|
||||
)
|
||||
},
|
||||
expanded = lazyListState.isScrollingUp,
|
||||
onClick = { viewModel.selectedVersion?.let(onAppClick) }
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
state = lazyListState
|
||||
) {
|
||||
viewModel.installedApp?.let { (packageInfo, installedApp) ->
|
||||
SelectedApp.Installed(
|
||||
packageName = viewModel.packageName,
|
||||
version = packageInfo.versionName!!
|
||||
).let {
|
||||
item {
|
||||
SelectedAppItem(
|
||||
selectedApp = it,
|
||||
selected = viewModel.selectedVersion == it,
|
||||
onClick = { viewModel.select(it) },
|
||||
patchCount = supportedVersions[it.version],
|
||||
enabled =
|
||||
!(installedApp?.installType == InstallType.MOUNT && !viewModel.rootInstaller.hasRootAccess()),
|
||||
alreadyPatched = installedApp != null && installedApp.installType != InstallType.MOUNT
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
GroupHeader(stringResource(R.string.downloadable_versions))
|
||||
}
|
||||
}
|
||||
|
||||
items(
|
||||
items = list,
|
||||
key = { it.version }
|
||||
) {
|
||||
SelectedAppItem(
|
||||
selectedApp = it,
|
||||
selected = viewModel.selectedVersion == it,
|
||||
onClick = { viewModel.select(it) },
|
||||
patchCount = supportedVersions[it.version]
|
||||
)
|
||||
}
|
||||
|
||||
if (viewModel.errorMessage != null) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(R.string.error_occurred))
|
||||
Text(
|
||||
text = viewModel.errorMessage!!,
|
||||
modifier = Modifier.padding(horizontal = 15.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (viewModel.isLoading) {
|
||||
item {
|
||||
LoadingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SelectedAppItem(
|
||||
selectedApp: SelectedApp,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
patchCount: Int?,
|
||||
enabled: Boolean = true,
|
||||
alreadyPatched: Boolean = false,
|
||||
) {
|
||||
ListItem(
|
||||
leadingContent = { HapticRadioButton(selected, null) },
|
||||
headlineContent = { Text(selectedApp.version) },
|
||||
supportingContent = when (selectedApp) {
|
||||
is SelectedApp.Installed ->
|
||||
if (alreadyPatched) {
|
||||
{ Text(stringResource(R.string.already_patched)) }
|
||||
} else {
|
||||
{ Text(stringResource(R.string.installed)) }
|
||||
}
|
||||
|
||||
is SelectedApp.Local -> {
|
||||
{ Text(stringResource(R.string.already_downloaded)) }
|
||||
}
|
||||
|
||||
else -> null
|
||||
},
|
||||
trailingContent = patchCount?.let {
|
||||
{
|
||||
Text(pluralStringResource(R.plurals.patch_count, it, it))
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.clickable(enabled = !alreadyPatched && enabled, onClick = onClick)
|
||||
.run {
|
||||
if (!enabled || alreadyPatched) alpha(0.5f)
|
||||
else this
|
||||
}
|
||||
)
|
||||
}
|
@ -1,39 +1,60 @@
|
||||
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.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.LazyColumnWithScrollbar
|
||||
import app.revanced.manager.ui.component.haptics.HapticCheckbox
|
||||
import app.revanced.manager.ui.component.settings.BooleanItem
|
||||
import app.revanced.manager.ui.component.settings.SettingsListItem
|
||||
import app.revanced.manager.ui.viewmodel.DownloadsViewModel
|
||||
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 +62,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 +71,178 @@ fun DownloadsSettingsScreen(
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
ColumnWithScrollbar(
|
||||
Box(
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxWidth()
|
||||
.zIndex(1f)
|
||||
) {
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = pullRefreshState,
|
||||
isRefreshing = viewModel.isRefreshingPlugins
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumnWithScrollbar(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.pullToRefresh(
|
||||
isRefreshing = viewModel.isRefreshingPlugins,
|
||||
state = pullRefreshState,
|
||||
onRefresh = viewModel::refreshPlugins
|
||||
)
|
||||
) {
|
||||
BooleanItem(
|
||||
preference = prefs.preferSplits,
|
||||
headline = R.string.prefer_splits,
|
||||
description = R.string.prefer_splits_description,
|
||||
)
|
||||
item {
|
||||
GroupHeader(stringResource(R.string.downloader_plugins))
|
||||
}
|
||||
pluginStates.forEach { (packageName, state) ->
|
||||
item(key = packageName) {
|
||||
var showDialog by rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
GroupHeader(stringResource(R.string.downloaded_apps))
|
||||
fun dismiss() {
|
||||
showDialog = false
|
||||
}
|
||||
|
||||
downloadedApps.forEach { app ->
|
||||
val selected = app in viewModel.selection
|
||||
val packageInfo =
|
||||
remember(packageName) {
|
||||
viewModel.pm.getPackageInfo(
|
||||
packageName
|
||||
)
|
||||
} ?: return@item
|
||||
|
||||
if (showDialog) {
|
||||
val signature =
|
||||
remember(packageName) {
|
||||
val androidSignature =
|
||||
viewModel.pm.getSignature(packageName)
|
||||
val hash = MessageDigest.getInstance("SHA-256")
|
||||
.digest(androidSignature.toByteArray())
|
||||
hash.toHexString(format = HexFormat.UpperCase)
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is DownloaderPluginState.Loaded -> TrustDialog(
|
||||
title = R.string.downloader_plugin_revoke_trust_dialog_title,
|
||||
body = stringResource(
|
||||
R.string.downloader_plugin_trust_dialog_body,
|
||||
packageName,
|
||||
signature
|
||||
),
|
||||
onDismiss = ::dismiss,
|
||||
onConfirm = {
|
||||
viewModel.revokePluginTrust(packageName)
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
|
||||
is DownloaderPluginState.Failed -> ExceptionViewerDialog(
|
||||
text = remember(state.throwable) {
|
||||
state.throwable.stackTraceToString()
|
||||
},
|
||||
onDismiss = ::dismiss
|
||||
)
|
||||
|
||||
is DownloaderPluginState.Untrusted -> TrustDialog(
|
||||
title = R.string.downloader_plugin_trust_dialog_title,
|
||||
body = stringResource(
|
||||
R.string.downloader_plugin_trust_dialog_body,
|
||||
packageName,
|
||||
signature
|
||||
),
|
||||
onDismiss = ::dismiss,
|
||||
onConfirm = {
|
||||
viewModel.trustPlugin(packageName)
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsListItem(
|
||||
modifier = Modifier.clickable { showDialog = true },
|
||||
headlineContent = {
|
||||
AppLabel(
|
||||
packageInfo = packageInfo,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
supportingContent = stringResource(
|
||||
when (state) {
|
||||
is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted
|
||||
is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed
|
||||
is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted
|
||||
}
|
||||
),
|
||||
trailingContent = { Text(packageInfo.versionName!!) }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (pluginStates.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.downloader_no_plugins_installed),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
GroupHeader(stringResource(R.string.downloaded_apps))
|
||||
}
|
||||
items(downloadedApps, key = { it.packageName to it.version }) { app ->
|
||||
val selected = app in viewModel.appSelection
|
||||
|
||||
SettingsListItem(
|
||||
modifier = Modifier.clickable { viewModel.toggleItem(app) },
|
||||
modifier = Modifier.clickable { viewModel.toggleApp(app) },
|
||||
headlineContent = app.packageName,
|
||||
leadingContent = (@Composable {
|
||||
HapticCheckbox(
|
||||
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) }
|
||||
)
|
||||
}
|
@ -14,7 +14,9 @@ import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.toast
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
@ -30,7 +32,8 @@ class AppSelectorViewModel(
|
||||
}
|
||||
val appList = pm.appList
|
||||
|
||||
var onStorageClick: (SelectedApp.Local) -> Unit = {}
|
||||
private val storageSelectionChannel = Channel<SelectedApp.Local>()
|
||||
val storageSelectionFlow = storageSelectionChannel.receiveAsFlow()
|
||||
|
||||
val suggestedAppVersions = patchBundleRepository.suggestedVersions.flowOn(Dispatchers.Default)
|
||||
|
||||
@ -54,7 +57,7 @@ class AppSelectorViewModel(
|
||||
}
|
||||
|
||||
if (patchBundleRepository.isVersionAllowed(selectedApp.packageName, selectedApp.version)) {
|
||||
onStorageClick(selectedApp)
|
||||
storageSelectionChannel.send(selectedApp)
|
||||
} else {
|
||||
nonSuggestedVersionDialogSubject = selectedApp
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import app.revanced.manager.domain.bundles.PatchBundleSource
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
|
||||
import app.revanced.manager.domain.bundles.RemotePatchBundle
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.domain.repository.DownloaderPluginRepository
|
||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||
import app.revanced.manager.network.api.ReVancedAPI
|
||||
import app.revanced.manager.util.toast
|
||||
@ -28,6 +29,7 @@ import kotlinx.coroutines.launch
|
||||
class DashboardViewModel(
|
||||
private val app: Application,
|
||||
private val patchBundleRepository: PatchBundleRepository,
|
||||
private val downloaderPluginRepository: DownloaderPluginRepository,
|
||||
private val reVancedAPI: ReVancedAPI,
|
||||
private val networkInfo: NetworkInfo,
|
||||
val prefs: PreferencesManager
|
||||
@ -39,6 +41,8 @@ class DashboardViewModel(
|
||||
val sources = patchBundleRepository.sources
|
||||
val selectedSources = mutableStateListOf<PatchBundleSource>()
|
||||
|
||||
val newDownloaderPluginsAvailable = downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() }
|
||||
|
||||
var updatedManagerVersion: String? by mutableStateOf(null)
|
||||
private set
|
||||
var showBatteryOptimizationsWarning by mutableStateOf(false)
|
||||
@ -52,6 +56,10 @@ class DashboardViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun ignoreNewDownloaderPlugins() = viewModelScope.launch {
|
||||
downloaderPluginRepository.acknowledgeAllNewPlugins()
|
||||
}
|
||||
|
||||
fun dismissUpdateDialog() {
|
||||
updatedManagerVersion = null
|
||||
}
|
||||
|
@ -1,10 +1,15 @@
|
||||
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 +19,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 +30,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(packageName: String) = viewModelScope.launch {
|
||||
downloaderPluginRepository.trustPackage(packageName)
|
||||
}
|
||||
|
||||
fun revokePluginTrust(packageName: String) = viewModelScope.launch {
|
||||
downloaderPluginRepository.revokeTrustForPackage(packageName)
|
||||
}
|
||||
}
|
@ -15,13 +15,17 @@ import app.revanced.manager.R
|
||||
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
|
||||
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.PatchBundleRepository
|
||||
import app.revanced.manager.domain.repository.PatchSelectionRepository
|
||||
import app.revanced.manager.domain.repository.SerializedSelection
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.ui.theme.Theme
|
||||
import app.revanced.manager.util.tag
|
||||
import app.revanced.manager.util.toast
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
@ -29,10 +33,40 @@ import kotlinx.serialization.json.Json
|
||||
class MainViewModel(
|
||||
private val patchBundleRepository: PatchBundleRepository,
|
||||
private val patchSelectionRepository: PatchSelectionRepository,
|
||||
private val downloadedAppRepository: DownloadedAppRepository,
|
||||
private val keystoreManager: KeystoreManager,
|
||||
private val app: Application,
|
||||
val prefs: PreferencesManager
|
||||
) : ViewModel() {
|
||||
private val appSelectChannel = Channel<SelectedApp>()
|
||||
val appSelectFlow = appSelectChannel.receiveAsFlow()
|
||||
|
||||
private suspend fun suggestedVersion(packageName: String) =
|
||||
patchBundleRepository.suggestedVersions.first()[packageName]
|
||||
|
||||
private suspend fun findDownloadedApp(app: SelectedApp): SelectedApp.Local? {
|
||||
if (app !is SelectedApp.Search) return null
|
||||
|
||||
val suggestedVersion = suggestedVersion(app.packageName) ?: return null
|
||||
|
||||
val downloadedApp =
|
||||
downloadedAppRepository.get(app.packageName, suggestedVersion, markUsed = true) ?: return null
|
||||
return SelectedApp.Local(
|
||||
downloadedApp.packageName,
|
||||
downloadedApp.version,
|
||||
downloadedAppRepository.getApkFileForApp(downloadedApp),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
fun selectApp(app: SelectedApp) = viewModelScope.launch {
|
||||
appSelectChannel.send(findDownloadedApp(app) ?: app)
|
||||
}
|
||||
|
||||
fun selectApp(packageName: String) = viewModelScope.launch {
|
||||
selectApp(SelectedApp.Search(packageName, suggestedVersion(packageName)))
|
||||
}
|
||||
|
||||
fun importLegacySettings(componentActivity: ComponentActivity) {
|
||||
if (!prefs.firstLaunch.getBlocking()) return
|
||||
|
||||
|
@ -8,7 +8,9 @@ import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@ -29,6 +31,8 @@ import app.revanced.manager.domain.worker.WorkerRepository
|
||||
import app.revanced.manager.patcher.logger.LogLevel
|
||||
import app.revanced.manager.patcher.logger.Logger
|
||||
import app.revanced.manager.patcher.worker.PatcherWorker
|
||||
import app.revanced.manager.plugin.downloader.PluginHostApi
|
||||
import app.revanced.manager.plugin.downloader.UserInteractionException
|
||||
import app.revanced.manager.service.InstallService
|
||||
import app.revanced.manager.service.UninstallService
|
||||
import app.revanced.manager.ui.component.InstallerStatusDialogModel
|
||||
@ -42,11 +46,14 @@ import app.revanced.manager.util.simpleMessage
|
||||
import app.revanced.manager.util.tag
|
||||
import app.revanced.manager.util.toast
|
||||
import app.revanced.manager.util.uiSafe
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.time.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
@ -58,6 +65,7 @@ import java.time.Duration
|
||||
import java.util.UUID
|
||||
|
||||
@Stable
|
||||
@OptIn(PluginHostApi::class)
|
||||
class PatcherViewModel(
|
||||
private val input: Destination.Patcher
|
||||
) : ViewModel(), KoinComponent {
|
||||
@ -68,19 +76,20 @@ class PatcherViewModel(
|
||||
private val installedAppRepository: InstalledAppRepository by inject()
|
||||
private val rootInstaller: RootInstaller by inject()
|
||||
|
||||
val installerStatusDialogModel : InstallerStatusDialogModel = object : InstallerStatusDialogModel {
|
||||
override var packageInstallerStatus: Int? by mutableStateOf(null)
|
||||
val installerStatusDialogModel: InstallerStatusDialogModel =
|
||||
object : InstallerStatusDialogModel {
|
||||
override var packageInstallerStatus: Int? by mutableStateOf(null)
|
||||
|
||||
override fun reinstall() {
|
||||
this@PatcherViewModel.reinstall()
|
||||
}
|
||||
override fun reinstall() {
|
||||
this@PatcherViewModel.reinstall()
|
||||
}
|
||||
|
||||
override fun install() {
|
||||
// Since this is a package installer status dialog,
|
||||
// InstallType.MOUNT is never used here.
|
||||
install(InstallType.DEFAULT)
|
||||
override fun install() {
|
||||
// Since this is a package installer status dialog,
|
||||
// InstallType.MOUNT is never used here.
|
||||
install(InstallType.DEFAULT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var installedApp: InstalledApp? = null
|
||||
val packageName: String = input.selectedApp.packageName
|
||||
@ -89,6 +98,13 @@ class PatcherViewModel(
|
||||
var isInstalling by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var currentActivityRequest: Pair<CompletableDeferred<Boolean>, String>? by mutableStateOf(null)
|
||||
val activityPromptDialog by derivedStateOf { currentActivityRequest?.second }
|
||||
|
||||
private var launchedActivity: CompletableDeferred<ActivityResult>? = null
|
||||
private val launchActivityChannel = Channel<Intent>()
|
||||
val launchActivityFlow = launchActivityChannel.receiveAsFlow()
|
||||
|
||||
private val tempDir = fs.tempDir.resolve("installer").also {
|
||||
it.deleteRecursively()
|
||||
it.mkdirs()
|
||||
@ -109,7 +125,7 @@ class PatcherViewModel(
|
||||
}
|
||||
|
||||
val patchesProgress = MutableStateFlow(Pair(0, input.selectedPatches.values.sumOf { it.size }))
|
||||
private val downloadProgress = MutableStateFlow<Pair<Float, Float>?>(null)
|
||||
private val downloadProgress = MutableStateFlow<Pair<Long, Long?>?>(null)
|
||||
val steps = generateSteps(
|
||||
app,
|
||||
input.selectedApp,
|
||||
@ -130,6 +146,33 @@ class PatcherViewModel(
|
||||
downloadProgress,
|
||||
patchesProgress,
|
||||
setInputFile = { inputFile = it },
|
||||
handleStartActivityRequest = { plugin, intent ->
|
||||
withContext(Dispatchers.Main) {
|
||||
if (currentActivityRequest != null) throw Exception("Another request is already pending.")
|
||||
try {
|
||||
// Wait for the dialog interaction.
|
||||
val accepted = with(CompletableDeferred<Boolean>()) {
|
||||
currentActivityRequest = this to plugin.name
|
||||
|
||||
await()
|
||||
}
|
||||
if (!accepted) throw UserInteractionException.RequestDenied()
|
||||
|
||||
// Launch the activity and wait for the result.
|
||||
try {
|
||||
with(CompletableDeferred<ActivityResult>()) {
|
||||
launchedActivity = this
|
||||
launchActivityChannel.send(intent)
|
||||
await()
|
||||
}
|
||||
} finally {
|
||||
launchedActivity = null
|
||||
}
|
||||
} finally {
|
||||
currentActivityRequest = null
|
||||
}
|
||||
}
|
||||
},
|
||||
onProgress = { name, state, message ->
|
||||
viewModelScope.launch {
|
||||
steps[currentStepIndex] = steps[currentStepIndex].run {
|
||||
@ -173,13 +216,15 @@ class PatcherViewModel(
|
||||
?.let(logger::trace)
|
||||
|
||||
if (pmStatus == PackageInstaller.STATUS_SUCCESS) {
|
||||
app.toast(app.getString(R.string.install_app_success))
|
||||
installedPackageName =
|
||||
intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME)
|
||||
viewModelScope.launch {
|
||||
installedAppRepository.addOrUpdate(
|
||||
installedPackageName!!,
|
||||
packageName,
|
||||
input.selectedApp.version,
|
||||
input.selectedApp.version
|
||||
?: pm.getPackageInfo(outputFile)?.versionName!!,
|
||||
InstallType.DEFAULT,
|
||||
input.selectedPatches
|
||||
)
|
||||
@ -245,6 +290,18 @@ class PatcherViewModel(
|
||||
|
||||
fun isDeviceRooted() = rootInstaller.isDeviceRooted()
|
||||
|
||||
fun rejectInteraction() {
|
||||
currentActivityRequest?.first?.complete(false)
|
||||
}
|
||||
|
||||
fun allowInteraction() {
|
||||
currentActivityRequest?.first?.complete(true)
|
||||
}
|
||||
|
||||
fun handleActivityResult(result: ActivityResult) {
|
||||
launchedActivity?.complete(result)
|
||||
}
|
||||
|
||||
fun export(uri: Uri?) = viewModelScope.launch {
|
||||
uri?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
@ -285,7 +342,8 @@ class PatcherViewModel(
|
||||
// Check if the app version is less than the installed version
|
||||
if (pm.getVersionCode(currentPackageInfo) < pm.getVersionCode(existingPackageInfo)) {
|
||||
// Exit if the selected app version is less than the installed version
|
||||
installerStatusDialogModel.packageInstallerStatus = PackageInstaller.STATUS_FAILURE_CONFLICT
|
||||
installerStatusDialogModel.packageInstallerStatus =
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
@ -305,6 +363,11 @@ class PatcherViewModel(
|
||||
|
||||
InstallType.MOUNT -> {
|
||||
try {
|
||||
val packageInfo = pm.getPackageInfo(outputFile)
|
||||
?: throw Exception("Failed to load application info")
|
||||
val label = with(pm) {
|
||||
packageInfo.label()
|
||||
}
|
||||
// Check for base APK, first check if the app is already installed
|
||||
if (existingPackageInfo == null) {
|
||||
// If the app is not installed, check if the output file is a base apk
|
||||
@ -316,24 +379,23 @@ class PatcherViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Get label
|
||||
val label = with(pm) {
|
||||
currentPackageInfo.label()
|
||||
}
|
||||
val inputVersion = input.selectedApp.version
|
||||
?: inputFile?.let(pm::getPackageInfo)?.versionName
|
||||
?: throw Exception("Failed to determine input APK version")
|
||||
|
||||
// Install as root
|
||||
rootInstaller.install(
|
||||
outputFile,
|
||||
inputFile,
|
||||
packageName,
|
||||
input.selectedApp.version,
|
||||
inputVersion,
|
||||
label
|
||||
)
|
||||
|
||||
installedAppRepository.addOrUpdate(
|
||||
packageInfo.packageName,
|
||||
packageName,
|
||||
packageName,
|
||||
input.selectedApp.version,
|
||||
inputVersion,
|
||||
InstallType.MOUNT,
|
||||
input.selectedPatches
|
||||
)
|
||||
@ -353,7 +415,7 @@ class PatcherViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to install", e)
|
||||
app.toast(app.getString(R.string.install_app_fail, e.simpleMessage()))
|
||||
} finally {
|
||||
@ -385,9 +447,10 @@ class PatcherViewModel(
|
||||
fun generateSteps(
|
||||
context: Context,
|
||||
selectedApp: SelectedApp,
|
||||
downloadProgress: StateFlow<Pair<Float, Float>?>? = null
|
||||
downloadProgress: StateFlow<Pair<Long, Long?>?>? = null
|
||||
): List<Step> {
|
||||
val needsDownload = selectedApp is SelectedApp.Download
|
||||
val needsDownload =
|
||||
selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search
|
||||
|
||||
return listOfNotNull(
|
||||
Step(
|
||||
|
@ -1,50 +1,91 @@
|
||||
package app.revanced.manager.ui.viewmodel
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
|
||||
import androidx.lifecycle.viewmodel.compose.saveable
|
||||
import app.revanced.manager.R
|
||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||
import app.revanced.manager.domain.installer.RootInstaller
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.domain.repository.DownloaderPluginRepository
|
||||
import app.revanced.manager.domain.repository.InstalledAppRepository
|
||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||
import app.revanced.manager.domain.repository.PatchOptionsRepository
|
||||
import app.revanced.manager.domain.repository.PatchSelectionRepository
|
||||
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
|
||||
import app.revanced.manager.network.downloader.ParceledDownloaderData
|
||||
import app.revanced.manager.plugin.downloader.GetScope
|
||||
import app.revanced.manager.plugin.downloader.PluginHostApi
|
||||
import app.revanced.manager.plugin.downloader.UserInteractionException
|
||||
import app.revanced.manager.ui.model.BundleInfo
|
||||
import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.util.Options
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.PatchSelection
|
||||
import app.revanced.manager.util.simpleMessage
|
||||
import app.revanced.manager.util.tag
|
||||
import app.revanced.manager.util.toast
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
@OptIn(SavedStateHandleSaveableApi::class)
|
||||
@OptIn(SavedStateHandleSaveableApi::class, PluginHostApi::class)
|
||||
class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
private val app: Application = get()
|
||||
val bundlesRepo: PatchBundleRepository = get()
|
||||
private val bundleRepository: PatchBundleRepository = get()
|
||||
private val selectionRepository: PatchSelectionRepository = get()
|
||||
private val optionsRepository: PatchOptionsRepository = get()
|
||||
private val pluginsRepository: DownloaderPluginRepository = get()
|
||||
private val installedAppRepository: InstalledAppRepository = get()
|
||||
private val rootInstaller: RootInstaller = get()
|
||||
private val pm: PM = get()
|
||||
private val savedStateHandle: SavedStateHandle = get()
|
||||
val prefs: PreferencesManager = get()
|
||||
val plugins = pluginsRepository.loadedPluginsFlow
|
||||
val desiredVersion = input.app.version
|
||||
val packageName = input.app.packageName
|
||||
|
||||
private val persistConfiguration = input.patches == null
|
||||
|
||||
val hasRoot = rootInstaller.hasRootAccess()
|
||||
var installedAppData: Pair<SelectedApp.Installed, InstalledApp?>? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
private var _selectedApp by savedStateHandle.saveable {
|
||||
mutableStateOf(input.app)
|
||||
}
|
||||
|
||||
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var selectedApp
|
||||
get() = _selectedApp
|
||||
set(value) {
|
||||
@ -52,10 +93,27 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
invalidateSelectedAppInfo()
|
||||
}
|
||||
|
||||
var selectedAppInfo: PackageInfo? by mutableStateOf(null)
|
||||
|
||||
init {
|
||||
invalidateSelectedAppInfo()
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
|
||||
val installedAppDeferred =
|
||||
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
|
||||
|
||||
installedAppData =
|
||||
packageInfo.await()?.let {
|
||||
SelectedApp.Installed(
|
||||
packageName,
|
||||
it.versionName!!
|
||||
) to installedAppDeferred.await()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val requiredVersion = combine(prefs.suggestedVersionSafeguard.flow, bundleRepository.suggestedVersions) { suggestedVersionSafeguard, suggestedVersions ->
|
||||
if (!suggestedVersionSafeguard) return@combine null
|
||||
|
||||
suggestedVersions[input.app.packageName]
|
||||
}
|
||||
|
||||
var options: Options by savedStateHandle.saveable {
|
||||
@ -64,9 +122,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
viewModelScope.launch {
|
||||
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
|
||||
|
||||
val packageName =
|
||||
selectedApp.packageName // Accessing this from another thread may cause crashes.
|
||||
|
||||
state.value = withContext(Dispatchers.Default) {
|
||||
val bundlePatches = bundleRepository.bundles.first()
|
||||
.mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } }
|
||||
@ -89,7 +144,7 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
viewModelScope.launch {
|
||||
if (!prefs.disableSelectionWarning.get()) return@launch
|
||||
|
||||
val previous = selectionRepository.getSelection(selectedApp.packageName)
|
||||
val previous = selectionRepository.getSelection(packageName)
|
||||
if (previous.values.sumOf { it.size } == 0) return@launch
|
||||
selection.value = SelectionState.Customized(previous)
|
||||
}
|
||||
@ -97,11 +152,102 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
selection
|
||||
}
|
||||
|
||||
var showSourceSelector by mutableStateOf(false)
|
||||
private set
|
||||
private var pluginAction: Pair<LoadedDownloaderPlugin, Job>? by mutableStateOf(null)
|
||||
val activePluginAction get() = pluginAction?.first?.packageName
|
||||
private var launchedActivity by mutableStateOf<CompletableDeferred<ActivityResult>?>(null)
|
||||
private val launchActivityChannel = Channel<Intent>()
|
||||
val launchActivityFlow = launchActivityChannel.receiveAsFlow()
|
||||
|
||||
val errorFlow = combine(plugins, snapshotFlow { selectedApp }) { pluginsList, app ->
|
||||
when {
|
||||
app is SelectedApp.Search && pluginsList.isEmpty() -> Error.NoPlugins
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun showSourceSelector() {
|
||||
dismissSourceSelector()
|
||||
showSourceSelector = true
|
||||
}
|
||||
|
||||
private fun cancelPluginAction() {
|
||||
pluginAction?.second?.cancel()
|
||||
pluginAction = null
|
||||
}
|
||||
|
||||
fun dismissSourceSelector() {
|
||||
cancelPluginAction()
|
||||
showSourceSelector = false
|
||||
}
|
||||
|
||||
fun searchUsingPlugin(plugin: LoadedDownloaderPlugin) {
|
||||
cancelPluginAction()
|
||||
pluginAction = plugin to viewModelScope.launch {
|
||||
try {
|
||||
val scope = object : GetScope {
|
||||
override val hostPackageName = app.packageName
|
||||
override val pluginPackageName = plugin.packageName
|
||||
override suspend fun requestStartActivity(intent: Intent) =
|
||||
withContext(Dispatchers.Main) {
|
||||
if (launchedActivity != null) error("Previous activity has not finished")
|
||||
try {
|
||||
val result = with(CompletableDeferred<ActivityResult>()) {
|
||||
launchedActivity = this
|
||||
launchActivityChannel.send(intent)
|
||||
await()
|
||||
}
|
||||
when (result.resultCode) {
|
||||
Activity.RESULT_OK -> result.data
|
||||
Activity.RESULT_CANCELED -> throw UserInteractionException.Activity.Cancelled()
|
||||
else -> throw UserInteractionException.Activity.NotCompleted(
|
||||
result.resultCode,
|
||||
result.data
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
launchedActivity = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
plugin.get(scope, packageName, desiredVersion)
|
||||
}?.let { (data, version) ->
|
||||
if (desiredVersion != null && version != desiredVersion) {
|
||||
app.toast(app.getString(R.string.downloader_invalid_version))
|
||||
return@launch
|
||||
}
|
||||
selectedApp = SelectedApp.Download(
|
||||
packageName,
|
||||
version,
|
||||
ParceledDownloaderData(plugin, data)
|
||||
)
|
||||
} ?: app.toast(app.getString(R.string.downloader_app_not_found))
|
||||
} catch (e: UserInteractionException.Activity) {
|
||||
app.toast(e.message!!)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
app.toast(app.getString(R.string.downloader_error, e.simpleMessage()))
|
||||
Log.e(tag, "Downloader.get threw an exception", e)
|
||||
} finally {
|
||||
pluginAction = null
|
||||
dismissSourceSelector()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePluginActivityResult(result: ActivityResult) {
|
||||
launchedActivity?.complete(result)
|
||||
}
|
||||
|
||||
private fun invalidateSelectedAppInfo() = viewModelScope.launch {
|
||||
val info = when (val app = selectedApp) {
|
||||
is SelectedApp.Download -> null
|
||||
is SelectedApp.Local -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.file) }
|
||||
is SelectedApp.Installed -> withContext(Dispatchers.IO) { pm.getPackageInfo(app.packageName) }
|
||||
else -> null
|
||||
}
|
||||
|
||||
selectedAppInfo = info
|
||||
@ -129,8 +275,6 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
this.options = filteredOptions
|
||||
|
||||
if (!persistConfiguration) return
|
||||
|
||||
val packageName = selectedApp.packageName
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
selection?.let { selectionRepository.updateSelection(packageName, it) }
|
||||
?: selectionRepository.clearSelection(packageName)
|
||||
@ -144,6 +288,10 @@ class SelectedAppInfoViewModel(input: Params) : ViewModel(), KoinComponent {
|
||||
val patches: PatchSelection?,
|
||||
)
|
||||
|
||||
enum class Error(@StringRes val resourceId: Int) {
|
||||
NoPlugins(R.string.downloader_no_plugins_available)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Returns a copy with all nonexistent options removed.
|
||||
|
@ -1,173 +0,0 @@
|
||||
package app.revanced.manager.ui.viewmodel
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.revanced.manager.data.room.apps.installed.InstalledApp
|
||||
import app.revanced.manager.domain.installer.RootInstaller
|
||||
import app.revanced.manager.domain.manager.PreferencesManager
|
||||
import app.revanced.manager.domain.repository.DownloadedAppRepository
|
||||
import app.revanced.manager.domain.repository.InstalledAppRepository
|
||||
import app.revanced.manager.domain.repository.PatchBundleRepository
|
||||
import app.revanced.manager.network.downloader.APKMirror
|
||||
import app.revanced.manager.network.downloader.AppDownloader
|
||||
import app.revanced.manager.ui.model.SelectedApp
|
||||
import app.revanced.manager.util.PM
|
||||
import app.revanced.manager.util.mutableStateSetOf
|
||||
import app.revanced.manager.util.simpleMessage
|
||||
import app.revanced.manager.util.tag
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
class VersionSelectorViewModel(
|
||||
val packageName: String
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val downloadedAppRepository: DownloadedAppRepository by inject()
|
||||
private val installedAppRepository: InstalledAppRepository by inject()
|
||||
private val patchBundleRepository: PatchBundleRepository by inject()
|
||||
private val pm: PM by inject()
|
||||
private val prefs: PreferencesManager by inject()
|
||||
private val appDownloader: AppDownloader = APKMirror()
|
||||
val rootInstaller: RootInstaller by inject()
|
||||
|
||||
var installedApp: Pair<PackageInfo, InstalledApp?>? by mutableStateOf(null)
|
||||
private set
|
||||
var isLoading by mutableStateOf(true)
|
||||
private set
|
||||
var errorMessage: String? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var requiredVersion: String? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var selectedVersion: SelectedApp? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
private var nonSuggestedVersionDialogSubject by mutableStateOf<SelectedApp?>(null)
|
||||
val showNonSuggestedVersionDialog by derivedStateOf { nonSuggestedVersionDialogSubject != null }
|
||||
|
||||
private val requiredVersionAsync = viewModelScope.async(Dispatchers.Default) {
|
||||
if (!prefs.suggestedVersionSafeguard.get()) return@async null
|
||||
|
||||
patchBundleRepository.suggestedVersions.first()[packageName]
|
||||
}
|
||||
|
||||
val supportedVersions = patchBundleRepository.bundles.map supportedVersions@{ bundles ->
|
||||
requiredVersionAsync.await()?.let { version ->
|
||||
// It is mandatory to use the suggested version if the safeguard is enabled.
|
||||
return@supportedVersions mapOf(
|
||||
version to bundles
|
||||
.asSequence()
|
||||
.flatMap { (_, bundle) -> bundle.patches }
|
||||
.flatMap { it.compatiblePackages.orEmpty() }
|
||||
.filter { it.packageName == packageName }
|
||||
.count { it.versions.isNullOrEmpty() || version in it.versions }
|
||||
)
|
||||
}
|
||||
|
||||
var patchesWithoutVersions = 0
|
||||
|
||||
bundles.flatMap { (_, bundle) ->
|
||||
bundle.patches.flatMap { patch ->
|
||||
patch.compatiblePackages.orEmpty()
|
||||
.filter { it.packageName == packageName }
|
||||
.onEach { if (it.versions == null) patchesWithoutVersions++ }
|
||||
.flatMap { it.versions.orEmpty() }
|
||||
}
|
||||
}.groupingBy { it }
|
||||
.eachCount()
|
||||
.toMutableMap()
|
||||
.apply {
|
||||
replaceAll { _, count ->
|
||||
count + patchesWithoutVersions
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.Default)
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
requiredVersion = requiredVersionAsync.await()
|
||||
}
|
||||
}
|
||||
|
||||
val downloadableVersions = mutableStateSetOf<SelectedApp.Download>()
|
||||
|
||||
val downloadedVersions = downloadedAppRepository.getAll().map { downloadedApps ->
|
||||
downloadedApps.filter { it.packageName == packageName }.map {
|
||||
SelectedApp.Local(
|
||||
it.packageName,
|
||||
it.version,
|
||||
downloadedAppRepository.getApkFileForApp(it),
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
val packageInfo = async(Dispatchers.IO) { pm.getPackageInfo(packageName) }
|
||||
val installedAppDeferred =
|
||||
async(Dispatchers.IO) { installedAppRepository.get(packageName) }
|
||||
|
||||
installedApp =
|
||||
packageInfo.await()?.let {
|
||||
it to installedAppDeferred.await()
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val compatibleVersions = supportedVersions.first()
|
||||
|
||||
appDownloader.getAvailableVersions(
|
||||
packageName,
|
||||
compatibleVersions.keys
|
||||
).collect {
|
||||
if (it.version in compatibleVersions || compatibleVersions.isEmpty()) {
|
||||
downloadableVersions.add(
|
||||
SelectedApp.Download(
|
||||
packageName,
|
||||
it.version,
|
||||
it
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
isLoading = false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Log.e(tag, "Failed to load apps", e)
|
||||
errorMessage = e.simpleMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNonSuggestedVersionDialog() {
|
||||
nonSuggestedVersionDialogSubject = null
|
||||
}
|
||||
|
||||
fun select(app: SelectedApp) {
|
||||
if (requiredVersion != null && app.version != requiredVersion) {
|
||||
nonSuggestedVersionDialogSubject = app
|
||||
return
|
||||
}
|
||||
|
||||
selectedVersion = app
|
||||
}
|
||||
}
|
@ -8,9 +8,10 @@ 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 androidx.core.content.pm.PackageInfoCompat
|
||||
import android.content.pm.Signature
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Immutable
|
||||
@ -37,7 +38,6 @@ data class AppInfo(
|
||||
) : Parcelable
|
||||
|
||||
@SuppressLint("QueryPermissionsNeeded")
|
||||
@Suppress("DEPRECATION")
|
||||
class PM(
|
||||
private val app: Application,
|
||||
patchBundleRepository: PatchBundleRepository
|
||||
@ -68,7 +68,7 @@ class PM(
|
||||
}
|
||||
|
||||
val installedApps = scope.async {
|
||||
app.packageManager.getInstalledPackages(MATCH_UNINSTALLED_PACKAGES).map { packageInfo ->
|
||||
getInstalledPackages().map { packageInfo ->
|
||||
AppInfo(
|
||||
packageInfo.packageName,
|
||||
0,
|
||||
@ -81,7 +81,7 @@ class PM(
|
||||
(compatibleApps.await() + installedApps.await())
|
||||
.distinctBy { it.packageName }
|
||||
.sortedWith(
|
||||
compareByDescending<AppInfo>{
|
||||
compareByDescending<AppInfo> {
|
||||
it.packageInfo != null && (it.patches ?: 0) > 0
|
||||
}.thenByDescending {
|
||||
it.patches
|
||||
@ -94,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) =
|
||||
getInstalledPackages(PackageManager.GET_CONFIGURATIONS)
|
||||
.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
|
||||
}
|
||||
@ -118,6 +133,18 @@ class PM(
|
||||
|
||||
fun getVersionCode(packageInfo: PackageInfo) = PackageInfoCompat.getLongVersionCode(packageInfo)
|
||||
|
||||
fun getSignature(packageName: String): Signature =
|
||||
// Get the last signature from the list because we want the newest one if SigningInfo.getSigningCertificateHistory() was used.
|
||||
PackageInfoCompat.getSignatures(app.packageManager, packageName).last()
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
fun hasSignature(packageName: String, signature: ByteArray) = PackageInfoCompat.hasSignatures(
|
||||
app.packageManager,
|
||||
packageName,
|
||||
mapOf(signature to PackageManager.CERT_INPUT_RAW_X509),
|
||||
false
|
||||
)
|
||||
|
||||
suspend fun installApp(apks: List<File>) = withContext(Dispatchers.IO) {
|
||||
val packageInstaller = app.packageManager.packageInstaller
|
||||
packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session ->
|
||||
|
@ -12,17 +12,22 @@ import androidx.compose.material3.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import app.revanced.manager.R
|
||||
@ -168,6 +173,30 @@ fun LocalDateTime.relativeTime(context: Context): String {
|
||||
}
|
||||
}
|
||||
|
||||
private var transparentListItemColorsCached: ListItemColors? = null
|
||||
|
||||
/**
|
||||
* The default ListItem colors, but with [ListItemColors.containerColor] set to [Color.Transparent].
|
||||
*/
|
||||
val transparentListItemColors
|
||||
@Composable get() = transparentListItemColorsCached
|
||||
?: ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
.also { transparentListItemColorsCached = it }
|
||||
|
||||
@Composable
|
||||
fun <T> EventEffect(flow: Flow<T>, vararg keys: Any?, state: Lifecycle.State = Lifecycle.State.STARTED, block: suspend (T) -> Unit) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentBlock by rememberUpdatedState(block)
|
||||
|
||||
LaunchedEffect(flow, state, *keys) {
|
||||
lifecycleOwner.repeatOnLifecycle(state) {
|
||||
flow.collect {
|
||||
currentBlock(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val isScrollingUpSensitivity = 10
|
||||
|
||||
@Composable
|
||||
@ -231,12 +260,4 @@ fun <T, R> ((T) -> R).withHapticFeedback(constant: Int): (T) -> R {
|
||||
}
|
||||
}
|
||||
|
||||
private var transparentListItemColorsCached: ListItemColors? = null
|
||||
|
||||
/**
|
||||
* The default ListItem colors, but with [ListItemColors.containerColor] set to [Color.Transparent].
|
||||
*/
|
||||
val transparentListItemColors
|
||||
@Composable get() = transparentListItemColorsCached
|
||||
?: ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
.also { transparentListItemColorsCached = it }
|
||||
fun Modifier.enabled(condition: Boolean) = if (condition) this else alpha(0.5f)
|
@ -5,6 +5,9 @@
|
||||
<string name="cli">CLI</string>
|
||||
<string name="manager">Manager</string>
|
||||
|
||||
<string name="plugin_host_permission_label">ReVanced Manager plugin host</string>
|
||||
<string name="plugin_host_permission_description">Used to control access to ReVanced Manager plugins. Only ReVanced Manager has this.</string>
|
||||
|
||||
<string name="toast_copied_to_clipboard">Copied!</string>
|
||||
<string name="copy_to_clipboard">Copy to clipboard</string>
|
||||
|
||||
@ -13,6 +16,7 @@
|
||||
<string name="select_app">Select an app</string>
|
||||
<string name="patches_selected">%1$d/%2$d selected</string>
|
||||
|
||||
<string name="new_downloader_plugins_notification">New downloader plugins available. Click here to configure them.</string>
|
||||
<string name="unsupported_architecture_warning">Patching on this device architecture is unsupported and will most likely fail.</string>
|
||||
|
||||
<string name="import_">Import</string>
|
||||
@ -31,15 +35,24 @@
|
||||
<string name="bundle_name_default">Default</string>
|
||||
<string name="bundle_name_fallback">Unnamed</string>
|
||||
|
||||
<string name="selected_app_meta">%1$s</string>
|
||||
<string name="selected_app_meta_any_version">Any available version</string>
|
||||
<string name="app_source_dialog_title">Select source</string>
|
||||
<string name="app_source_dialog_option_auto">Auto</string>
|
||||
<string name="app_source_dialog_option_auto_description">Use all installed downloaders to find a suitable APK file</string>
|
||||
<string name="app_source_dialog_option_auto_unavailable">No plugins available</string>
|
||||
<string name="app_source_dialog_option_installed_no_root">Mounted apps cannot be patched again without root access</string>
|
||||
<string name="app_source_dialog_option_installed_version_not_suggested">Version %s does not match the suggested version</string>
|
||||
|
||||
<string name="patch_item_description">Start patching the application</string>
|
||||
<string name="patch_selector_item">Patch selection and options</string>
|
||||
<string name="patch_selector_item_description">%d patches selected</string>
|
||||
<string name="no_patches_selected">No patches selected</string>
|
||||
|
||||
<string name="version_selector_item">Change version</string>
|
||||
<string name="version_selector_item_description">%s selected</string>
|
||||
<string name="apk_source_selector_item">Change source</string>
|
||||
<string name="apk_source_auto">Current: All downloaders</string>
|
||||
<string name="apk_source_downloader">Current: %s</string>
|
||||
<string name="apk_source_installed">Current: Installed</string>
|
||||
<string name="apk_source_local">Current: File</string>
|
||||
|
||||
<string name="legacy_import_failed">Could not import legacy settings</string>
|
||||
|
||||
@ -111,10 +124,14 @@
|
||||
<string name="patch_options_reset_bundle_description">Resets patch options for all patches in a bundle</string>
|
||||
<string name="patch_options_reset_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 to load. Click for more details</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>
|
||||
@ -227,10 +244,11 @@
|
||||
<string name="unpatch_app">Unpatch app?</string>
|
||||
<string name="unpatch_description">Are you sure you want to unpatch this app?</string>
|
||||
|
||||
<string name="error_occurred">An error occurred</string>
|
||||
<string name="already_downloaded">Already downloaded</string>
|
||||
<string name="select_version">Select version</string>
|
||||
<string name="downloadable_versions">Downloadable versions</string>
|
||||
<string name="downloader_invalid_version">Downloader did not fetch the correct version</string>
|
||||
<string name="downloader_app_not_found">Downloader did not find the app</string>
|
||||
<string name="downloader_error">Downloader error: %s</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>
|
||||
@ -258,6 +276,7 @@
|
||||
<string name="save_apk_success">APK Saved</string>
|
||||
<string name="sign_fail">Failed to sign APK: %s</string>
|
||||
<string name="save_logs">Save logs</string>
|
||||
<string name="plugin_activity_dialog_body">User interaction is required in order to proceed with this plugin.</string>
|
||||
<string name="select_install_type">Select installation type</string>
|
||||
|
||||
<string name="patcher_step_group_preparing">Preparing</string>
|
||||
@ -383,6 +402,7 @@
|
||||
<string name="auto_update">Auto update</string>
|
||||
<string name="unsupported_patches_dialog">These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details.</string>
|
||||
<string name="unsupported_patch">Unsupported patch</string>
|
||||
<string name="any_version">Any</string>
|
||||
<string name="never_show_again">Never show again</string>
|
||||
<string name="show_manager_update_dialog_on_launch">Show update message on launch</string>
|
||||
<string name="show_manager_update_dialog_on_launch_description">Shows a popup notification whenever there is a new update available on launch.</string>
|
||||
|
@ -4,5 +4,7 @@
|
||||
<style name="Theme.ReVancedManager" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.AppCompat.NoActionBar</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
</style>
|
||||
</resources>
|
Reference in New Issue
Block a user