From 125a15eabc8f9155af237e746d625a17a924a670 Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 09:41:19 +0100 Subject: [PATCH 01/13] getPackageInfo are nullable and should be treated appropriately --- .../app/revanced/manager/patcher/worker/PatcherWorker.kt | 6 +++++- gradle/libs.versions.toml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) 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 99f73f31ee..e444e74e34 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 @@ -218,7 +218,11 @@ class PatcherWorker( } is SelectedApp.Local -> selectedApp.file.also { args.setInputFile(it) } - is SelectedApp.Installed -> File(pm.getPackageInfo(selectedApp.packageName)!!.applicationInfo!!.sourceDir) + is SelectedApp.Installed -> { + val pkgInfo = pm.getPackageInfo(selectedApp.packageName) ?: throw IllegalStateException("Package ${selectedApp.packageName} is not installed.") + val appInfo = pkgInfo.applicationInfo ?: throw IllegalStateException("Failed to retrieve application info for ${selectedApp.packageName}.") + File(appInfo.sourceDir) + } } val runtime = if (prefs.useProcessRuntime.get()) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2737b623be..c6e2e3dddc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,7 @@ ktor = "3.4.1" markdown-renderer = "0.39.2" fading-edges = "1.0.4" kotlin = "2.3.10" -android-gradle-plugin = "8.13.2" +android-gradle-plugin = "9.1.0" dev-tools-gradle-plugin = "2.3.5" about-libraries = "13.2.1" coil = "2.7.0" From 645f21835ba27bd0d63c45cc22bc8a6d273a0c7b Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 10:06:58 +0100 Subject: [PATCH 02/13] update acquire timeout --- .../app/revanced/manager/patcher/worker/PatcherWorker.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 e444e74e34..0dfe1a39a4 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.annotation.SuppressLint import android.app.Activity import android.app.Notification import android.app.NotificationChannel @@ -122,12 +123,14 @@ class PatcherWorker( } catch (e: Exception) { Log.d(tag, "Failed to set foreground info:", e) } - + @SuppressLint("WakelockTimeout") val wakeLock: PowerManager.WakeLock = (applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::Patcher") .apply { - acquire(10 * 60 * 1000L) + // acquire without a timeout as we're managing the wakelock ourselves + // in the finally-block, and therefore, it will be released regardless (of failure/success eventually). + acquire() Log.d(tag, "Acquired wakelock.") } From 48e7bd71f48ad7acc2eb69fc2325c3c594076aeb Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 10:14:06 +0100 Subject: [PATCH 03/13] correct cert expiration timing being 192y instead of 8y --- .../java/app/revanced/manager/domain/manager/KeystoreManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt index b8b92fbbce..51aff92d65 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt @@ -21,7 +21,7 @@ class KeystoreManager(app: Application, private val prefs: PreferencesManager) { * Default alias and password for the keystore. */ const val DEFAULT = "ReVanced" - private val eightYearsFromNow get() = Date(System.currentTimeMillis() + (365.days * 8).inWholeMilliseconds * 24) + private val eightYearsFromNow get() = Date(System.currentTimeMillis() + (365.days * 8).inWholeMilliseconds) } private val keystorePath = From 0cc6a30611fa799c4fa006bd8dc3a467bf78d55b Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 10:23:51 +0100 Subject: [PATCH 04/13] fix: incorrect use of offset for ui updates --- .../manager/domain/repository/DownloadedAppRepository.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 c40d160535..0cbf7ca839 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 @@ -98,9 +98,7 @@ class DownloadedAppRepository( override fun write(b: ByteArray?, off: Int, len: Int) = out.write(b, off, len).also { - emitProgress( - (len - off).toLong() - ) + emitProgress(len.toLong()) } } downloader.impl.download(scope, data, stream) From 9c0904f9d93f1ae80817a4747ca15a3e11279983 Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 10:25:49 +0100 Subject: [PATCH 05/13] fix: incorrect string interpolation leads to no versionNaming --- .../manager/domain/repository/DownloadedAppRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0cbf7ca839..27a17d1318 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 @@ -116,7 +116,7 @@ class DownloadedAppRepository( if ( pkgInfo.versionName != expectedVersion && (appCompatibilityCheck || patchesCompatibilityCheck) - ) error("The selected app version ($pkgInfo.versionName) doesn't match the suggested version. Please use the suggested version ($expectedVersion), or adjust your settings by disabling \"Require suggested app version\" and enabling \"Disable version compatibility check\".") + ) error("The selected app version (${pkgInfo.versionName}) doesn't match the suggested version. Please use the suggested version ($expectedVersion), or adjust your settings by disabling \"Require suggested app version\" and enabling \"Disable version compatibility check\".") } // Delete the previous copy (if present). From 4b3fa18956b16cde491f2e5371e4e11edc20d2a3 Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 11:00:18 +0100 Subject: [PATCH 06/13] Properly handle strings and get userid programmatically --- .../manager/domain/installer/RootInstaller.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt b/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt index b7fbf19c7a..383e07737f 100644 --- a/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt +++ b/app/src/main/java/app/revanced/manager/domain/installer/RootInstaller.kt @@ -5,6 +5,7 @@ import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection import android.os.IBinder +import android.os.Process import app.revanced.manager.IRootSystemService import app.revanced.manager.service.ManagerRootService import app.revanced.manager.util.PM @@ -71,7 +72,8 @@ class RootInstaller( suspend fun isAppMounted(packageName: String) = withContext(Dispatchers.IO) { pm.getPackageInfo(packageName)?.applicationInfo?.sourceDir?.let { - execute("mount | grep \"$it\"").isSuccess + // Use -F to ensure the path isn't treated like a regex + execute("mount | grep -F \"$it\"").isSuccess } ?: false } @@ -112,10 +114,11 @@ class RootInstaller( unmount(packageName) stockAPK?.let { stockApp -> - // TODO: get user id programmatically - execute("pm uninstall -k --user 0 $packageName") + // Android assigns 100000 UIDs per user (https://android.googlesource.com/platform/frameworks/base/+/HEAD/core/java/android/os/UserHandle.java#47) + val userId = Process.myUid() / 100000 + execute("pm uninstall -k --user ${userId} \"$packageName\"") - execute("pm install -r -d --user 0 \"${stockApp.absolutePath}\"") + execute("pm install -r -d --user ${userId} \"${stockApp.absolutePath}\"") .assertSuccess("Failed to install stock app") stockApp.delete() From 27c5a2e53d968f83074819764bfa2023b0ac2c0e Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 11:09:40 +0100 Subject: [PATCH 07/13] fix: concurrency issues may occur with multiple workers --- .../app/revanced/manager/domain/worker/WorkerRepository.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt b/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt index 222a31c48b..b90e4729f0 100644 --- a/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/worker/WorkerRepository.kt @@ -6,6 +6,7 @@ import androidx.work.OneTimeWorkRequest import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import java.util.UUID +import java.util.concurrent.ConcurrentHashMap class WorkerRepository(app: Application) { val workManager = WorkManager.getInstance(app) @@ -14,7 +15,7 @@ class WorkerRepository(app: Application) { * The standard WorkManager communication APIs use [androidx.work.Data], which has too many limitations. * We can get around those limits by passing inputs using global variables instead. */ - val workerInputs = mutableMapOf() + val workerInputs = ConcurrentHashMap() @Suppress("UNCHECKED_CAST") fun > claimInput(worker: W): A { @@ -30,7 +31,7 @@ class WorkerRepository(app: Application) { .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() workerInputs[request.id] = input - workManager.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request) + workManager.enqueueUniqueWork(name, ExistingWorkPolicy.KEEP, request) return request.id } } \ No newline at end of file From 3658ff34762913972cbdd081ce7fc149169c2307 Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 11:16:05 +0100 Subject: [PATCH 08/13] use a collision free random number gen instead --- .../main/java/app/revanced/manager/data/room/AppDatabase.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 c571f48ec4..53dc74e63e 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 @@ -22,7 +22,9 @@ import app.revanced.manager.data.room.downloader.DownloaderEntity 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 java.util.UUID import kotlin.random.Random +import kotlin.uuid.Uuid @Database( entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, InstalledPatchBundle::class, OptionGroup::class, Option::class, DownloaderEntity::class], @@ -49,6 +51,6 @@ abstract class AppDatabase : RoomDatabase() { class DeleteTrustedDownloaders : AutoMigrationSpec companion object { - fun generateUid() = Random.Default.nextInt() + fun generateUid() = UUID.randomUUID().mostSignificantBits.toInt() } } \ No newline at end of file From ae787f842fd601eee3806fb924849205741faf4f Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 11:16:25 +0100 Subject: [PATCH 09/13] remove unused improts --- app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt | 2 -- 1 file changed, 2 deletions(-) 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 53dc74e63e..7d8c2eae4a 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 @@ -23,8 +23,6 @@ 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 java.util.UUID -import kotlin.random.Random -import kotlin.uuid.Uuid @Database( entities = [PatchBundleEntity::class, PatchSelection::class, SelectedPatch::class, DownloadedApp::class, InstalledApp::class, AppliedPatch::class, InstalledPatchBundle::class, OptionGroup::class, Option::class, DownloaderEntity::class], From 633ceb2602d4f5d76a4b4b6db1820cc3dcf6a732 Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sat, 21 Mar 2026 11:28:31 +0100 Subject: [PATCH 10/13] fix: don't throw if long parsing fails Instead of throwing on invalid values, it silently fails and filters the invalid Long out as a null --- .../manager/domain/manager/base/BasePreferencesManager.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 c3de2c1bca..09b34a17a9 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 @@ -139,7 +139,10 @@ class LongSetPreference( ) : Preference>(dataStore, default) { private val key = stringSetPreferencesKey(key) - override fun Preferences.read() = this[key]?.mapTo(mutableSetOf()) { it.toLong() } ?: default + override fun Preferences.read() = this[key]?.mapNotNullTo(mutableSetOf()) { + it.toLongOrNull() + } ?: default + override fun MutablePreferences.write(value: Set) { this[key] = value.mapTo(mutableSetOf()) { it.toString() } } From c1f853db572d77f28ab631636daf92a691067b4f Mon Sep 17 00:00:00 2001 From: Anajrim Date: Sun, 22 Mar 2026 13:28:57 +0100 Subject: [PATCH 11/13] fix(app): harden patching flow, DI params, and state consistency - fix Koin crash by always passing SelectedApplicationInfo.ViewModelParams when resolving SelectedAppInfoViewModel in nested destinations - serialize patcher ProgressEvent handling through a single channel consumer to prevent out-of-order/concurrent step state updates - improve PatcherWorker resilience: - rethrow UserInteractionException instead of silently swallowing it - timeout downloader loading (30s) to avoid indefinite waits - fail fast if patched APK is missing/empty before signing - delete failed output artifacts after unsuccessful runs - stop silently swallowing setForeground failures on Android 14+ - remove GlobalScope usage in uiSafe and patcher cleanup paths - make patch selection/options writes more atomic and race-safe: - DAO-level get-or-create helpers with conflict-safe inserts - transactional selection import replacement - replace several versionName!! call sites with safe fallbacks to prevent NPEs on malformed APK metadata - replace magic download cache TTL literal with a named constant This reduces startup crashes, improves patching reliability, and hardens data integrity under concurrent updates. # Conflicts: # app/src/main/java/app/revanced/manager/ui/viewmodel/AppSelectorViewModel.kt --- .../java/app/revanced/manager/MainActivity.kt | 14 ++++++--- .../manager/data/room/options/OptionDao.kt | 20 +++++++++++-- .../data/room/selection/SelectionDao.kt | 30 +++++++++++++++++-- .../repository/DownloadedAppRepository.kt | 16 ++++++---- .../repository/PatchOptionsRepository.kt | 10 +------ .../repository/PatchSelectionRepository.kt | 19 ++++-------- .../manager/patcher/worker/PatcherWorker.kt | 25 ++++++++++++++-- .../manager/ui/viewmodel/PatcherViewModel.kt | 26 +++++++++++----- .../ui/viewmodel/SelectedAppInfoViewModel.kt | 4 +-- .../java/app/revanced/manager/util/Util.kt | 13 ++++---- 10 files changed, 119 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/MainActivity.kt b/app/src/main/java/app/revanced/manager/MainActivity.kt index 5f5a7dc105..d126c4b0dd 100644 --- a/app/src/main/java/app/revanced/manager/MainActivity.kt +++ b/app/src/main/java/app/revanced/manager/MainActivity.kt @@ -296,9 +296,12 @@ private fun ReVancedManager(vm: MainViewModel) { composable { val data = it.getComplexArg() + val parentBackStackEntry = navController.navGraphEntry(it) + val parentData = + parentBackStackEntry.getComplexArg() val selectedAppInfoVm = koinViewModel( - viewModelStoreOwner = navController.navGraphEntry(it) - ) + viewModelStoreOwner = parentBackStackEntry + ) { parametersOf(parentData) } PatchesSelectorScreen( onBackClick = navController::popBackStackSafe, @@ -316,9 +319,12 @@ private fun ReVancedManager(vm: MainViewModel) { composable { val data = it.getComplexArg() + val parentBackStackEntry = navController.navGraphEntry(it) + val parentData = + parentBackStackEntry.getComplexArg() val selectedAppInfoVm = koinViewModel( - viewModelStoreOwner = navController.navGraphEntry(it) - ) + viewModelStoreOwner = parentBackStackEntry + ) { parametersOf(parentData) } RequiredOptionsScreen( onBackClick = navController::popBackStackSafe, diff --git a/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt b/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt index 66c69b43c1..dc8c62cf27 100644 --- a/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/options/OptionDao.kt @@ -3,8 +3,10 @@ package app.revanced.manager.data.room.options import androidx.room.Dao import androidx.room.Insert import androidx.room.MapColumn +import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import app.revanced.manager.data.room.AppDatabase import kotlinx.coroutines.flow.Flow @Dao @@ -23,8 +25,8 @@ abstract class OptionDao { @Query("SELECT package_name FROM option_groups") abstract fun getPackagesWithOptions(): Flow> - @Insert - abstract suspend fun createOptionGroup(group: OptionGroup) + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun createOptionGroupIfMissing(group: OptionGroup) @Query("DELETE FROM option_groups WHERE patch_bundle = :uid") abstract suspend fun resetOptionsForPatchBundle(uid: Int) @@ -47,4 +49,18 @@ abstract class OptionDao { clearGroup(groupId) insertOptions(options) } + + @Transaction + open suspend fun getOrCreateGroupId(bundleUid: Int, packageName: String): Int { + getGroupId(bundleUid, packageName)?.let { return it } + createOptionGroupIfMissing( + OptionGroup( + uid = AppDatabase.generateUid(), + patchBundle = bundleUid, + packageName = packageName + ) + ) + return getGroupId(bundleUid, packageName) + ?: throw IllegalStateException("Failed to create options group for $packageName") + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt index 5c32b8fe80..796e65a565 100644 --- a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt @@ -3,8 +3,10 @@ package app.revanced.manager.data.room.selection import androidx.room.Dao import androidx.room.Insert import androidx.room.MapColumn +import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import app.revanced.manager.data.room.AppDatabase import kotlinx.coroutines.flow.Flow @Dao @@ -32,8 +34,8 @@ abstract class SelectionDao { @Query("SELECT uid FROM patch_selections WHERE patch_bundle = :bundleUid AND package_name = :packageName") abstract suspend fun getSelectionId(bundleUid: Int, packageName: String): Int? - @Insert - abstract suspend fun createSelection(selection: PatchSelection) + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun createSelectionIfMissing(selection: PatchSelection) @Query("SELECT package_name FROM patch_selections") abstract fun getPackagesWithSelection(): Flow> @@ -65,4 +67,28 @@ abstract class SelectionDao { clearSelection(selectionUid) selectPatches(patches.map { SelectedPatch(selectionUid, it) }) } + + @Transaction + open suspend fun getOrCreateSelectionId(bundleUid: Int, packageName: String): Int { + getSelectionId(bundleUid, packageName)?.let { return it } + createSelectionIfMissing( + PatchSelection( + uid = AppDatabase.generateUid(), + patchBundle = bundleUid, + packageName = packageName + ) + ) + return getSelectionId(bundleUid, packageName) + ?: throw IllegalStateException("Failed to create selection for $packageName") + } + + @Transaction + open suspend fun replaceForPatchBundle(bundleUid: Int, selections: Map>) { + resetForPatchBundle(bundleUid) + selections.forEach { (packageName, patches) -> + val selectionUid = getOrCreateSelectionId(bundleUid, packageName) + clearSelection(selectionUid) + selectPatches(patches.map { SelectedPatch(selectionUid, it) }) + } + } } \ No newline at end of file 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 27a17d1318..3a7dc1dc8b 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 @@ -28,6 +28,10 @@ class DownloadedAppRepository( db: AppDatabase, private val pm: PM ) { + companion object { + private const val CACHE_TTL_MS = 6 * 60 * 60 * 1_000L + } + private val dir = app.getDir("downloaded-apps", Context.MODE_PRIVATE) private val dao = db.downloadedAppDao() @@ -39,11 +43,10 @@ class DownloadedAppRepository( private fun getApkFileForDir(directory: File) = directory.listFiles()!!.first() suspend fun cleanUp() { - val threshold = 1000 * 60 * 60 * 6 val now = System.currentTimeMillis() val targets = getAll().first().filter { - (now - it.lastUsed) > threshold + (now - it.lastUsed) > CACHE_TTL_MS } delete(targets) } @@ -112,15 +115,16 @@ class DownloadedAppRepository( 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}") + val versionName = pkgInfo.versionName ?: error("Downloaded APK has no version name") expectedVersion?.let { if ( - pkgInfo.versionName != expectedVersion && + versionName != expectedVersion && (appCompatibilityCheck || patchesCompatibilityCheck) - ) error("The selected app version (${pkgInfo.versionName}) doesn't match the suggested version. Please use the suggested version ($expectedVersion), or adjust your settings by disabling \"Require suggested app version\" and enabling \"Disable version compatibility check\".") + ) error("The selected app version ($versionName) doesn't match the suggested version. Please use the suggested version ($expectedVersion), or adjust your settings by disabling \"Require suggested app version\" and enabling \"Disable version compatibility check\".") } // Delete the previous copy (if present). - dao.get(pkgInfo.packageName, pkgInfo.versionName!!)?.directory?.let { + dao.get(pkgInfo.packageName, versionName)?.directory?.let { if (!dir.resolve(it) .deleteRecursively() ) throw Exception("Failed to delete existing directory") @@ -128,7 +132,7 @@ class DownloadedAppRepository( dao.upsert( DownloadedApp( packageName = pkgInfo.packageName, - version = pkgInfo.versionName!!, + version = versionName, directory = relativePath, ) ) diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt index 0e55b2d8d5..cad6d776b2 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchOptionsRepository.kt @@ -3,7 +3,6 @@ package app.revanced.manager.domain.repository import android.util.Log import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.options.Option -import app.revanced.manager.data.room.options.OptionGroup import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.util.Options import app.revanced.manager.util.tag @@ -13,13 +12,6 @@ import kotlinx.coroutines.flow.map class PatchOptionsRepository(db: AppDatabase) { private val dao = db.optionDao() - private suspend fun getOrCreateGroup(bundleUid: Int, packageName: String) = - dao.getGroupId(bundleUid, packageName) ?: OptionGroup( - uid = AppDatabase.generateUid(), - patchBundle = bundleUid, - packageName = packageName - ).also { dao.createOptionGroup(it) }.uid - suspend fun getOptions( packageName: String, bundlePatches: Map> @@ -57,7 +49,7 @@ class PatchOptionsRepository(db: AppDatabase) { suspend fun saveOptions(packageName: String, options: Options) = dao.updateOptions(options.entries.associate { (sourceUid, bundlePatchOptions) -> - val groupId = getOrCreateGroup(sourceUid, packageName) + val groupId = dao.getOrCreateGroupId(sourceUid, packageName) groupId to bundlePatchOptions.flatMap { (patchName, patchOptions) -> patchOptions.mapNotNull { (key, value) -> diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt index e9c448d0e8..b67e69a352 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt @@ -1,27 +1,18 @@ package app.revanced.manager.domain.repository import app.revanced.manager.data.room.AppDatabase -import app.revanced.manager.data.room.AppDatabase.Companion.generateUid -import app.revanced.manager.data.room.selection.PatchSelection import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map class PatchSelectionRepository(db: AppDatabase) { private val dao = db.selectionDao() - private suspend fun getOrCreateSelection(bundleUid: Int, packageName: String) = - dao.getSelectionId(bundleUid, packageName) ?: PatchSelection( - uid = generateUid(), - patchBundle = bundleUid, - packageName = packageName - ).also { dao.createSelection(it) }.uid - suspend fun getSelection(packageName: String): Map> = dao.getSelectedPatches(packageName).mapValues { it.value.toSet() } suspend fun updateSelection(packageName: String, selection: Map>) = dao.updateSelections(selection.mapKeys { (sourceUid, _) -> - getOrCreateSelection( + dao.getOrCreateSelectionId( sourceUid, packageName ) @@ -47,10 +38,10 @@ class PatchSelectionRepository(db: AppDatabase) { suspend fun export(bundleUid: Int): SerializedSelection = dao.exportSelection(bundleUid) suspend fun import(bundleUid: Int, selection: SerializedSelection) { - dao.resetForPatchBundle(bundleUid) - dao.updateSelections(selection.entries.associate { (packageName, patches) -> - getOrCreateSelection(bundleUid, packageName) to patches.toSet() - }) + dao.replaceForPatchBundle( + bundleUid, + selection.mapValues { (_, patches) -> patches.toSet() } + ) } } 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 0dfe1a39a4..301a89f180 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 @@ -49,10 +49,12 @@ import app.revanced.manager.util.tag import com.topjohnwu.superuser.Shell import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File +import java.io.IOException @OptIn(DownloaderHostApi::class) class PatcherWorker( @@ -121,7 +123,10 @@ class PatcherWorker( // This does not always show up for some reason. setForeground(getForegroundInfo()) } catch (e: Exception) { - Log.d(tag, "Failed to set foreground info:", e) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + throw e + } + Log.w(tag, "Failed to set foreground info", e) } @SuppressLint("WakelockTimeout") val wakeLock: PowerManager.WakeLock = @@ -145,6 +150,7 @@ class PatcherWorker( private suspend fun runPatcher(args: Args): Result { val patchedApk = fs.tempDir.resolve("patched.apk") + var success = false return try { if (args.input is SelectedApp.Installed) { @@ -187,7 +193,9 @@ class PatcherWorker( is SelectedApp.Search -> { runStep(StepId.DownloadAPK, args.onEvent) { - downloaderRepository.loadedDownloadersFlow.first() + withTimeout(30_000L) { + downloaderRepository.loadedDownloadersFlow.first() + } .firstNotNullOfOrNull { downloader -> try { val getScope = object : GetScope, Scope by downloader.scopeImpl { @@ -213,7 +221,11 @@ class PatcherWorker( }?.takeIf { (_, version) -> selectedApp.version == null || version == selectedApp.version } } catch (e: UserInteractionException.Activity.NotCompleted) { throw e - } catch (_: UserInteractionException) { + } catch (e: UserInteractionException) { + Log.i(tag, "User interaction cancelled downloader flow", e) + throw e + } catch (e: IOException) { + Log.w(tag, "Downloader ${downloader.name} failed with an IO error, trying next downloader", e) null }?.let { (data, _) -> download(downloader, data) } } ?: throw Exception("App is not available.") @@ -245,10 +257,14 @@ class PatcherWorker( ) runStep(StepId.SignAPK, args.onEvent) { + require(patchedApk.exists() && patchedApk.length() > 0L) { + "Patched APK was not generated" + } keystoreManager.sign(patchedApk, File(args.output)) } Log.i(tag, "Patching succeeded".logFmt()) + success = true Result.success() } catch (e: ProcessRuntime.RemoteFailureException) { Log.e( @@ -273,6 +289,9 @@ class PatcherWorker( Result.failure() } finally { patchedApk.delete() + if (!success) { + File(args.output).delete() + } // Only delete the input APK right after patching finishes when the user isn't rooted, since it would be needed for mounting // (it would be deleted right after installing with root) if (args.input is SelectedApp.Local && args.input.temporary && Shell.isAppGrantedRoot() == false) { 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 32d2098270..415ede830b 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 @@ -65,13 +65,12 @@ import app.revanced.manager.util.toast import app.revanced.manager.util.uiSafe import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -141,6 +140,7 @@ class PatcherViewModel( private var launchedActivity: CompletableDeferred? = null private val launchActivityChannel = Channel() val launchActivityFlow = launchActivityChannel.receiveAsFlow() + private val progressEventChannel = Channel(UNLIMITED) private val tempDir = savedStateHandle.saveable(key = "tempDir") { fs.uiTempDir.resolve("installer").also { @@ -154,6 +154,7 @@ class PatcherViewModel( * It should not be cancelled on system-initiated process death since that would cancel the installation process. */ private val installerCoroutineScope = CoroutineScope(Dispatchers.Main) + private val cleanupScope = CoroutineScope(Dispatchers.Main) /** * Holds the package name of the Apk we are trying to install. @@ -271,17 +272,22 @@ class PatcherViewModel( viewModelScope.launch { installedApp = installedAppRepository.get(packageName) } + + viewModelScope.launch { + for (event in progressEventChannel) { + applyProgressEvent(event) + } + } } - @OptIn(DelicateCoroutinesApi::class) override fun onCleared() { super.onCleared() workManager.cancelWorkById(patcherWorkerId.uuid) if (input.selectedApp is SelectedApp.Installed && installedApp?.installType == InstallType.MOUNT) { - GlobalScope.launch(Dispatchers.Main) { + cleanupScope.launch { uiSafe(app, R.string.failed_to_mount, "Failed to mount") { - withTimeout(Duration.ofMinutes(1L)) { + withTimeout(Duration.ofSeconds(10L)) { rootInstaller.mount(packageName) } } @@ -289,9 +295,13 @@ class PatcherViewModel( } } - private fun handleProgressEvent(event: ProgressEvent) = viewModelScope.launch { + private fun handleProgressEvent(event: ProgressEvent) { + progressEventChannel.trySend(event) + } + + private fun applyProgressEvent(event: ProgressEvent) { if (event is ProgressEvent.Failed && event.stepId == null && steps.any { it.state == State.FAILED }) { - return@launch + return } val stepIndex = steps.indexOfFirst { @@ -554,7 +564,7 @@ class PatcherViewModel( installerPkgName, packageName, input.selectedApp.version ?: withContext(Dispatchers.IO) { - pm.getPackageInfo(outputFile)?.versionName!! + pm.getPackageInfo(outputFile)?.versionName ?: "unknown" }, InstallType.DEFAULT, input.selectedPatches, 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 981143c301..2181211125 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 @@ -126,7 +126,7 @@ class SelectedAppInfoViewModel( if (it.isSplitApk()) return@let null SelectedApp.Installed( packageName, - it.versionName!! + it.versionName ?: "unknown" ) to installedAppDeferred.await() } @@ -288,7 +288,7 @@ class SelectedAppInfoViewModel( pm.getPackageInfo(this)?.let { info -> SelectedApp.Local( packageName = info.packageName, - version = info.versionName!!, + version = info.versionName ?: "unknown", file = this, temporary = true ) 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 49239d7ac9..3909a8014b 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -6,7 +6,8 @@ import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo import android.graphics.Bitmap -import android.os.Build +import android.os.Handler +import android.os.Looper import android.renderscript.Allocation import android.renderscript.Element import android.renderscript.RenderScript @@ -23,7 +24,6 @@ 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.getValue import androidx.compose.runtime.produceState import androidx.compose.runtime.rememberUpdatedState @@ -48,10 +48,7 @@ import app.revanced.manager.BuildConfig import app.revanced.manager.R import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest @@ -97,15 +94,13 @@ fun Context.toast(@StringRes stringRes: Int, duration: Int = Toast.LENGTH_SHORT) * @param logMsg The log message. * @param block The code to execute. */ -@OptIn(DelicateCoroutinesApi::class) inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, block: () -> Unit) { try { block() } catch (e: CancellationException) { throw e } catch (error: Exception) { - // You can only toast on the main thread. - GlobalScope.launch(Dispatchers.Main) { + val showToast = { context.toast( context.getString( toastMsg, @@ -113,6 +108,8 @@ inline fun uiSafe(context: Context, @StringRes toastMsg: Int, logMsg: String, bl ) ) } + if (Looper.myLooper() == Looper.getMainLooper()) showToast() + else Handler(Looper.getMainLooper()).post(showToast) Log.e(tag, logMsg, error) } From 2ade04eca4fb155bfe633e41ceb94ec043c25e6e Mon Sep 17 00:00:00 2001 From: Anajrim Date: Mon, 23 Mar 2026 08:56:49 +0100 Subject: [PATCH 12/13] fix: update changelog source in ViewModel --- .../java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt | 3 +-- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt index 78f97cca1d..e49d90c5b2 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/UpdateViewModel.kt @@ -43,7 +43,6 @@ import ru.solrudev.ackpine.session.parameters.Confirmation class UpdateViewModel( private val api: ReVancedAPI, - private val source: ChangelogSource, private val downloadOnScreenEntry: Boolean ) : ViewModel(), KoinComponent { private val app: Application by inject() @@ -77,7 +76,7 @@ class UpdateViewModel( pageSize = 10, enablePlaceholders = false ), - pagingSourceFactory = { ChangelogsRepository(api, source) } + pagingSourceFactory = { ChangelogsRepository(api, ChangelogSource.Manager) } ).flow.cachedIn(viewModelScope) private val location = fs.tempDir.resolve("updater.apk") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c6e2e3dddc..2737b623be 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,7 @@ ktor = "3.4.1" markdown-renderer = "0.39.2" fading-edges = "1.0.4" kotlin = "2.3.10" -android-gradle-plugin = "9.1.0" +android-gradle-plugin = "8.13.2" dev-tools-gradle-plugin = "2.3.5" about-libraries = "13.2.1" coil = "2.7.0" From 4d6be54a26bc74a96ecd222251f7eaac4870b09a Mon Sep 17 00:00:00 2001 From: Anajrim Date: Mon, 23 Mar 2026 11:54:54 +0100 Subject: [PATCH 13/13] fix: improve channel buffer handling --- .../app/revanced/manager/ui/viewmodel/PatcherViewModel.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 415ede830b..409ee7c192 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 @@ -70,10 +70,11 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.time.withTimeout import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent @@ -140,7 +141,10 @@ class PatcherViewModel( private var launchedActivity: CompletableDeferred? = null private val launchActivityChannel = Channel() val launchActivityFlow = launchActivityChannel.receiveAsFlow() - private val progressEventChannel = Channel(UNLIMITED) + private val progressEventChannel = Channel( + capacity = 10000, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) private val tempDir = savedStateHandle.saveable(key = "tempDir") { fs.uiTempDir.resolve("installer").also {