diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 79b726bb..87f37444 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f284b52a..b9b9c1af 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -49,6 +49,10 @@ -keep class com.android.** { *; } +-keep class app.revanced.manager.plugin.** { + *; +} + -dontwarn com.google.auto.value.** -dontwarn java.awt.** -dontwarn javax.** diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json index eff10786..fd83a51e 100644 --- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "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')" ] } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3acb1c04..0404d045 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,16 @@ - + + + @@ -17,12 +24,6 @@ tools:ignore="ScopedStorage" /> - - - - - - + + diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 4c8d9ef7..ff01094c 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -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(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( diff --git a/app/src/main/java/app/revanced/manager/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/ManagerApplication.kt index 66ab2483..2060e602 100644 --- a/app/src/main/java/app/revanced/manager/ManagerApplication.kt +++ b/app/src/main/java/app/revanced/manager/ManagerApplication.kt @@ -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() diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt index 0440a7c2..403bd1cf 100644 --- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt +++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt @@ -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() diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt index 60d1561d..f1703314 100644 --- a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt +++ b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedApp.kt @@ -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() ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt index 4f4d9623..492dbde1 100644 --- a/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/apps/downloaded/DownloadedAppDao.kt @@ -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) diff --git a/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt new file mode 100644 index 00000000..8e1b9c39 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPlugin.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt new file mode 100644 index 00000000..ad1845f7 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/plugins/TrustedDownloaderPluginDao.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt index df2d7018..159436d4 100644 --- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt @@ -22,6 +22,7 @@ val repositoryModule = module { // It is best to load patch bundles ASAP createdAtStart() } + singleOf(::DownloaderPluginRepository) singleOf(::WorkerRepository) singleOf(::DownloadedAppRepository) singleOf(::InstalledAppRepository) diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index 0c69767c..a59d65a2 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -12,7 +12,6 @@ val viewModelModule = module { viewModelOf(::SettingsViewModel) viewModelOf(::AdvancedSettingsViewModel) viewModelOf(::AppSelectorViewModel) - viewModelOf(::VersionSelectorViewModel) viewModelOf(::PatcherViewModel) viewModelOf(::UpdateViewModel) viewModelOf(::ChangelogsViewModel) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index 8cdc1f19..dbf2f100 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -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()) } diff --git a/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt index 2b04bd41..06f75465 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/base/BasePreferencesManager.kt @@ -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) = + 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 Preference.value get() = prefs.run { read() } set(value) = prefs.run { write(value) } + + operator fun Preference>.plusAssign(value: String) = prefs.run { + write(read() + value) + } } abstract class Preference( @@ -65,10 +72,12 @@ abstract class Preference( 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, + key: String, + default: Set +) : BasePreference>(dataStore, default) { + override val key = stringSetPreferencesKey(key) +} + class BooleanPreference( dataStore: DataStore, key: String, diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt index fe339a2e..b4598fb9 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloadedAppRepository.kt @@ -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?) -> Unit = {}, + plugin: LoadedDownloaderPlugin, + data: Parcelable, + expectedPackageName: String, + expectedVersion: String?, + onDownload: suspend (downloadProgress: Pair) -> 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) { downloadedApps.forEach { diff --git a/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt new file mode 100644 index 00000000..791a09ac --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/repository/DownloaderPluginRepository.kt @@ -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()) + val pluginStates = _pluginStates.asStateFlow() + val loadedPluginsFlow = pluginStates.map { states -> + states.values.filterIsInstance().map { it.plugin } + } + + private val acknowledgedDownloaderPlugins = prefs.acknowledgedDownloaderPlugins + private val installedPluginPackageNames = MutableStateFlow(emptySet()) + 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 { + 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 } + ?: throw Exception("Could not find a valid downloader implementation in class $canonicalName") + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt b/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt deleted file mode 100644 index 25365551..00000000 --- a/app/src/main/java/app/revanced/manager/network/downloader/APKMirror.kt +++ /dev/null @@ -1,277 +0,0 @@ -package app.revanced.manager.network.downloader - -import android.os.Build.SUPPORTED_ABIS -import app.revanced.manager.network.service.HttpService -import io.ktor.client.plugins.onDownload -import io.ktor.client.request.parameter -import io.ktor.client.request.url -import it.skrape.selects.html5.a -import it.skrape.selects.html5.div -import it.skrape.selects.html5.form -import it.skrape.selects.html5.h5 -import it.skrape.selects.html5.input -import it.skrape.selects.html5.p -import it.skrape.selects.html5.span -import kotlinx.coroutines.flow.flow -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -import org.koin.core.component.KoinComponent -import org.koin.core.component.get -import org.koin.core.component.inject -import java.io.File - -class APKMirror : AppDownloader, KoinComponent { - private val httpClient: HttpService = get() - - enum class APKType { - APK, - BUNDLE - } - - data class Variant( - val apkType: APKType, - val arch: String, - val link: String - ) - - private suspend fun getAppLink(packageName: String): String { - val searchResults = httpClient.getHtml { url("$APK_MIRROR/?post_type=app_release&searchtype=app&s=$packageName") } - .div { - withId = "content" - findFirst { - div { - withClass = "listWidget" - findAll { - - find { - it.children.first().text.contains(packageName) - }!!.children.mapNotNull { - if (it.classNames.isEmpty()) { - it.h5 { - withClass = "appRowTitle" - findFirst { - a { - findFirst { - attribute("href") - } - } - } - } - } else null - } - - } - } - } - } - - return searchResults.find { url -> - httpClient.getHtml { url(APK_MIRROR + url) } - .div { - withId = "primary" - findFirst { - div { - withClass = "tab-buttons" - findFirst { - div { - withClass = "tab-button-positioning" - findFirst { - children.any { - it.attribute("href") == "https://play.google.com/store/apps/details?id=$packageName" - } - } - } - } - } - } - } - } ?: throw Exception("App isn't available for download") - } - - override fun getAvailableVersions(packageName: String, versionFilter: Set) = flow { - - // We have to hardcode some apps since there are multiple apps with that package name - val appCategory = when (packageName) { - "com.google.android.apps.youtube.music" -> "youtube-music" - "com.google.android.youtube" -> "youtube" - else -> getAppLink(packageName).split("/")[3] - } - - var page = 1 - - val versions = mutableListOf() - - while ( - if (versionFilter.isNotEmpty()) - versions.size < versionFilter.size && page <= 7 - else - page <= 1 - ) { - httpClient.getHtml { - url("$APK_MIRROR/uploads/page/$page/") - parameter("appcategory", appCategory) - }.div { - withClass = "widget_appmanager_recentpostswidget" - findFirst { - div { - withClass = "listWidget" - findFirst { - children.mapNotNull { element -> - if (element.className.isEmpty()) { - - APKMirrorApp( - packageName = packageName, - version = element.div { - withClass = "infoSlide" - findFirst { - p { - findFirst { - span { - withClass = "infoSlide-value" - findFirst { - text - } - } - } - } - } - }.also { - if (it in versionFilter) - versions.add(it) - }, - downloadLink = element.findFirst { - a { - withClass = "downloadLink" - findFirst { - attribute("href") - } - } - } - ) - - } else null - } - } - } - } - }.onEach { version -> emit(version) } - - page++ - } - } - - @Parcelize - private class APKMirrorApp( - override val packageName: String, - override val version: String, - private val downloadLink: String, - ) : AppDownloader.App, KoinComponent { - @IgnoredOnParcel private val httpClient: HttpService by inject() - - override suspend fun download( - saveDirectory: File, - preferSplit: Boolean, - onDownload: suspend (downloadProgress: Pair?) -> Unit - ) { - val variants = httpClient.getHtml { url(APK_MIRROR + downloadLink) } - .div { - withClass = "variants-table" - findFirst { // list of variants - children.drop(1).map { - Variant( - apkType = it.div { - findFirst { - span { - findFirst { - enumValueOf(text) - } - } - } - }, - arch = it.div { - findSecond { - text - } - }, - link = it.div { - findFirst { - a { - findFirst { - attribute("href") - } - } - } - } - ) - } - } - } - - val orderedAPKTypes = mutableListOf(APKType.APK, APKType.BUNDLE) - .also { if (preferSplit) it.reverse() } - - val variant = orderedAPKTypes.firstNotNullOfOrNull { apkType -> - supportedArches.firstNotNullOfOrNull { arch -> - variants.find { it.arch == arch && it.apkType == apkType } - } - } ?: throw Exception("No compatible variant found") - - if (variant.apkType == APKType.BUNDLE) throw Exception("Split apks are not supported yet") // TODO - - val downloadPage = httpClient.getHtml { url(APK_MIRROR + variant.link) } - .a { - withClass = "downloadButton" - findFirst { - attribute("href") - } - } - - val downloadLink = httpClient.getHtml { url(APK_MIRROR + downloadPage) } - .form { - withId = "filedownload" - findFirst { - val apkLink = attribute("action") - val id = input { - withAttribute = "name" to "id" - findFirst { - attribute("value") - } - } - val key = input { - withAttribute = "name" to "key" - findFirst { - attribute("value") - } - } - "$apkLink?id=$id&key=$key" - } - } - - val targetFile = saveDirectory.resolve("base.apk") - - try { - httpClient.download(targetFile) { - url(APK_MIRROR + downloadLink) - onDownload { bytesSentTotal, contentLength -> - onDownload(bytesSentTotal.div(100000).toFloat().div(10) to contentLength.div(100000).toFloat().div(10)) - } - } - - if (variant.apkType == APKType.BUNDLE) { - // TODO: Extract temp.zip - - targetFile.delete() - } - } finally { - onDownload(null) - } - } - } - - companion object { - const val APK_MIRROR = "https://www.apkmirror.com" - - val supportedArches = listOf("universal", "noarch") + SUPPORTED_ABIS - } - -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt b/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt deleted file mode 100644 index dcefa26e..00000000 --- a/app/src/main/java/app/revanced/manager/network/downloader/AppDownloader.kt +++ /dev/null @@ -1,27 +0,0 @@ -package app.revanced.manager.network.downloader - -import android.os.Parcelable -import kotlinx.coroutines.flow.Flow -import java.io.File - -interface AppDownloader { - - /** - * Returns all downloadable apps. - * - * @param packageName The package name of the app. - * @param versionFilter A set of versions to filter. - */ - fun getAvailableVersions(packageName: String, versionFilter: Set): Flow - - interface App : Parcelable { - val packageName: String - val version: String - - suspend fun download( - saveDirectory: File, - preferSplit: Boolean, - onDownload: suspend (downloadProgress: Pair?) -> Unit = {} - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt b/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt new file mode 100644 index 00000000..a72d60c7 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/DownloaderPluginState.kt @@ -0,0 +1,9 @@ +package app.revanced.manager.network.downloader + +sealed interface DownloaderPluginState { + data object Untrusted : DownloaderPluginState + + data class Loaded(val plugin: LoadedDownloaderPlugin) : DownloaderPluginState + + data class Failed(val throwable: Throwable) : DownloaderPluginState +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt new file mode 100644 index 00000000..50ddd561 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/LoadedDownloaderPlugin.kt @@ -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?, + val download: suspend OutputDownloadScope.(data: Parcelable, outputStream: OutputStream) -> Unit, + val classLoader: ClassLoader +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt b/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt new file mode 100644 index 00000000..a43db930 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/downloader/ParceledDownloaderData.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt index cd2a2f83..2babc7f4 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchInfo.kt @@ -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 } } diff --git a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt index c295bde1..d2b5babb 100644 --- a/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt +++ b/app/src/main/java/app/revanced/manager/patcher/worker/PatcherWorker.kt @@ -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?>, + val downloadProgress: MutableStateFlow?>, val patchesProgress: MutableStateFlow>, + 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) { diff --git a/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt new file mode 100644 index 00000000..1ceb9cef --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/ExceptionViewerDialog.kt @@ -0,0 +1,79 @@ +package app.revanced.manager.ui.component + +import android.content.Intent +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.revanced.manager.R +import app.revanced.manager.ui.component.bundle.BundleTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExceptionViewerDialog(text: String, onDismiss: () -> Unit) { + val context = LocalContext.current + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnBackPress = true + ) + ) { + Scaffold( + topBar = { + BundleTopBar( + title = stringResource(R.string.bundle_error), + onBackClick = onDismiss, + backIcon = { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + stringResource(R.string.back) + ) + }, + actions = { + IconButton( + onClick = { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra( + Intent.EXTRA_TEXT, + text + ) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + } + ) { + Icon( + Icons.Outlined.Share, + contentDescription = stringResource(R.string.share) + ) + } + } + ) + } + ) { paddingValues -> + ColumnWithScrollbar( + modifier = Modifier.padding(paddingValues) + ) { + Text(text, modifier = Modifier.horizontalScroll(rememberScrollState())) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt b/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt index 303cff05..04b5b589 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/SearchView.kt @@ -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 ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt index 83c60e0f..eaebd834 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt @@ -1,21 +1,16 @@ package app.revanced.manager.ui.component.bundle -import android.content.Intent import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.outlined.DeleteOutline -import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.* 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())) - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt b/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt index 6840837b..280635ce 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/patcher/Steps.kt @@ -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? = null + downloadProgress: Pair? = 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? = null, size: Dp) { +fun StepIcon(state: State, progress: Pair? = null, size: Dp) { val strokeWidth = Dp(floor(size.value / 10) + 1) when (state) { @@ -233,8 +234,15 @@ fun StepIcon(state: State, progress: Pair? = 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 ) } -} \ No newline at end of file +} + +private val Long.megaBytes get() = "%.1f".format(locale = Locale.ROOT, toDouble() / 1_000_000) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt index 2d40dda7..7c680477 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/settings/SettingsListItem.kt @@ -22,13 +22,36 @@ fun SettingsListItem( colors: ListItemColors = ListItemDefaults.colors(), tonalElevation: Dp = ListItemDefaults.Elevation, shadowElevation: Dp = ListItemDefaults.Elevation, -) = ListItem( +) = SettingsListItem( headlineContent = { Text( text = headlineContent, style = MaterialTheme.typography.titleLarge ) }, + modifier = modifier, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + colors = colors, + tonalElevation = tonalElevation, + shadowElevation = shadowElevation +) + +@Composable +fun SettingsListItem( + headlineContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + overlineContent: @Composable (() -> Unit)? = null, + supportingContent: String? = null, + leadingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), + tonalElevation: Dp = ListItemDefaults.Elevation, + shadowElevation: Dp = ListItemDefaults.Elevation, +) = ListItem( + headlineContent = headlineContent, modifier = modifier.then(Modifier.padding(horizontal = 8.dp)), overlineContent = overlineContent, supportingContent = { diff --git a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt index e15bdfb6..93c59411 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/Destination.kt @@ -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 diff --git a/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt b/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt index a1fafa32..9a1f3e29 100644 --- a/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt +++ b/app/src/main/java/app/revanced/manager/ui/destination/SelectedAppInfoDestination.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt index e8dc938d..e2bd8b1e 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt @@ -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 diff --git a/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt b/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt index 4c7fc417..c08c823e 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/PatcherStep.kt @@ -19,5 +19,5 @@ data class Step( val category: StepCategory, val state: State = State.WAITING, val message: String? = null, - val downloadProgress: StateFlow?>? = null + val downloadProgress: StateFlow?>? = null ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt b/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt index 4e3e8807..5d05c4ea 100644 --- a/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt +++ b/app/src/main/java/app/revanced/manager/ui/model/SelectedApp.kt @@ -1,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 } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt index eb814c3b..ce6a13ce 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/AppSelectorScreen.kt @@ -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( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt index 4dc74766..6d316899 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt @@ -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 ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt index 239aebbf..9ddf2ef8 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/InstalledAppInfoScreen.kt @@ -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() ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt index 6aedde2f..5df81ed3 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatcherScreen.kt @@ -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( diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 511a1c36..e38f320b 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -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 diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt index 9c3f59d4..9fb48372 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SelectedAppInfoScreen.kt @@ -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(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, + installedApp: Pair?, + 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 + ) + } + } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt deleted file mode 100644 index a8d12d50..00000000 --- a/app/src/main/java/app/revanced/manager/ui/screen/VersionSelectorScreen.kt +++ /dev/null @@ -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 { - 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 - } - ) -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index 41e80b40..68e02942 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -1,39 +1,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) } + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt index 2acfdcd5..eaa66f47 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt @@ -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() + 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 } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt index 5a019c51..99be81ec 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt @@ -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() + 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 } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt index 4688cf16..e2c750df 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DownloadsViewModel.kt @@ -1,10 +1,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 { @@ -23,24 +30,39 @@ class DownloadsViewModel( }.thenBy { it.version } ) } + val appSelection = mutableStateSetOf() - val selection = mutableStateSetOf() + var isRefreshingPlugins by mutableStateOf(false) + private set - fun toggleItem(downloadedApp: DownloadedApp) { - if (selection.contains(downloadedApp)) - selection.remove(downloadedApp) + fun toggleApp(downloadedApp: DownloadedApp) { + if (appSelection.contains(downloadedApp)) + appSelection.remove(downloadedApp) else - selection.add(downloadedApp) + appSelection.add(downloadedApp) } - fun delete() { + fun deleteApps() { viewModelScope.launch(NonCancellable) { - downloadedAppRepository.delete(selection) + downloadedAppRepository.delete(appSelection) withContext(Dispatchers.Main) { - selection.clear() + appSelection.clear() } } } + fun refreshPlugins() = viewModelScope.launch { + isRefreshingPlugins = true + downloaderPluginRepository.reload() + isRefreshingPlugins = false + } + + fun trustPlugin(packageName: String) = viewModelScope.launch { + downloaderPluginRepository.trustPackage(packageName) + } + + fun revokePluginTrust(packageName: String) = viewModelScope.launch { + downloaderPluginRepository.revokeTrustForPackage(packageName) + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt index 1b9aab30..d665efb0 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/MainViewModel.kt @@ -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() + 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 diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt index 00c1231f..5ab42a89 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatcherViewModel.kt @@ -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, String>? by mutableStateOf(null) + val activityPromptDialog by derivedStateOf { currentActivityRequest?.second } + + private var launchedActivity: CompletableDeferred? = null + private val launchActivityChannel = Channel() + 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?>(null) + private val downloadProgress = MutableStateFlow?>(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()) { + currentActivityRequest = this to plugin.name + + await() + } + if (!accepted) throw UserInteractionException.RequestDenied() + + // Launch the activity and wait for the result. + try { + with(CompletableDeferred()) { + 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?>? = null + downloadProgress: StateFlow?>? = null ): List { - val needsDownload = selectedApp is SelectedApp.Download + val needsDownload = + selectedApp is SelectedApp.Download || selectedApp is SelectedApp.Search return listOfNotNull( Step( diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index 4dba4136..3e16d69e 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -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? 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? by mutableStateOf(null) + val activePluginAction get() = pluginAction?.first?.packageName + private var launchedActivity by mutableStateOf?>(null) + private val launchActivityChannel = Channel() + 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()) { + 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. diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt deleted file mode 100644 index d9f73264..00000000 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/VersionSelectorViewModel.kt +++ /dev/null @@ -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? by mutableStateOf(null) - private set - var isLoading by mutableStateOf(true) - private set - var errorMessage: String? by mutableStateOf(null) - private set - - var requiredVersion: String? by mutableStateOf(null) - private set - - var selectedVersion: SelectedApp? by mutableStateOf(null) - private set - - private var nonSuggestedVersionDialogSubject by mutableStateOf(null) - val showNonSuggestedVersionDialog by derivedStateOf { nonSuggestedVersionDialogSubject != null } - - private val requiredVersionAsync = viewModelScope.async(Dispatchers.Default) { - if (!prefs.suggestedVersionSafeguard.get()) return@async null - - 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() - - 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 - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt index d0e6dbb4..f137e699 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -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{ + compareByDescending { 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 = + 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) = withContext(Dispatchers.IO) { val packageInstaller = app.packageManager.packageInstaller packageInstaller.openSession(packageInstaller.createSession(sessionParams)).use { session -> diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index 5cb379e3..ac0046df 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -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 EventEffect(flow: Flow, 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).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 } \ No newline at end of file +fun Modifier.enabled(condition: Boolean) = if (condition) this else alpha(0.5f) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20722c02..b9ba4305 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,9 @@ CLI Manager + ReVanced Manager plugin host + Used to control access to ReVanced Manager plugins. Only ReVanced Manager has this. + Copied! Copy to clipboard @@ -13,6 +16,7 @@ Select an app %1$d/%2$d selected + New downloader plugins available. Click here to configure them. Patching on this device architecture is unsupported and will most likely fail. Import @@ -31,15 +35,24 @@ Default Unnamed - %1$s + Any available version + Select source + Auto + Use all installed downloaders to find a suitable APK file + No plugins available + Mounted apps cannot be patched again without root access + Version %s does not match the suggested version Start patching the application Patch selection and options %d patches selected No patches selected - Change version - %s selected + Change source + Current: All downloaders + Current: %s + Current: Installed + Current: File Could not import legacy settings @@ -111,10 +124,14 @@ Resets patch options for all patches in a bundle Reset patch options Resets all patch options - Prefer split APK\'s - Prefer split APK\'s instead of full APK\'s - Prefer universal APK\'s - Prefer universal instead of arch-specific APK\'s + Plugins + Trusted + Failed to load. Click for more details + Untrusted + Trust plugin? + Revoke trust? + Package name: %1$s\nSignature (SHA-256): %2$s + No downloaded apps found Search apps… Loading… @@ -227,10 +244,11 @@ Unpatch app? Are you sure you want to unpatch this app? - An error occurred - Already downloaded - Select version - Downloadable versions + Downloader did not fetch the correct version + Downloader did not find the app + Downloader error: %s + No plugins installed. + No trusted plugins available for use. Check your settings. Already patched Filter @@ -258,6 +276,7 @@ APK Saved Failed to sign APK: %s Save logs + User interaction is required in order to proceed with this plugin. Select installation type Preparing @@ -383,6 +402,7 @@ Auto update These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details. Unsupported patch + Any Never show again Show update message on launch Shows a popup notification whenever there is a new update available on launch. diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 25bde821..59eb8c34 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -4,5 +4,7 @@ \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index ca1372dd..8ed32e02 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,16 @@ plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.devtools) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.parcelize) apply false - alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.about.libraries) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.binary.compatibility.validator) } + +apiValidation { + ignoredProjects.addAll(listOf("app", "example-downloader-plugin")) + nonPublicMarkers += "app.revanced.manager.plugin.downloader.PluginHostApi" +} \ No newline at end of file diff --git a/downloader-plugin/.gitignore b/downloader-plugin/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/downloader-plugin/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/downloader-plugin/api/downloader-plugin.api b/downloader-plugin/api/downloader-plugin.api new file mode 100644 index 00000000..d3a22653 --- /dev/null +++ b/downloader-plugin/api/downloader-plugin.api @@ -0,0 +1,171 @@ +public abstract interface class app/revanced/manager/plugin/downloader/BaseDownloadScope : app/revanced/manager/plugin/downloader/Scope { +} + +public final class app/revanced/manager/plugin/downloader/ConstantsKt { + public static final field PLUGIN_HOST_PERMISSION Ljava/lang/String; +} + +public final class app/revanced/manager/plugin/downloader/DownloadUrl : android/os/Parcelable { + public static final field CREATOR Landroid/os/Parcelable$Creator; + public fun (Ljava/lang/String;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Map;)Lapp/revanced/manager/plugin/downloader/DownloadUrl; + public static synthetic fun copy$default (Lapp/revanced/manager/plugin/downloader/DownloadUrl;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lapp/revanced/manager/plugin/downloader/DownloadUrl; + public final fun describeContents ()I + public fun equals (Ljava/lang/Object;)Z + public final fun getHeaders ()Ljava/util/Map; + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public final fun toDownloadResult ()Lkotlin/Pair; + public fun toString ()Ljava/lang/String; + public final fun writeToParcel (Landroid/os/Parcel;I)V +} + +public final class app/revanced/manager/plugin/downloader/DownloadUrl$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/DownloadUrl; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/DownloadUrl; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public final class app/revanced/manager/plugin/downloader/Downloader { +} + +public final class app/revanced/manager/plugin/downloader/DownloaderBuilder { +} + +public final class app/revanced/manager/plugin/downloader/DownloaderKt { + public static final fun Downloader (Lkotlin/jvm/functions/Function1;)Lapp/revanced/manager/plugin/downloader/DownloaderBuilder; +} + +public final class app/revanced/manager/plugin/downloader/DownloaderScope : app/revanced/manager/plugin/downloader/Scope { + public final fun download (Lkotlin/jvm/functions/Function3;)V + public final fun get (Lkotlin/jvm/functions/Function4;)V + public fun getHostPackageName ()Ljava/lang/String; + public fun getPluginPackageName ()Ljava/lang/String; + public final fun useService (Landroid/content/Intent;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class app/revanced/manager/plugin/downloader/ExtensionsKt { + public static final fun download (Lapp/revanced/manager/plugin/downloader/DownloaderScope;Lkotlin/jvm/functions/Function4;)V +} + +public abstract interface class app/revanced/manager/plugin/downloader/GetScope : app/revanced/manager/plugin/downloader/Scope { + public abstract fun requestStartActivity (Landroid/content/Intent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class app/revanced/manager/plugin/downloader/InputDownloadScope : app/revanced/manager/plugin/downloader/BaseDownloadScope { +} + +public abstract interface class app/revanced/manager/plugin/downloader/OutputDownloadScope : app/revanced/manager/plugin/downloader/BaseDownloadScope { + public abstract fun reportSize (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class app/revanced/manager/plugin/downloader/Package : android/os/Parcelable { + public static final field CREATOR Landroid/os/Parcelable$Creator; + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lapp/revanced/manager/plugin/downloader/Package; + public static synthetic fun copy$default (Lapp/revanced/manager/plugin/downloader/Package;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/revanced/manager/plugin/downloader/Package; + public final fun describeContents ()I + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getVersion ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public final fun writeToParcel (Landroid/os/Parcel;I)V +} + +public final class app/revanced/manager/plugin/downloader/Package$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/Package; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/Package; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public abstract interface annotation class app/revanced/manager/plugin/downloader/PluginHostApi : java/lang/annotation/Annotation { +} + +public abstract interface class app/revanced/manager/plugin/downloader/Scope { + public abstract fun getHostPackageName ()Ljava/lang/String; + public abstract fun getPluginPackageName ()Ljava/lang/String; +} + +public abstract class app/revanced/manager/plugin/downloader/UserInteractionException : java/lang/Exception { + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public abstract class app/revanced/manager/plugin/downloader/UserInteractionException$Activity : app/revanced/manager/plugin/downloader/UserInteractionException { + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class app/revanced/manager/plugin/downloader/UserInteractionException$Activity$Cancelled : app/revanced/manager/plugin/downloader/UserInteractionException$Activity { +} + +public final class app/revanced/manager/plugin/downloader/UserInteractionException$Activity$NotCompleted : app/revanced/manager/plugin/downloader/UserInteractionException$Activity { + public final fun getIntent ()Landroid/content/Intent; + public final fun getResultCode ()I +} + +public final class app/revanced/manager/plugin/downloader/UserInteractionException$RequestDenied : app/revanced/manager/plugin/downloader/UserInteractionException { +} + +public final class app/revanced/manager/plugin/downloader/webview/APIKt { + public static final fun WebViewDownloader (Lkotlin/jvm/functions/Function4;)Lapp/revanced/manager/plugin/downloader/DownloaderBuilder; + public static final fun runWebView (Lapp/revanced/manager/plugin/downloader/GetScope;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public class app/revanced/manager/plugin/downloader/webview/IWebView$Default : app/revanced/manager/plugin/downloader/webview/IWebView { + public fun ()V + public fun asBinder ()Landroid/os/IBinder; + public fun finish ()V + public fun load (Ljava/lang/String;)V +} + +public abstract class app/revanced/manager/plugin/downloader/webview/IWebView$Stub : android/os/Binder, app/revanced/manager/plugin/downloader/webview/IWebView { + public fun ()V + public fun asBinder ()Landroid/os/IBinder; + public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/plugin/downloader/webview/IWebView; + public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z +} + +public class app/revanced/manager/plugin/downloader/webview/IWebViewEvents$Default : app/revanced/manager/plugin/downloader/webview/IWebViewEvents { + public fun ()V + public fun asBinder ()Landroid/os/IBinder; + public fun download (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public fun pageLoad (Ljava/lang/String;)V + public fun ready (Lapp/revanced/manager/plugin/downloader/webview/IWebView;)V +} + +public abstract class app/revanced/manager/plugin/downloader/webview/IWebViewEvents$Stub : android/os/Binder, app/revanced/manager/plugin/downloader/webview/IWebViewEvents { + public fun ()V + public fun asBinder ()Landroid/os/IBinder; + public static fun asInterface (Landroid/os/IBinder;)Lapp/revanced/manager/plugin/downloader/webview/IWebViewEvents; + public fun onTransact (ILandroid/os/Parcel;Landroid/os/Parcel;I)Z +} + +public final class app/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lapp/revanced/manager/plugin/downloader/webview/WebViewActivity$Parameters; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + +public abstract interface class app/revanced/manager/plugin/downloader/webview/WebViewCallbackScope : app/revanced/manager/plugin/downloader/Scope { + public abstract fun finish (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun load (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class app/revanced/manager/plugin/downloader/webview/WebViewScope : app/revanced/manager/plugin/downloader/Scope { + public final fun download (Lkotlin/jvm/functions/Function5;)V + public fun getHostPackageName ()Ljava/lang/String; + public fun getPluginPackageName ()Ljava/lang/String; + public final fun pageLoad (Lkotlin/jvm/functions/Function3;)V +} + diff --git a/downloader-plugin/build.gradle.kts b/downloader-plugin/build.gradle.kts new file mode 100644 index 00000000..9d66a6e0 --- /dev/null +++ b/downloader-plugin/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + `maven-publish` +} + +android { + namespace = "app.revanced.manager.plugin.downloader" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + aidl = true + } +} +dependencies { + implementation(libs.androidx.ktx) + implementation(libs.activity.ktx) + implementation(libs.runtime.ktx) + implementation(libs.appcompat) +} + +publishing { + repositories { + mavenLocal() + } + + publications { + create("release") { + groupId = "app.revanced" + artifactId = "manager-downloader-plugin" + version = "1.0" + + afterEvaluate { + from(components["release"]) + } + } + } +} \ No newline at end of file diff --git a/downloader-plugin/consumer-rules.pro b/downloader-plugin/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/downloader-plugin/proguard-rules.pro b/downloader-plugin/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/downloader-plugin/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/downloader-plugin/src/main/AndroidManifest.xml b/downloader-plugin/src/main/AndroidManifest.xml new file mode 100644 index 00000000..74b7379f --- /dev/null +++ b/downloader-plugin/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl new file mode 100644 index 00000000..d657fcc3 --- /dev/null +++ b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebView.aidl @@ -0,0 +1,8 @@ +// IWebView.aidl +package app.revanced.manager.plugin.downloader.webview; + +@JavaPassthrough(annotation="@app.revanced.manager.plugin.downloader.PluginHostApi") +oneway interface IWebView { + void load(String url); + void finish(); +} \ No newline at end of file diff --git a/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl new file mode 100644 index 00000000..b0237de2 --- /dev/null +++ b/downloader-plugin/src/main/aidl/app/revanced/manager/plugin/downloader/webview/IWebViewEvents.aidl @@ -0,0 +1,11 @@ +// IWebViewEvents.aidl +package app.revanced.manager.plugin.downloader.webview; + +import app.revanced.manager.plugin.downloader.webview.IWebView; + +@JavaPassthrough(annotation="@app.revanced.manager.plugin.downloader.PluginHostApi") +oneway interface IWebViewEvents { + void ready(IWebView iface); + void pageLoad(String url); + void download(String url, String mimetype, String userAgent); +} \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Constants.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Constants.kt new file mode 100644 index 00000000..469daaae --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Constants.kt @@ -0,0 +1,7 @@ +package app.revanced.manager.plugin.downloader + +/** + * The permission ID of the special plugin host permission. Only ReVanced Manager will have this permission. + * Plugin UI activities and internal services can be protected using this permission. + */ +const val PLUGIN_HOST_PERMISSION = "app.revanced.manager.permission.PLUGIN_HOST" \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt new file mode 100644 index 00000000..bf0a219b --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Downloader.kt @@ -0,0 +1,165 @@ +package app.revanced.manager.plugin.downloader + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.app.Activity +import android.os.Parcelable +import kotlinx.coroutines.withTimeout +import java.io.InputStream +import java.io.OutputStream +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This API is only intended for plugin hosts, don't use it in a plugin.", +) +@Retention(AnnotationRetention.BINARY) +annotation class PluginHostApi + +/** + * The base interface for all DSL scopes. + */ +interface Scope { + /** + * The package name of ReVanced Manager. + */ + val hostPackageName: String + + /** + * The package name of the plugin. + */ + val pluginPackageName: String +} + +/** + * The scope of [DownloaderScope.get]. + */ +interface GetScope : Scope { + /** + * Ask the user to perform some required interaction in the activity specified by the provided [Intent]. + * This function returns normally with the resulting [Intent] when the activity finishes with code [Activity.RESULT_OK]. + * + * @throws UserInteractionException.RequestDenied User decided to skip this plugin. + * @throws UserInteractionException.Activity.Cancelled The activity was cancelled. + * @throws UserInteractionException.Activity.NotCompleted The activity finished with an unknown result code. + */ + suspend fun requestStartActivity(intent: Intent): Intent? +} + +interface BaseDownloadScope : Scope + +/** + * The scope for [DownloaderScope.download]. + */ +interface InputDownloadScope : BaseDownloadScope + +typealias Size = Long +typealias DownloadResult = Pair + +typealias Version = String +typealias GetResult = Pair + +class DownloaderScope internal constructor( + private val scopeImpl: Scope, + internal val context: Context +) : Scope by scopeImpl { + // Returning an InputStream is the primary way for plugins to implement the download function, but we also want to offer an OutputStream API since using InputStream might not be convenient in all cases. + // It is much easier to implement the main InputStream API on top of OutputStreams compared to doing it the other way around, which is why we are using OutputStream here. This detail is not visible to plugins. + internal var download: (suspend OutputDownloadScope.(T, OutputStream) -> Unit)? = null + internal var get: (suspend GetScope.(String, String?) -> GetResult?)? = null + private val inputDownloadScopeImpl = object : InputDownloadScope, Scope by scopeImpl {} + + /** + * Define the download block of the plugin. + */ + fun download(block: suspend InputDownloadScope.(data: T) -> DownloadResult) { + download = { app, outputStream -> + val (inputStream, size) = inputDownloadScopeImpl.block(app) + + inputStream.use { + if (size != null) reportSize(size) + it.copyTo(outputStream) + } + } + } + + /** + * Define the get block of the plugin. + * The block should return null if the app cannot be found. The version in the result must match the version argument unless it is null. + */ + fun get(block: suspend GetScope.(packageName: String, version: String?) -> GetResult?) { + get = block + } + + /** + * Utilize the service specified by the provided [Intent]. The service will be unbound when the scope ends. + */ + suspend fun useService(intent: Intent, block: suspend (IBinder) -> R): R { + var onBind: ((IBinder) -> Unit)? = null + val serviceConn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) = + onBind!!(service!!) + + override fun onServiceDisconnected(name: ComponentName?) {} + } + + return try { + val binder = withTimeout(10000L) { + suspendCoroutine { continuation -> + onBind = continuation::resume + context.bindService(intent, serviceConn, Context.BIND_AUTO_CREATE) + } + } + block(binder) + } finally { + onBind = null + context.unbindService(serviceConn) + } + } +} + +class DownloaderBuilder internal constructor(private val block: DownloaderScope.() -> Unit) { + @PluginHostApi + fun build(scopeImpl: Scope, context: Context) = + with(DownloaderScope(scopeImpl, context)) { + block() + + Downloader( + download = download!!, + get = get!! + ) + } +} + +class Downloader internal constructor( + @property:PluginHostApi val get: suspend GetScope.(packageName: String, version: String?) -> GetResult?, + @property:PluginHostApi val download: suspend OutputDownloadScope.(data: T, outputStream: OutputStream) -> Unit +) + +/** + * Define a downloader plugin. + */ +fun Downloader(block: DownloaderScope.() -> Unit) = DownloaderBuilder(block) + +/** + * @see GetScope.requestStartActivity + */ +sealed class UserInteractionException(message: String) : Exception(message) { + class RequestDenied @PluginHostApi constructor() : + UserInteractionException("Request denied by user") + + sealed class Activity(message: String) : UserInteractionException(message) { + class Cancelled @PluginHostApi constructor() : Activity("Interaction cancelled") + + /** + * @param resultCode The result code of the activity. + * @param intent The [Intent] of the activity. + */ + class NotCompleted @PluginHostApi constructor(val resultCode: Int, val intent: Intent?) : + Activity("Unexpected activity result code: $resultCode") + } +} \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt new file mode 100644 index 00000000..a1e6bf79 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Extensions.kt @@ -0,0 +1,42 @@ +package app.revanced.manager.plugin.downloader + +import android.app.Activity +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.os.Parcelable +import java.io.OutputStream + +/** + * The scope of the [OutputStream] version of [DownloaderScope.download]. + */ +interface OutputDownloadScope : BaseDownloadScope { + suspend fun reportSize(size: Long) +} + +/** + * A replacement for [DownloaderScope.download] that uses [OutputStream]. + * The provided [OutputStream] does not need to be closed manually. + */ +fun DownloaderScope.download(block: suspend OutputDownloadScope.(T, OutputStream) -> Unit) { + download = block +} + +/** + * Performs [GetScope.requestStartActivity] with an [Intent] created using the type information of [ACTIVITY]. + * @see [GetScope.requestStartActivity] + */ +suspend inline fun GetScope.requestStartActivity() = + requestStartActivity( + Intent().apply { setClassName(pluginPackageName, ACTIVITY::class.qualifiedName!!) } + ) + +/** + * Performs [DownloaderScope.useService] with an [Intent] created using the type information of [SERVICE]. + * @see [DownloaderScope.useService] + */ +suspend inline fun DownloaderScope<*>.useService( + noinline block: suspend (IBinder) -> R +) = useService( + Intent().apply { setClassName(pluginPackageName, SERVICE::class.qualifiedName!!) }, block +) \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Parcelables.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Parcelables.kt new file mode 100644 index 00000000..414ad889 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/Parcelables.kt @@ -0,0 +1,39 @@ +package app.revanced.manager.plugin.downloader + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.net.HttpURLConnection +import java.net.URI + +/** + * A simple parcelable data class for storing a package name and version. + * This can be used as the data type for plugins that only need a name and version to implement their [DownloaderScope.download] function. + * + * @param name The package name. + * @param version The version. + */ +@Parcelize +data class Package(val name: String, val version: String) : Parcelable + +/** + * A data class for storing a download URL. + * + * @param url The download URL. + * @param headers The headers to use for the request. + */ +@Parcelize +data class DownloadUrl(val url: String, val headers: Map = emptyMap()) : Parcelable { + /** + * Converts this into a [DownloadResult]. + */ + fun toDownloadResult(): DownloadResult = with(URI.create(url).toURL().openConnection() as HttpURLConnection) { + useCaches = false + allowUserInteraction = false + headers.forEach(::setRequestProperty) + + connectTimeout = 10_000 + connect() + + inputStream to getHeaderField("Content-Length").toLong() + } +} \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt new file mode 100644 index 00000000..2e5034e1 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/API.kt @@ -0,0 +1,176 @@ +package app.revanced.manager.plugin.downloader.webview + +import android.content.Intent +import app.revanced.manager.plugin.downloader.DownloadUrl +import app.revanced.manager.plugin.downloader.DownloaderScope +import app.revanced.manager.plugin.downloader.GetScope +import app.revanced.manager.plugin.downloader.Scope +import app.revanced.manager.plugin.downloader.Downloader +import app.revanced.manager.plugin.downloader.PluginHostApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import kotlin.properties.Delegates + +typealias InitialUrl = String +typealias PageLoadCallback = suspend WebViewCallbackScope.(url: String) -> Unit +typealias DownloadCallback = suspend WebViewCallbackScope.(url: String, mimeType: String, userAgent: String) -> Unit + +interface WebViewCallbackScope : Scope { + /** + * Finishes the activity and returns the [result]. + */ + suspend fun finish(result: T) + + /** + * Tells the WebView to load the specified [url]. + */ + suspend fun load(url: String) +} + +@OptIn(PluginHostApi::class) +class WebViewScope internal constructor( + coroutineScope: CoroutineScope, + private val scopeImpl: Scope, + setResult: (T) -> Unit +) : Scope by scopeImpl { + private var onPageLoadCallback: PageLoadCallback = {} + private var onDownloadCallback: DownloadCallback = { _, _, _ -> } + + @OptIn(ExperimentalCoroutinesApi::class) + private val dispatcher = Dispatchers.Default.limitedParallelism(1) + private lateinit var webView: IWebView + internal lateinit var initialUrl: String + + internal val binder = object : IWebViewEvents.Stub() { + override fun ready(iface: IWebView?) { + coroutineScope.launch(dispatcher) { + webView = iface!!.also { + it.load(initialUrl) + } + } + } + + override fun pageLoad(url: String?) { + coroutineScope.launch(dispatcher) { onPageLoadCallback(callbackScope, url!!) } + } + + override fun download(url: String?, mimetype: String?, userAgent: String?) { + coroutineScope.launch(dispatcher) { + onDownloadCallback( + callbackScope, + url!!, + mimetype!!, + userAgent!! + ) + } + } + } + + private val callbackScope = object : WebViewCallbackScope, Scope by scopeImpl { + override suspend fun finish(result: T) { + setResult(result) + // Tell the WebViewActivity to finish + webView.let { withContext(Dispatchers.IO) { it.finish() } } + } + + override suspend fun load(url: String) { + webView.let { withContext(Dispatchers.IO) { it.load(url) } } + } + + } + + /** + * Called when the WebView attempts to download a file to disk. + */ + fun download(block: DownloadCallback) { + onDownloadCallback = block + } + + /** + * Called when the WebView finishes loading a page. + */ + fun pageLoad(block: PageLoadCallback) { + onPageLoadCallback = block + } +} + +@JvmInline +private value class Container(val value: U) + +/** + * Run a [android.webkit.WebView] Activity controlled by the provided code block. + * The activity will keep running until it is cancelled or an event handler calls [WebViewCallbackScope.finish]. + * The [block] defines the event handlers and returns the initial URL. + * + * @param title The string displayed in the action bar. + * @param block The control block. + */ +@OptIn(PluginHostApi::class) +suspend fun GetScope.runWebView( + title: String, + block: suspend WebViewScope.() -> InitialUrl +) = supervisorScope { + var result by Delegates.notNull>() + + val scope = WebViewScope(this@supervisorScope, this@runWebView) { result = Container(it) } + scope.initialUrl = scope.block() + + // Start the webview activity and wait until it finishes. + requestStartActivity(Intent().apply { + putExtra( + WebViewActivity.KEY, + WebViewActivity.Parameters(title, scope.binder) + ) + setClassName( + hostPackageName, + WebViewActivity::class.qualifiedName!! + ) + }) + + // Return the result and cancel any leftover coroutines. + coroutineContext.cancelChildren() + result.value +} + +/** + * Implement a downloader using [runWebView] and [DownloadUrl]. This function will automatically define a handler for download events unlike [runWebView]. + * Returning null inside the [block] is equivalent to returning null inside [DownloaderScope.get]. + * + * @see runWebView + */ +fun WebViewDownloader(block: suspend WebViewScope.(packageName: String, version: String?) -> InitialUrl?) = + Downloader { + val label = context.applicationInfo.loadLabel( + context.packageManager + ).toString() + + get { packageName, version -> + class ReturnNull : Exception() + + try { + runWebView(label) { + download { url, _, userAgent -> + finish( + DownloadUrl( + url, + mapOf("User-Agent" to userAgent) + ) + ) + } + + block(this@runWebView, packageName, version) ?: throw ReturnNull() + } to version + } catch (_: ReturnNull) { + null + } + } + + download { + it.toDownloadResult() + } + } \ No newline at end of file diff --git a/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt new file mode 100644 index 00000000..aff01337 --- /dev/null +++ b/downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/webview/WebViewActivity.kt @@ -0,0 +1,161 @@ +package app.revanced.manager.plugin.downloader.webview + +import android.annotation.SuppressLint +import android.os.Bundle +import android.os.IBinder +import android.os.Parcelable +import android.view.MenuItem +import android.webkit.CookieManager +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.addCallback +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.viewModelScope +import app.revanced.manager.plugin.downloader.PluginHostApi +import app.revanced.manager.plugin.downloader.R +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@OptIn(PluginHostApi::class) +@PluginHostApi +class WebViewActivity : ComponentActivity() { + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val vm by viewModels() + enableEdgeToEdge() + setContentView(R.layout.activity_webview) + + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + val webView = findViewById(R.id.webview) + onBackPressedDispatcher.addCallback { + if (webView.canGoBack()) webView.goBack() + else cancelActivity() + } + + val params = intent.getParcelableExtra(KEY)!! + actionBar?.apply { + title = params.title + setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel) + setDisplayHomeAsUpEnabled(true) + } + + val events = IWebViewEvents.Stub.asInterface(params.events)!! + vm.setup(events) + + webView.apply { + settings.apply { + cacheMode = WebSettings.LOAD_NO_CACHE + allowContentAccess = false + domStorageEnabled = true + javaScriptEnabled = true + } + + webViewClient = vm.webViewClient + setDownloadListener { url, userAgent, _, mimetype, _ -> + vm.onDownload(url, mimetype, userAgent) + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + vm.commands.collect { + when (it) { + is WebViewModel.Command.Finish -> { + setResult(RESULT_OK) + finish() + } + + is WebViewModel.Command.Load -> webView.loadUrl(it.url) + } + } + } + } + } + + private fun cancelActivity() { + setResult(RESULT_CANCELED) + finish() + } + + override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) { + cancelActivity() + + true + } else super.onOptionsItemSelected(item) + + @Parcelize + internal class Parameters( + val title: String, val events: IBinder + ) : Parcelable + + internal companion object { + const val KEY = "params" + } +} + +@OptIn(PluginHostApi::class) +internal class WebViewModel : ViewModel() { + init { + CookieManager.getInstance().apply { + removeAllCookies(null) + setAcceptCookie(true) + } + } + + private val commandChannel = Channel() + val commands = commandChannel.receiveAsFlow() + + private var eventBinder: IWebViewEvents? = null + private val ctrlBinder = object : IWebView.Stub() { + override fun load(url: String?) { + viewModelScope.launch { + commandChannel.send(Command.Load(url!!)) + } + } + + override fun finish() { + viewModelScope.launch { + commandChannel.send(Command.Finish) + } + } + } + + val webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + eventBinder!!.pageLoad(url) + } + } + + fun onDownload(url: String, mimeType: String, userAgent: String) { + eventBinder!!.download(url, mimeType, userAgent) + } + + fun setup(binder: IWebViewEvents) { + if (eventBinder != null) return + eventBinder = binder + binder.ready(ctrlBinder) + } + + sealed interface Command { + data class Load(val url: String) : Command + data object Finish : Command + } +} \ No newline at end of file diff --git a/downloader-plugin/src/main/res/layout/activity_webview.xml b/downloader-plugin/src/main/res/layout/activity_webview.xml new file mode 100644 index 00000000..51f761d9 --- /dev/null +++ b/downloader-plugin/src/main/res/layout/activity_webview.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/downloader-plugin/src/main/res/values/strings.xml b/downloader-plugin/src/main/res/values/strings.xml new file mode 100644 index 00000000..73862c41 --- /dev/null +++ b/downloader-plugin/src/main/res/values/strings.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/downloader-plugin/src/main/res/values/themes.xml b/downloader-plugin/src/main/res/values/themes.xml new file mode 100644 index 00000000..495cde8e --- /dev/null +++ b/downloader-plugin/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/example-downloader-plugin/.gitignore b/example-downloader-plugin/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/example-downloader-plugin/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/example-downloader-plugin/build.gradle.kts b/example-downloader-plugin/build.gradle.kts new file mode 100644 index 00000000..b480add9 --- /dev/null +++ b/example-downloader-plugin/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.compose.compiler) +} + +android { + val packageName = "app.revanced.manager.plugin.downloader.example" + + namespace = packageName + compileSdk = 35 + + defaultConfig { + applicationId = packageName + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + + if (project.hasProperty("signAsDebug")) { + signingConfig = signingConfigs.getByName("debug") + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures.compose = true +} + +dependencies { + implementation(libs.activity.compose) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling) + implementation(libs.compose.material3) + + compileOnly(project(":downloader-plugin")) +} \ No newline at end of file diff --git a/example-downloader-plugin/proguard-rules.pro b/example-downloader-plugin/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/example-downloader-plugin/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/example-downloader-plugin/src/main/AndroidManifest.xml b/example-downloader-plugin/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e10b2d28 --- /dev/null +++ b/example-downloader-plugin/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt new file mode 100644 index 00000000..dd2b26c5 --- /dev/null +++ b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/ExamplePlugin.kt @@ -0,0 +1,69 @@ +@file:Suppress("Unused") + +package app.revanced.manager.plugin.downloader.example + +import android.app.Application +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Parcelable +import app.revanced.manager.plugin.downloader.Downloader +import app.revanced.manager.plugin.downloader.requestStartActivity +import app.revanced.manager.plugin.downloader.webview.WebViewDownloader +import kotlinx.parcelize.Parcelize +import kotlin.io.path.* + +val apkMirrorDownloader = WebViewDownloader { packageName, version -> + with(Uri.Builder()) { + scheme("https") + authority("www.apkmirror.com") + mapOf( + "post_type" to "app_release", + "searchtype" to "apk", + "s" to (version?.let { "$packageName $it" } ?: packageName), + "bundles%5B%5D" to "apk_files" // bundles[] + ).forEach { (key, value) -> + appendQueryParameter(key, value) + } + + build().toString() + } +} + +@Parcelize +class InstalledApp(val path: String) : Parcelable + +private val application by lazy { + // Don't do this in a real plugin. + val clazz = Class.forName("android.app.ActivityThread") + val activityThread = clazz.getMethod("currentActivityThread")(null) + clazz.getMethod("getApplication")(activityThread) as Application +} + +val installedAppDownloader = Downloader { + val pm = application.packageManager + + get { packageName, version -> + val packageInfo = try { + pm.getPackageInfo(packageName, 0) + } catch (_: PackageManager.NameNotFoundException) { + return@get null + } + if (version != null && packageInfo.versionName != version) return@get null + + requestStartActivity() + + InstalledApp(packageInfo.applicationInfo!!.sourceDir) to packageInfo.versionName + } + + + download { app -> + with(Path(app.path)) { inputStream() to fileSize() } + } + + /* + download { app, outputStream -> + val path = Path(app.path) + reportSize(path.fileSize()) + Files.copy(path, outputStream) + }*/ +} diff --git a/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/InteractionActivity.kt b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/InteractionActivity.kt new file mode 100644 index 00000000..0390f3bd --- /dev/null +++ b/example-downloader-plugin/src/main/java/app/revanced/manager/plugin/downloader/example/InteractionActivity.kt @@ -0,0 +1,65 @@ +package app.revanced.manager.plugin.downloader.example + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.Modifier + +class InteractionActivity : ComponentActivity() { + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val isDarkTheme = isSystemInDarkTheme() + + val colorScheme = if (isDarkTheme) darkColorScheme() else lightColorScheme() + + MaterialTheme(colorScheme) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("User interaction example") } + ) + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + Text("This is an example interaction.") + Row { + TextButton( + onClick = { + setResult(RESULT_CANCELED) + finish() + } + ) { + Text("Cancel") + } + + TextButton( + onClick = { + setResult(RESULT_OK) + finish() + } + ) { + Text("Continue") + } + } + } + } + } + + } + } +} \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml b/example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/example-downloader-plugin/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml b/example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/example-downloader-plugin/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 00000000..6f3b755b --- /dev/null +++ b/example-downloader-plugin/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/example-downloader-plugin/src/main/res/values/strings.xml b/example-downloader-plugin/src/main/res/values/strings.xml new file mode 100644 index 00000000..4006549c --- /dev/null +++ b/example-downloader-plugin/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Example Downloader Plugin + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47d9b401..325e1127 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,14 @@ [versions] ktx = "1.15.0" material3 = "1.3.1" -ui-tooling = "1.7.5" +ui-tooling = "1.7.6" viewmodel-lifecycle = "2.8.7" splash-screen = "1.0.1" -compose-activity = "1.9.3" +activity = "1.9.3" +appcompat = "1.7.0" preferences-datastore = "1.1.1" work-runtime = "2.10.0" -compose-bom = "2024.10.01" +compose-bom = "2024.12.01" accompanist = "0.34.0" placeholder = "1.1.2" reorderable = "1.5.2" @@ -23,10 +24,11 @@ reimagined-navigation = "1.5.0" ktor = "2.3.9" markdown-renderer = "0.22.0" fading-edges = "1.0.4" -kotlin = "2.0.21" -android-gradle-plugin = "8.7.2" -dev-tools-gradle-plugin = "2.0.21-1.0.27" +kotlin = "2.1.0" +android-gradle-plugin = "8.7.3" +dev-tools-gradle-plugin = "2.1.0-1.0.29" about-libraries-gradle-plugin = "11.1.1" +binary-compatibility-validator = "0.17.0" coil = "2.6.0" app-icon-loader-coil = "1.5.0" skrapeit = "1.2.2" @@ -43,9 +45,11 @@ androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "viewmodel-lifecycle" } runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "viewmodel-lifecycle" } splash-screen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splash-screen" } -compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "compose-activity" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" } +activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work-runtime" } preferences-datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "preferences-datastore" } +appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } # Compose compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } @@ -135,9 +139,11 @@ compose-icons-fontawesome = { group = "com.github.BenjaminHalko.compose-icons", [plugins] android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" } +android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } devtools = { id = "com.google.devtools.ksp", version.ref = "dev-tools-gradle-plugin" } about-libraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "about-libraries-gradle-plugin" } +binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index f66506b8..18b2f456 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,3 +26,5 @@ dependencyResolutionManagement { } rootProject.name = "ReVanced Manager" include(":app") +include(":downloader-plugin") +include(":example-downloader-plugin")