Compare commits

..

22 Commits

Author SHA1 Message Date
3fc2bc611c make it more consistent 2025-07-15 17:35:43 +02:00
df04c67a36 Merge branch 'compose-dev' into feat/naming 2025-07-15 17:27:29 +02:00
486ed5967f fix: Show selection warning also on patch option (#2643) 2025-07-15 15:32:49 +02:00
789f9ec867 feat: allow bundles to use classes from other bundles (#1951) 2025-07-15 14:28:40 +02:00
b51d1ee47a fix: Transparent status on fullscreen dialog (#2654) 2025-07-14 15:35:27 +02:00
7148ee66f8 feat: Rename strings 2025-07-10 22:29:25 +02:00
e1c4166a06 feat: Rename strings 2025-07-09 22:51:34 +02:00
f7a4ae5791 fix: Add missing header for "Updates" settings (#2642) 2025-07-08 21:57:56 +02:00
cb2dbbee24 feat: Improve bundle info screen design (#2548) 2025-07-08 20:23:03 +02:00
578dcce9b6 chore: Merge branch 'dev' into compose-dev 2025-07-08 22:24:17 +07:00
8c6c0f3c76 fix: Patch selection screen padding (#2533) 2025-07-08 17:20:44 +02:00
979a2dc410 fix: Playback Switch's Haptic Feedback (#2639)
Signed-off-by: Pun Butrach <pun.butrach@gmail.com>
2025-07-08 18:11:45 +07:00
baa9122a88 fix: Improve background running notification (#2614) 2025-07-07 22:38:41 +02:00
b70fc03bc7 fix: Allow different app version when downloading via plugin if setting is off (#2579)
Co-authored-by: Ax333l <main@axelen.xyz>
2025-07-05 18:44:11 +02:00
81a4ebd327 fix: display version from manifest (#2634) 2025-07-04 18:58:11 +02:00
83fc7f131a chore: Remove obsolete deleteLastPatchedApp call 2025-06-02 17:12:50 +03:00
8e4a9088ea ci: Use install instead of clean install for NPM dependencies
Co-authored-by: Pun Butrach <pun.butrach@gmail.com>
2025-06-02 16:05:59 +02:00
7ee2b1a026 chore: Sync translations (#2522)
Signed-off-by: Pun Butrach <pun.butrach@gmail.com>
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Pun Butrach <pun.butrach@gmail.com>
2025-05-31 15:36:42 +07:00
40c99ab4dc ci: Set missing translation actor/email for commit
Signed-off-by: Pun Butrach <pun.butrach@gmail.com>
2025-05-31 15:18:37 +07:00
1752fae9d9 chore: Better ignore rules
Based on https://github.com/github/gitignore/blob/main/Flutter.gitignore

Signed-off-by: Pun Butrach <pun.butrach@gmail.com>
2025-05-31 15:12:26 +07:00
d264a2a363 build: Support Flutter v3.32
Signed-off-by: Pun Butrach <pun.butrach@gmail.com>
2025-05-31 15:11:49 +07:00
a5e909cfc8 fix: Obscure Flutter Impeller renderer bugs
This is workaround to entirely disabling Flutter Impeller in favour of Skia.

Signed-off-by: Pun Butrach <pun.butrach@gmail.com>
2025-05-31 14:57:21 +07:00
47 changed files with 1516 additions and 1155 deletions

View File

@ -0,0 +1,74 @@
package app.revanced.manager.data.redux
import android.util.Log
import app.revanced.manager.util.tag
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
// This file implements React Redux-like state management.
class Store<S>(private val coroutineScope: CoroutineScope, initialState: S) : ActionContext {
private val _state = MutableStateFlow(initialState)
val state = _state.asStateFlow()
// Do not touch these without the lock.
private var isRunningActions = false
private val queueChannel = Channel<Action<S>>(capacity = 10)
private val lock = Mutex()
suspend fun dispatch(action: Action<S>) = lock.withLock {
Log.d(tag, "Dispatching $action")
queueChannel.send(action)
if (isRunningActions) return@withLock
isRunningActions = true
coroutineScope.launch {
runActions()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun runActions() {
while (true) {
val action = withTimeoutOrNull(200L) { queueChannel.receive() }
if (action == null) {
Log.d(tag, "Stopping action runner")
lock.withLock {
// New actions may be dispatched during the timeout.
isRunningActions = !queueChannel.isEmpty
if (!isRunningActions) return
}
continue
}
Log.d(tag, "Running $action")
_state.value = try {
with(action) { this@Store.execute(_state.value) }
} catch (c: CancellationException) {
// This is done without the lock, but cancellation usually means the store is no longer needed.
isRunningActions = false
throw c
} catch (e: Exception) {
action.catch(e)
continue
}
}
}
}
interface ActionContext
interface Action<S> {
suspend fun ActionContext.execute(current: S): S
suspend fun catch(exception: Exception) {
Log.e(tag, "Got exception while executing $this", exception)
}
}

View File

@ -1,25 +1,15 @@
package app.revanced.manager.data.room.bundles package app.revanced.manager.data.room.bundles
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface PatchBundleDao { interface PatchBundleDao {
@Query("SELECT * FROM patch_bundles") @Query("SELECT * FROM patch_bundles")
suspend fun all(): List<PatchBundleEntity> suspend fun all(): List<PatchBundleEntity>
@Query("SELECT version, auto_update FROM patch_bundles WHERE uid = :uid")
fun getPropsById(uid: Int): Flow<BundleProperties?>
@Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid") @Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid")
suspend fun updateVersionHash(uid: Int, patches: String?) suspend fun updateVersionHash(uid: Int, patches: String?)
@Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid")
suspend fun setAutoUpdate(uid: Int, value: Boolean)
@Query("UPDATE patch_bundles SET name = :value WHERE uid = :uid")
suspend fun setName(uid: Int, value: String)
@Query("DELETE FROM patch_bundles WHERE uid != 0") @Query("DELETE FROM patch_bundles WHERE uid != 0")
suspend fun purgeCustomBundles() suspend fun purgeCustomBundles()
@ -32,6 +22,9 @@ interface PatchBundleDao {
@Query("DELETE FROM patch_bundles WHERE uid = :uid") @Query("DELETE FROM patch_bundles WHERE uid = :uid")
suspend fun remove(uid: Int) suspend fun remove(uid: Int)
@Insert @Query("SELECT name, version, auto_update, source FROM patch_bundles WHERE uid = :uid")
suspend fun add(source: PatchBundleEntity) suspend fun getProps(uid: Int): PatchBundleProperties?
@Upsert
suspend fun upsert(source: PatchBundleEntity)
} }

View File

@ -38,7 +38,9 @@ data class PatchBundleEntity(
@ColumnInfo(name = "auto_update") val autoUpdate: Boolean @ColumnInfo(name = "auto_update") val autoUpdate: Boolean
) )
data class BundleProperties( data class PatchBundleProperties(
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "version") val versionHash: String? = null, @ColumnInfo(name = "version") val versionHash: String? = null,
@ColumnInfo(name = "source") val source: Source,
@ColumnInfo(name = "auto_update") val autoUpdate: Boolean @ColumnInfo(name = "auto_update") val autoUpdate: Boolean
) )

View File

@ -15,7 +15,6 @@ val repositoryModule = module {
createdAtStart() createdAtStart()
} }
singleOf(::NetworkInfo) singleOf(::NetworkInfo)
singleOf(::PatchBundlePersistenceRepository)
singleOf(::PatchSelectionRepository) singleOf(::PatchSelectionRepository)
singleOf(::PatchOptionsRepository) singleOf(::PatchOptionsRepository)
singleOf(::PatchBundleRepository) { singleOf(::PatchBundleRepository) {

View File

@ -23,4 +23,5 @@ val viewModelModule = module {
viewModelOf(::InstalledAppsViewModel) viewModelOf(::InstalledAppsViewModel)
viewModelOf(::InstalledAppInfoViewModel) viewModelOf(::InstalledAppInfoViewModel)
viewModelOf(::UpdatesSettingsViewModel) viewModelOf(::UpdatesSettingsViewModel)
viewModelOf(::BundleListViewModel)
} }

View File

@ -1,21 +1,29 @@
package app.revanced.manager.domain.bundles package app.revanced.manager.domain.bundles
import app.revanced.manager.data.redux.ActionContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
class LocalPatchBundle(name: String, id: Int, directory: File) : class LocalPatchBundle(
PatchBundleSource(name, id, directory) { name: String,
suspend fun replace(patches: InputStream) { uid: Int,
error: Throwable?,
directory: File
) : PatchBundleSource(name, uid, error, directory) {
suspend fun ActionContext.replace(patches: InputStream) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
patchBundleOutputStream().use { outputStream -> patchBundleOutputStream().use { outputStream ->
patches.copyTo(outputStream) patches.copyTo(outputStream)
} }
} }
}
reload()?.also { override fun copy(error: Throwable?, name: String) = LocalPatchBundle(
saveVersionHash(it.readManifestAttribute("Version")) name,
} uid,
} error,
directory
)
} }

View File

@ -1,22 +1,10 @@
package app.revanced.manager.domain.bundles package app.revanced.manager.domain.bundles
import android.app.Application
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.data.redux.ActionContext
import app.revanced.manager.R
import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository
import app.revanced.manager.patcher.patch.PatchBundle import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File import java.io.File
import java.io.OutputStream import java.io.OutputStream
@ -24,27 +12,32 @@ import java.io.OutputStream
* A [PatchBundle] source. * A [PatchBundle] source.
*/ */
@Stable @Stable
sealed class PatchBundleSource(initialName: String, val uid: Int, directory: File) : KoinComponent { sealed class PatchBundleSource(
protected val configRepository: PatchBundlePersistenceRepository by inject() val name: String,
private val app: Application by inject() val uid: Int,
error: Throwable?,
protected val directory: File
) {
protected val patchesFile = directory.resolve("patches.jar") protected val patchesFile = directory.resolve("patches.jar")
private val _state = MutableStateFlow(load()) val state = when {
val state = _state.asStateFlow() error != null -> State.Failed(error)
!hasInstalled() -> State.Missing
else -> State.Available(PatchBundle(patchesFile.absolutePath))
}
private val _nameFlow = MutableStateFlow(initialName) val patchBundle get() = (state as? State.Available)?.bundle
val nameFlow = val version get() = patchBundle?.manifestAttributes?.version
_nameFlow.map { it.ifEmpty { app.getString(if (isDefault) R.string.bundle_name_default else R.string.bundle_name_fallback) } } val isNameOutOfDate get() = patchBundle?.manifestAttributes?.name?.let { it != name } == true
val error get() = (state as? State.Failed)?.throwable
suspend fun getName() = nameFlow.first() suspend fun ActionContext.deleteLocalFile() = withContext(Dispatchers.IO) {
patchesFile.delete()
}
val versionFlow = state.map { it.patchBundleOrNull()?.readManifestAttribute("Version") } abstract fun copy(error: Throwable? = this.error, name: String = this.name): PatchBundleSource
val patchCountFlow = state.map { it.patchBundleOrNull()?.patches?.size ?: 0 }
/** protected fun hasInstalled() = patchesFile.exists()
* Returns true if the bundle has been downloaded to local storage.
*/
fun hasInstalled() = patchesFile.exists()
protected fun patchBundleOutputStream(): OutputStream = with(patchesFile) { protected fun patchBundleOutputStream(): OutputStream = with(patchesFile) {
// Android 14+ requires dex containers to be readonly. // Android 14+ requires dex containers to be readonly.
@ -56,62 +49,14 @@ sealed class PatchBundleSource(initialName: String, val uid: Int, directory: Fil
} }
} }
private fun load(): State {
if (!hasInstalled()) return State.Missing
return try {
State.Loaded(PatchBundle(patchesFile))
} catch (t: Throwable) {
Log.e(tag, "Failed to load patch bundle with UID $uid", t)
State.Failed(t)
}
}
suspend fun reload(): PatchBundle? {
val newState = load()
_state.value = newState
val bundle = newState.patchBundleOrNull()
// Try to read the name from the patch bundle manifest if the bundle does not have a name.
if (bundle != null && _nameFlow.value.isEmpty()) {
bundle.readManifestAttribute("Name")?.let { setName(it) }
}
return bundle
}
/**
* Create a flow that emits the [app.revanced.manager.data.room.bundles.BundleProperties] of this [PatchBundleSource].
* The flow will emit null if the associated [PatchBundleSource] is deleted.
*/
fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default)
suspend fun getProps() = propsFlow().first()!!
suspend fun currentVersionHash() = getProps().versionHash
protected suspend fun saveVersionHash(version: String?) =
configRepository.updateVersionHash(uid, version)
suspend fun setName(name: String) {
configRepository.setName(uid, name)
_nameFlow.value = name
}
sealed interface State { sealed interface State {
fun patchBundleOrNull(): PatchBundle? = null
data object Missing : State data object Missing : State
data class Failed(val throwable: Throwable) : State data class Failed(val throwable: Throwable) : State
data class Loaded(val bundle: PatchBundle) : State { data class Available(val bundle: PatchBundle) : State
override fun patchBundleOrNull() = bundle
}
} }
companion object Extensions { companion object Extensions {
val PatchBundleSource.isDefault inline get() = uid == 0 val PatchBundleSource.isDefault inline get() = uid == 0
val PatchBundleSource.asRemoteOrNull inline get() = this as? RemotePatchBundle val PatchBundleSource.asRemoteOrNull inline get() = this as? RemotePatchBundle
val PatchBundleSource.nameState
@Composable inline get() = nameFlow.collectAsStateWithLifecycle(
""
)
} }
} }

View File

@ -1,6 +1,6 @@
package app.revanced.manager.domain.bundles package app.revanced.manager.domain.bundles
import androidx.compose.runtime.Stable import app.revanced.manager.data.redux.ActionContext
import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAsset import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.service.HttpService import app.revanced.manager.network.service.HttpService
@ -8,15 +8,24 @@ import app.revanced.manager.network.utils.getOrThrow
import io.ktor.client.request.url import io.ktor.client.request.url
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import java.io.File import java.io.File
@Stable sealed class RemotePatchBundle(
sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) : name: String,
PatchBundleSource(name, id, directory) { uid: Int,
protected val versionHash: String?,
error: Throwable?,
directory: File,
val endpoint: String,
val autoUpdate: Boolean,
) : PatchBundleSource(name, uid, error, directory), KoinComponent {
protected val http: HttpService by inject() protected val http: HttpService by inject()
protected abstract suspend fun getLatestInfo(): ReVancedAsset protected abstract suspend fun getLatestInfo(): ReVancedAsset
abstract fun copy(error: Throwable? = this.error, name: String = this.name, autoUpdate: Boolean = this.autoUpdate): RemotePatchBundle
override fun copy(error: Throwable?, name: String): RemotePatchBundle = copy(error, name, this.autoUpdate)
private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) {
patchBundleOutputStream().use { patchBundleOutputStream().use {
@ -25,47 +34,72 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo
} }
} }
saveVersionHash(info.version) info.version
reload()
} }
suspend fun downloadLatest() { /**
download(getLatestInfo()) * Downloads the latest version regardless if there is a new update available.
} */
suspend fun ActionContext.downloadLatest() = download(getLatestInfo())
suspend fun update(): Boolean = withContext(Dispatchers.IO) { suspend fun ActionContext.update(): String? = withContext(Dispatchers.IO) {
val info = getLatestInfo() val info = getLatestInfo()
if (hasInstalled() && info.version == currentVersionHash()) if (hasInstalled() && info.version == versionHash)
return@withContext false return@withContext null
download(info) download(info)
true
} }
suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) {
patchesFile.delete()
reload()
}
suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value)
companion object { companion object {
const val updateFailMsg = "Failed to update patch bundle(s)" const val updateFailMsg = "Failed to update patches"
} }
} }
class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) : class JsonPatchBundle(
RemotePatchBundle(name, id, directory, endpoint) { name: String,
uid: Int,
versionHash: String?,
error: Throwable?,
directory: File,
endpoint: String,
autoUpdate: Boolean,
) : RemotePatchBundle(name, uid, versionHash, error, directory, endpoint, autoUpdate) {
override suspend fun getLatestInfo() = withContext(Dispatchers.IO) { override suspend fun getLatestInfo() = withContext(Dispatchers.IO) {
http.request<ReVancedAsset> { http.request<ReVancedAsset> {
url(endpoint) url(endpoint)
}.getOrThrow() }.getOrThrow()
} }
override fun copy(error: Throwable?, name: String, autoUpdate: Boolean) = JsonPatchBundle(
name,
uid,
versionHash,
error,
directory,
endpoint,
autoUpdate,
)
} }
class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) : class APIPatchBundle(
RemotePatchBundle(name, id, directory, endpoint) { name: String,
uid: Int,
versionHash: String?,
error: Throwable?,
directory: File,
endpoint: String,
autoUpdate: Boolean,
) : RemotePatchBundle(name, uid, versionHash, error, directory, endpoint, autoUpdate) {
private val api: ReVancedAPI by inject() private val api: ReVancedAPI by inject()
override suspend fun getLatestInfo() = api.getPatchesUpdate().getOrThrow() override suspend fun getLatestInfo() = api.getPatchesUpdate().getOrThrow()
override fun copy(error: Throwable?, name: String, autoUpdate: Boolean) = APIPatchBundle(
name,
uid,
versionHash,
error,
directory,
endpoint,
autoUpdate,
)
} }

View File

@ -40,6 +40,8 @@ class DownloadedAppRepository(
data: Parcelable, data: Parcelable,
expectedPackageName: String, expectedPackageName: String,
expectedVersion: String?, expectedVersion: String?,
appCompatibilityCheck: Boolean,
patchesCompatibilityCheck: Boolean,
onDownload: suspend (downloadProgress: Pair<Long, Long?>) -> Unit, onDownload: suspend (downloadProgress: Pair<Long, Long?>) -> Unit,
): File { ): File {
// Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here. // Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here.
@ -96,7 +98,12 @@ class DownloadedAppRepository(
val pkgInfo = val pkgInfo =
pm.getPackageInfo(targetFile.toFile()) ?: error("Downloaded APK file is invalid") 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 (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}") expectedVersion?.let {
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\".")
}
// Delete the previous copy (if present). // Delete the previous copy (if present).
dao.get(pkgInfo.packageName, pkgInfo.versionName!!)?.directory?.let { dao.get(pkgInfo.packageName, pkgInfo.versionName!!)?.directory?.let {

View File

@ -1,58 +0,0 @@
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.bundles.PatchBundleEntity
import app.revanced.manager.data.room.bundles.Source
import kotlinx.coroutines.flow.distinctUntilChanged
class PatchBundlePersistenceRepository(db: AppDatabase) {
private val dao = db.patchBundleDao()
suspend fun loadConfiguration(): List<PatchBundleEntity> {
val all = dao.all()
if (all.isEmpty()) {
dao.add(defaultSource)
return listOf(defaultSource)
}
return all
}
suspend fun reset() = dao.reset()
suspend fun create(name: String, source: Source, autoUpdate: Boolean = false) =
PatchBundleEntity(
uid = generateUid(),
name = name,
versionHash = null,
source = source,
autoUpdate = autoUpdate
).also {
dao.add(it)
}
suspend fun delete(uid: Int) = dao.remove(uid)
/**
* Sets the version hash used for updates.
*/
suspend fun updateVersionHash(uid: Int, versionHash: String?) =
dao.updateVersionHash(uid, versionHash)
suspend fun setAutoUpdate(uid: Int, value: Boolean) = dao.setAutoUpdate(uid, value)
suspend fun setName(uid: Int, name: String) = dao.setName(uid, name)
fun getProps(id: Int) = dao.getPropsById(id).distinctUntilChanged()
private companion object {
val defaultSource = PatchBundleEntity(
uid = 0,
name = "",
versionHash = null,
source = Source.API,
autoUpdate = false
)
}
}

View File

@ -3,55 +3,78 @@ package app.revanced.manager.domain.repository
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.annotation.StringRes
import app.revanced.library.mostCommonCompatibleVersions import app.revanced.library.mostCommonCompatibleVersions
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.redux.Action
import app.revanced.manager.data.redux.ActionContext
import app.revanced.manager.data.redux.Store
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.bundles.PatchBundleEntity
import app.revanced.manager.data.room.bundles.PatchBundleProperties
import app.revanced.manager.data.room.bundles.Source
import app.revanced.manager.domain.bundles.APIPatchBundle import app.revanced.manager.domain.bundles.APIPatchBundle
import app.revanced.manager.domain.bundles.JsonPatchBundle import app.revanced.manager.domain.bundles.JsonPatchBundle
import app.revanced.manager.data.room.bundles.Source as SourceInfo import app.revanced.manager.data.room.bundles.Source as SourceInfo
import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.flatMapLatestAndCombine import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.toast
import kotlinx.collections.immutable.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import kotlin.collections.joinToString
import kotlin.collections.map
import kotlin.text.ifEmpty
class PatchBundleRepository( class PatchBundleRepository(
private val app: Application, private val app: Application,
private val persistenceRepo: PatchBundlePersistenceRepository,
private val networkInfo: NetworkInfo, private val networkInfo: NetworkInfo,
private val prefs: PreferencesManager, private val prefs: PreferencesManager,
db: AppDatabase,
) { ) {
private val dao = db.patchBundleDao()
private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE) private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
private val _sources: MutableStateFlow<Map<Int, PatchBundleSource>> = private val store = Store(CoroutineScope(Dispatchers.Default), State())
MutableStateFlow(emptyMap())
val sources = _sources.map { it.values.toList() }
val bundles = sources.flatMapLatestAndCombine( val sources = store.state.map { it.sources.values.toList() }
combiner = { val bundles = store.state.map {
it.mapNotNull { (uid, state) -> it.sources.mapNotNull { (uid, src) ->
val bundle = state.patchBundleOrNull() ?: return@mapNotNull null uid to (src.patchBundle ?: return@mapNotNull null)
uid to bundle
}.toMap() }.toMap()
} }
) { val bundleInfoFlow = store.state.map { it.info }
it.state.map { state -> it.uid to state }
fun scopedBundleInfoFlow(packageName: String, version: String?) = bundleInfoFlow.map {
it.map { (_, bundleInfo) ->
bundleInfo.forPackage(
packageName,
version
)
}
} }
val suggestedVersions = bundles.map { val patchCountsFlow = bundleInfoFlow.map { it.mapValues { (_, info) -> info.patches.size } }
val suggestedVersions = bundleInfoFlow.map {
val allPatches = val allPatches =
it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet() it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()
@ -74,6 +97,100 @@ class PatchBundleRepository(
} }
} }
private suspend inline fun dispatchAction(
name: String,
crossinline block: suspend ActionContext.(current: State) -> State
) {
store.dispatch(object : Action<State> {
override suspend fun ActionContext.execute(current: State) = block(current)
override fun toString() = name
})
}
/**
* Performs a reload. Do not call this outside of a store action.
*/
private suspend fun doReload(): State {
val entities = loadFromDb().onEach {
Log.d(tag, "Bundle: $it")
}
val sources = entities.associate { it.uid to it.load() }.toPersistentMap()
val hasOutOfDateNames = sources.values.any { it.isNameOutOfDate }
if (hasOutOfDateNames) dispatchAction(
"Sync names"
) { state ->
val nameChanges = state.sources.mapNotNull { (_, src) ->
if (!src.isNameOutOfDate) return@mapNotNull null
val newName = src.patchBundle?.manifestAttributes?.name?.takeIf { it != src.name }
?: return@mapNotNull null
src.uid to newName
}
val sources = state.sources.toMutableMap()
val info = state.info.toMutableMap()
nameChanges.forEach { (uid, name) ->
updateDb(uid) { it.copy(name = name) }
sources[uid] = sources[uid]!!.copy(name = name)
info[uid] = info[uid]?.copy(name = name) ?: return@forEach
}
State(sources.toPersistentMap(), info.toPersistentMap())
}
val info = loadMetadata(sources).toPersistentMap()
return State(sources, info)
}
suspend fun reload() = dispatchAction("Full reload") {
doReload()
}
private suspend fun loadFromDb(): List<PatchBundleEntity> {
val all = dao.all()
if (all.isEmpty()) {
dao.upsert(defaultSource)
return listOf(defaultSource)
}
return all
}
private suspend fun loadMetadata(sources: Map<Int, PatchBundleSource>): Map<Int, PatchBundleInfo.Global> {
// Map bundles -> sources
val map = sources.mapNotNull { (_, src) ->
(src.patchBundle ?: return@mapNotNull null) to src
}.toMap()
val metadata = try {
PatchBundle.Loader.metadata(map.keys)
} catch (error: Throwable) {
val uids = map.values.map { it.uid }
dispatchAction("Mark bundles as failed") { state ->
state.copy(sources = state.sources.mutate {
uids.forEach { uid ->
it[uid] = it[uid]?.copy(error = error) ?: return@forEach
}
})
}
Log.e(tag, "Failed to load bundles", error)
emptyMap()
}
return metadata.entries.associate { (bundle, patches) ->
val src = map[bundle]!!
src.uid to PatchBundleInfo.Global(
src.name,
bundle.manifestAttributes?.version,
src.uid,
patches
)
}
}
suspend fun isVersionAllowed(packageName: String, version: String) = suspend fun isVersionAllowed(packageName: String, version: String) =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
if (!prefs.suggestedVersionSafeguard.get()) return@withContext true if (!prefs.suggestedVersionSafeguard.get()) return@withContext true
@ -89,96 +206,211 @@ class PatchBundleRepository(
private fun PatchBundleEntity.load(): PatchBundleSource { private fun PatchBundleEntity.load(): PatchBundleSource {
val dir = directoryOf(uid) val dir = directoryOf(uid)
val actualName =
name.ifEmpty { app.getString(if (uid == 0) R.string.patches_name_default else R.string.patches_name_fallback) }
return when (source) { return when (source) {
is SourceInfo.Local -> LocalPatchBundle(name, uid, dir) is SourceInfo.Local -> LocalPatchBundle(actualName, uid, null, dir)
is SourceInfo.API -> APIPatchBundle(name, uid, dir, SourceInfo.API.SENTINEL) is SourceInfo.API -> APIPatchBundle(
is SourceInfo.Remote -> JsonPatchBundle( actualName,
name,
uid, uid,
versionHash,
null,
dir, dir,
source.url.toString() SourceInfo.API.SENTINEL,
autoUpdate,
)
is SourceInfo.Remote -> JsonPatchBundle(
actualName,
uid,
versionHash,
null,
dir,
source.url.toString(),
autoUpdate,
) )
} }
} }
suspend fun reload() = withContext(Dispatchers.Default) { private suspend fun createEntity(name: String, source: Source, autoUpdate: Boolean = false) =
val entities = persistenceRepo.loadConfiguration().onEach { PatchBundleEntity(
Log.d(tag, "Bundle: $it") uid = generateUid(),
name = name,
versionHash = null,
source = source,
autoUpdate = autoUpdate
).also {
dao.upsert(it)
} }
_sources.value = entities.associate { /**
it.uid to it.load() * Updates a patch bundle in the database. Do not use this outside an action.
*/
private suspend fun updateDb(
uid: Int,
block: (PatchBundleProperties) -> PatchBundleProperties
) {
val previous = dao.getProps(uid)!!
val new = block(previous)
dao.upsert(
PatchBundleEntity(
uid = uid,
name = new.name,
versionHash = new.versionHash,
source = new.source,
autoUpdate = new.autoUpdate,
)
)
}
suspend fun reset() = dispatchAction("Reset") { state ->
dao.reset()
state.sources.keys.forEach { directoryOf(it).deleteRecursively() }
doReload()
}
suspend fun remove(vararg bundles: PatchBundleSource) =
dispatchAction("Remove (${bundles.map { it.uid }.joinToString(",")})") { state ->
val sources = state.sources.toMutableMap()
val info = state.info.toMutableMap()
bundles.forEach {
if (it.isDefault) return@forEach
dao.remove(it.uid)
directoryOf(it.uid).deleteRecursively()
sources.remove(it.uid)
info.remove(it.uid)
}
State(sources.toPersistentMap(), info.toPersistentMap())
}
suspend fun createLocal(createStream: suspend () -> InputStream) = dispatchAction("Add bundle") {
with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) {
try {
createStream().use { patches -> replace(patches) }
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(tag, "Got exception while importing bundle", e)
withContext(Dispatchers.Main) {
app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage()))
}
deleteLocalFile()
} }
} }
suspend fun reset() = withContext(Dispatchers.Default) { doReload()
persistenceRepo.reset()
_sources.value = emptyMap()
bundlesDir.apply {
deleteRecursively()
mkdirs()
} }
reload() suspend fun createRemote(url: String, autoUpdate: Boolean) =
dispatchAction("Add bundle ($url)") { state ->
val src = createEntity("", SourceInfo.from(url), autoUpdate).load() as RemotePatchBundle
update(src)
state.copy(sources = state.sources.put(src.uid, src))
} }
suspend fun remove(bundle: PatchBundleSource) = withContext(Dispatchers.Default) { suspend fun reloadApiBundles() = dispatchAction("Reload API bundles") {
persistenceRepo.delete(bundle.uid) this@PatchBundleRepository.sources.first().filterIsInstance<APIPatchBundle>().forEach {
directoryOf(bundle.uid).deleteRecursively() with(it) { deleteLocalFile() }
updateDb(it.uid) { it.copy(versionHash = null) }
_sources.update {
it.filterKeys { key ->
key != bundle.uid
}
}
} }
private fun addBundle(patchBundle: PatchBundleSource) = doReload()
_sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } }
suspend fun createLocal(patches: InputStream) = withContext(Dispatchers.Default) {
val uid = persistenceRepo.create("", SourceInfo.Local).uid
val bundle = LocalPatchBundle("", uid, directoryOf(uid))
bundle.replace(patches)
addBundle(bundle)
} }
suspend fun createRemote(url: String, autoUpdate: Boolean) = withContext(Dispatchers.Default) { suspend fun RemotePatchBundle.setAutoUpdate(value: Boolean) =
val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate) dispatchAction("Set auto update ($name, $value)") { state ->
addBundle(entity.load()) updateDb(uid) { it.copy(autoUpdate = value) }
val newSrc = (state.sources[uid] as? RemotePatchBundle)?.copy(autoUpdate = value)
?: return@dispatchAction state
state.copy(sources = state.sources.put(uid, newSrc))
} }
private suspend inline fun <reified T> getBundlesByType() = suspend fun update(vararg sources: RemotePatchBundle, showToast: Boolean = false) {
sources.first().filterIsInstance<T>() val uids = sources.map { it.uid }.toSet()
store.dispatch(Update(showToast = showToast) { it.uid in uids })
suspend fun reloadApiBundles() {
getBundlesByType<APIPatchBundle>().forEach {
it.deleteLocalFiles()
} }
reload() suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true))
}
suspend fun redownloadRemoteBundles() = /**
getBundlesByType<RemotePatchBundle>().forEach { it.downloadLatest() } * Updates all bundles that should be automatically updated.
*/
suspend fun updateCheck() = store.dispatch(Update { it.autoUpdate })
suspend fun updateCheck() = private inner class Update(
uiSafe(app, R.string.source_download_fail, "Failed to update bundles") { private val force: Boolean = false,
coroutineScope { private val showToast: Boolean = false,
private val predicate: (bundle: RemotePatchBundle) -> Boolean = { true },
) : Action<State> {
private suspend fun toast(@StringRes id: Int, vararg args: Any?) =
withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) }
override fun toString() = if (force) "Redownload remote bundles" else "Update check"
override suspend fun ActionContext.execute(
current: State
) = coroutineScope {
if (!networkInfo.isSafe()) { if (!networkInfo.isSafe()) {
Log.d(tag, "Skipping update check because the network is down or metered.") Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope return@coroutineScope current
} }
getBundlesByType<RemotePatchBundle>().forEach { val updated = current.sources.values
launch { .filterIsInstance<RemotePatchBundle>()
if (!it.getProps().autoUpdate) return@launch .filter { predicate(it) }
Log.d(tag, "Updating patch bundle: ${it.getName()}") .map {
it.update() async {
Log.d(tag, "Updating patch bundle: ${it.name}")
val newVersion = with(it) {
if (force) downloadLatest() else update()
} ?: return@async null
it to newVersion
} }
} }
.awaitAll()
.filterNotNull()
.toMap()
if (updated.isEmpty()) {
if (showToast) toast(R.string.patches_update_unavailable)
return@coroutineScope current
}
updated.forEach { (src, newVersionHash) ->
val name = src.patchBundle?.manifestAttributes?.name ?: src.name
updateDb(src.uid) {
it.copy(versionHash = newVersionHash, name = name)
} }
} }
if (showToast) toast(R.string.patches_update_success)
doReload()
}
override suspend fun catch(exception: Exception) {
Log.e(tag, "Failed to update patches", exception)
toast(R.string.patches_download_fail, exception.simpleMessage())
}
}
data class State(
val sources: PersistentMap<Int, PatchBundleSource> = persistentMapOf(),
val info: PersistentMap<Int, PatchBundleInfo.Global> = persistentMapOf()
)
private companion object {
val defaultSource = PatchBundleEntity(
uid = 0,
name = "",
versionHash = null,
source = Source.API,
autoUpdate = false
)
}
} }

View File

@ -1,47 +1,73 @@
package app.revanced.manager.patcher.patch package app.revanced.manager.patcher.patch
import android.util.Log import kotlinx.parcelize.IgnoredOnParcel
import app.revanced.manager.util.tag import android.os.Parcelable
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.loadPatchesFromDex
import app.revanced.patcher.patch.PatchLoader import kotlinx.parcelize.Parcelize
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.jar.JarFile import java.util.jar.JarFile
import kotlin.collections.filter
class PatchBundle(val patchesJar: File) { @Parcelize
private val loader = object : Iterable<Patch<*>> { data class PatchBundle(val patchesJar: String) : Parcelable {
private fun load(): Iterable<Patch<*>> {
patchesJar.setReadOnly()
return PatchLoader.Dex(setOf(patchesJar))
}
override fun iterator(): Iterator<Patch<*>> = load().iterator()
}
init {
Log.d(tag, "Loaded patch bundle: $patchesJar")
}
/**
* A list containing the metadata of every patch inside this bundle.
*/
val patches = loader.map(::PatchInfo)
/** /**
* The [java.util.jar.Manifest] of [patchesJar]. * The [java.util.jar.Manifest] of [patchesJar].
*/ */
private val manifest = try { @IgnoredOnParcel
private val manifest by lazy {
try {
JarFile(patchesJar).use { it.manifest } JarFile(patchesJar).use { it.manifest }
} catch (_: IOException) { } catch (_: IOException) {
null null
} }
}
fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name) @IgnoredOnParcel
val manifestAttributes by lazy {
if (manifest != null)
ManifestAttributes(
name = readManifestAttribute("name"),
version = readManifestAttribute("version"),
description = readManifestAttribute("description"),
source = readManifestAttribute("source"),
author = readManifestAttribute("author"),
contact = readManifestAttribute("contact"),
website = readManifestAttribute("website"),
license = readManifestAttribute("license")
) else
null
}
/** private fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)
* Load all patches compatible with the specified package. ?.takeIf { it.isNotBlank() } // If empty, set it to null instead.
*/
fun patches(packageName: String) = loader.filter { patch -> data class ManifestAttributes(
val name: String?,
val version: String?,
val description: String?,
val source: String?,
val author: String?,
val contact: String?,
val website: String?,
val license: String?
)
object Loader {
private fun patches(bundles: Iterable<PatchBundle>) =
loadPatchesFromDex(
bundles.map { File(it.patchesJar) }.toSet()
).byPatchesFile.mapKeys { (file, _) ->
val absPath = file.absolutePath
bundles.single { absPath == it.patchesJar }
}
fun metadata(bundles: Iterable<PatchBundle>) =
patches(bundles).mapValues { (_, patches) -> patches.map(::PatchInfo) }
fun patches(bundles: Iterable<PatchBundle>, packageName: String) =
patches(bundles).mapValues { (_, patches) ->
patches.filter { patch ->
val compatiblePackages = patch.compatiblePackages val compatiblePackages = patch.compatiblePackages
?: // The patch has no compatibility constraints, which means it is universal. ?: // The patch has no compatibility constraints, which means it is universal.
return@filter true return@filter true
@ -52,5 +78,7 @@ class PatchBundle(val patchesJar: File) {
} }
true true
}.toSet()
}
} }
} }

View File

@ -0,0 +1,145 @@
package app.revanced.manager.patcher.patch
import app.revanced.manager.util.PatchSelection
/**
* A base class for storing [PatchBundle] metadata.
*/
sealed class PatchBundleInfo {
/**
* The name of the bundle.
*/
abstract val name: String
/**
* The version of the bundle.
*/
abstract val version: String?
/**
* The unique ID of the bundle.
*/
abstract val uid: Int
/**
* The patch list.
*/
abstract val patches: List<PatchInfo>
/**
* Information about a bundle and all the patches it contains.
*
* @see [PatchBundleInfo]
*/
data class Global(
override val name: String,
override val version: String?,
override val uid: Int,
override val patches: List<PatchInfo>
) : PatchBundleInfo() {
/**
* Create a [PatchBundleInfo.Scoped] that only contains information about patches that are relevant for a specific [packageName].
*/
fun forPackage(packageName: String, version: String?): Scoped {
val relevantPatches = patches.filter { it.compatibleWith(packageName) }
val compatible = mutableListOf<PatchInfo>()
val incompatible = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
relevantPatches.forEach {
val targetList = when {
it.compatiblePackages == null -> universal
it.supports(
packageName,
version
) -> compatible
else -> incompatible
}
targetList.add(it)
}
return Scoped(
name,
this.version,
uid,
relevantPatches,
compatible,
incompatible,
universal
)
}
}
/**
* Contains information about a bundle that is relevant for a specific package name.
*
* @param compatible Patches that are compatible with the specified package name and version.
* @param incompatible Patches that are compatible with the specified package name but not version.
* @param universal Patches that are compatible with all packages.
* @see [PatchBundleInfo.Global.forPackage]
* @see [PatchBundleInfo]
*/
data class Scoped(
override val name: String,
override val version: String?,
override val uid: Int,
override val patches: List<PatchInfo>,
val compatible: List<PatchInfo>,
val incompatible: List<PatchInfo>,
val universal: List<PatchInfo>
) : PatchBundleInfo() {
fun patchSequence(allowIncompatible: Boolean) = if (allowIncompatible) {
patches.asSequence()
} else {
sequence {
yieldAll(compatible)
yieldAll(universal)
}
}
}
companion object Extensions {
inline fun Iterable<Scoped>.toPatchSelection(
allowIncompatible: Boolean,
condition: (Int, PatchInfo) -> Boolean
): PatchSelection = this.associate { bundle ->
val patches =
bundle.patchSequence(allowIncompatible)
.mapNotNullTo(mutableSetOf()) { patch ->
patch.name.takeIf {
condition(
bundle.uid,
patch
)
}
}
bundle.uid to patches
}
/**
* Algorithm for determining whether all required options have been set.
*/
inline fun Iterable<Scoped>.requiredOptionsSet(
allowIncompatible: Boolean,
crossinline isSelected: (Scoped, PatchInfo) -> Boolean,
crossinline optionsForPatch: (Scoped, PatchInfo) -> Map<String, Any?>?
) = all bundle@{ bundle ->
bundle
.patchSequence(allowIncompatible)
.filter { isSelected(bundle, it) }
.all patch@{
if (it.options.isNullOrEmpty()) return@patch true
val opts by lazy { optionsForPatch(bundle, it).orEmpty() }
it.options.all option@{ option ->
if (!option.required || option.default != null) return@option true
option.key in opts
}
}
}
}
}

View File

@ -3,6 +3,7 @@ package app.revanced.manager.patcher.runtime
import android.content.Context import android.content.Context
import app.revanced.manager.patcher.Session import app.revanced.manager.patcher.Session
import app.revanced.manager.patcher.logger.Logger import app.revanced.manager.patcher.logger.Logger
import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.worker.ProgressEventHandler import app.revanced.manager.patcher.worker.ProgressEventHandler
import app.revanced.manager.ui.model.State import app.revanced.manager.ui.model.State
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
@ -23,14 +24,17 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
onPatchCompleted: suspend () -> Unit, onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler, onProgress: ProgressEventHandler,
) { ) {
val bundles = bundles()
val selectedBundles = selectedPatches.keys val selectedBundles = selectedPatches.keys
val allPatches = bundles.filterKeys { selectedBundles.contains(it) } val bundles = bundles()
.mapValues { (_, bundle) -> bundle.patches(packageName) } val uids = bundles.entries.associate { (key, value) -> value to key }
val allPatches =
PatchBundle.Loader.patches(bundles.values, packageName)
.mapKeys { (b, _) -> uids[b]!! }
.filterKeys { it in selectedBundles }
val patchList = selectedPatches.flatMap { (bundle, selected) -> val patchList = selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { selected.contains(it.name) } allPatches[bundle]?.filter { it.name in selected }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist") ?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
} }

View File

@ -142,8 +142,6 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
} }
} }
val bundles = bundles()
val parameters = Parameters( val parameters = Parameters(
aaptPath = aaptPath, aaptPath = aaptPath,
frameworkDir = frameworkPath, frameworkDir = frameworkPath,
@ -151,13 +149,11 @@ class ProcessRuntime(private val context: Context) : Runtime(context) {
packageName = packageName, packageName = packageName,
inputFile = inputFile, inputFile = inputFile,
outputFile = outputFile, outputFile = outputFile,
configurations = selectedPatches.map { (id, patches) -> configurations = bundles().map { (uid, bundle) ->
val bundle = bundles[id]!!
PatchConfiguration( PatchConfiguration(
bundle.patchesJar.absolutePath, bundle,
patches, selectedPatches[uid].orEmpty(),
options[id].orEmpty() options[uid].orEmpty()
) )
} }
) )

View File

@ -1,6 +1,7 @@
package app.revanced.manager.patcher.runtime.process package app.revanced.manager.patcher.runtime.process
import android.os.Parcelable import android.os.Parcelable
import app.revanced.manager.patcher.patch.PatchBundle
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue import kotlinx.parcelize.RawValue
@ -17,7 +18,7 @@ data class Parameters(
@Parcelize @Parcelize
data class PatchConfiguration( data class PatchConfiguration(
val bundlePath: String, val bundle: PatchBundle,
val patches: Set<String>, val patches: Set<String>,
val options: @RawValue Map<String, Map<String, Any?>> val options: @RawValue Map<String, Map<String, Any?>>
) : Parcelable ) : Parcelable

View File

@ -56,11 +56,10 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB") logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB")
val allPatches = PatchBundle.Loader.patches(parameters.configurations.map { it.bundle }, parameters.packageName)
val patchList = parameters.configurations.flatMap { config -> val patchList = parameters.configurations.flatMap { config ->
val bundle = PatchBundle(File(config.bundlePath)) val patches = (allPatches[config.bundle] ?: return@flatMap emptyList())
.filter { it.name in config.patches }
val patches =
bundle.patches(parameters.packageName).filter { it.name in config.patches }
.associateBy { it.name } .associateBy { it.name }
config.options.forEach { (patchName, opts) -> config.options.forEach { (patchName, opts) ->

View File

@ -14,9 +14,9 @@ import android.os.Parcelable
import android.os.PowerManager import android.os.PowerManager
import android.util.Log import android.util.Log
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.core.content.ContextCompat
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import app.revanced.manager.MainActivity
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.platform.Filesystem
import app.revanced.manager.data.room.apps.installed.InstallType import app.revanced.manager.data.room.apps.installed.InstallType
@ -88,22 +88,25 @@ class PatcherWorker(
) )
private fun createNotification(): Notification { private fun createNotification(): Notification {
val notificationIntent = Intent(applicationContext, PatcherWorker::class.java) val notificationIntent = Intent(applicationContext, MainActivity::class.java).apply {
val pendingIntent: PendingIntent = PendingIntent.getActivity( flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
applicationContext, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE applicationContext, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
) )
val channel = NotificationChannel( val channel = NotificationChannel(
"revanced-patcher-patching", "Patching", NotificationManager.IMPORTANCE_HIGH "revanced-patcher-patching", "Patching", NotificationManager.IMPORTANCE_LOW
) )
val notificationManager = val notificationManager =
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) applicationContext.getSystemService(NotificationManager::class.java)
notificationManager!!.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
return Notification.Builder(applicationContext, channel.id) return Notification.Builder(applicationContext, channel.id)
.setContentTitle(applicationContext.getText(R.string.app_name)) .setContentTitle(applicationContext.getText(R.string.patcher_notification_title))
.setContentText(applicationContext.getText(R.string.patcher_notification_message)) .setContentText(applicationContext.getText(R.string.patcher_notification_text))
.setLargeIcon(Icon.createWithResource(applicationContext, R.drawable.ic_notification))
.setSmallIcon(Icon.createWithResource(applicationContext, R.drawable.ic_notification)) .setSmallIcon(Icon.createWithResource(applicationContext, R.drawable.ic_notification))
.setContentIntent(pendingIntent).build() .setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SERVICE)
.build()
} }
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
@ -158,6 +161,8 @@ class PatcherWorker(
data, data,
args.packageName, args.packageName,
args.input.version, args.input.version,
prefs.suggestedVersionSafeguard.get(),
!prefs.disablePatchVersionCompatCheck.get(),
onDownload = args.onDownloadProgress onDownload = args.onDownloadProgress
).also { ).also {
args.setInputFile(it) args.setInputFile(it)

View File

@ -30,7 +30,7 @@ fun ExceptionViewerDialog(text: String, onDismiss: () -> Unit) {
Scaffold( Scaffold(
topBar = { topBar = {
BundleTopBar( BundleTopBar(
title = stringResource(R.string.bundle_error), title = stringResource(R.string.patches_error),
onBackClick = onDismiss, onBackClick = onDismiss,
backIcon = { backIcon = {
Icon( Icon(

View File

@ -1,6 +1,7 @@
package app.revanced.manager.ui.component package app.revanced.manager.ui.component
import android.view.WindowManager import android.view.WindowManager
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -9,6 +10,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.WindowCompat
private val properties = DialogProperties( private val properties = DialogProperties(
usePlatformDefaultWidth = false, usePlatformDefaultWidth = false,
@ -22,11 +24,17 @@ fun FullscreenDialog(onDismissRequest: () -> Unit, content: @Composable () -> Un
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
properties = properties properties = properties
) { ) {
val window = (LocalView.current.parent as DialogWindowProvider).window val view = LocalView.current
LaunchedEffect(Unit) { val isDarkTheme = isSystemInDarkTheme()
LaunchedEffect(isDarkTheme) {
val window = (view.parent as DialogWindowProvider).window
window.statusBarColor = Color.Transparent.toArgb() window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb() window.navigationBarColor = Color.Transparent.toArgb()
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
} }
content() content()

View File

@ -1,181 +0,0 @@
package app.revanced.manager.ui.component.bundle
import android.webkit.URLUtil
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.outlined.Extension
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.Sell
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.TextInputDialog
import app.revanced.manager.ui.component.haptics.HapticSwitch
@Composable
fun BaseBundleDialog(
modifier: Modifier = Modifier,
isDefault: Boolean,
name: String?,
remoteUrl: String?,
onRemoteUrlChange: ((String) -> Unit)? = null,
patchCount: Int,
version: String?,
autoUpdate: Boolean,
onAutoUpdateChange: (Boolean) -> Unit,
onPatchesClick: () -> Unit,
extraFields: @Composable ColumnScope.() -> Unit = {}
) {
ColumnWithScrollbar(
modifier = Modifier
.fillMaxWidth()
.then(modifier),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.Start),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Inventory2,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
name?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight(800)),
color = MaterialTheme.colorScheme.primary,
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 2.dp)
) {
version?.let {
Tag(Icons.Outlined.Sell, it)
}
Tag(Icons.Outlined.Extension, patchCount.toString())
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
if (remoteUrl != null) {
BundleListItem(
headlineText = stringResource(R.string.bundle_auto_update),
supportingText = stringResource(R.string.bundle_auto_update_description),
trailingContent = {
HapticSwitch(
checked = autoUpdate,
onCheckedChange = onAutoUpdateChange
)
},
modifier = Modifier.clickable {
onAutoUpdateChange(!autoUpdate)
}
)
}
remoteUrl?.takeUnless { isDefault }?.let { url ->
var showUrlInputDialog by rememberSaveable {
mutableStateOf(false)
}
if (showUrlInputDialog) {
TextInputDialog(
initial = url,
title = stringResource(R.string.bundle_input_source_url),
onDismissRequest = { showUrlInputDialog = false },
onConfirm = {
showUrlInputDialog = false
onRemoteUrlChange?.invoke(it)
},
validator = {
if (it.isEmpty()) return@TextInputDialog false
URLUtil.isValidUrl(it)
}
)
}
BundleListItem(
modifier = Modifier.clickable(
enabled = onRemoteUrlChange != null,
onClick = {
showUrlInputDialog = true
}
),
headlineText = stringResource(R.string.bundle_input_source_url),
supportingText = url.ifEmpty {
stringResource(R.string.field_not_set)
}
)
}
val patchesClickable = patchCount > 0
BundleListItem(
headlineText = stringResource(R.string.patches),
supportingText = stringResource(R.string.bundle_view_patches),
modifier = Modifier.clickable(
enabled = patchesClickable,
onClick = onPatchesClick
)
) {
if (patchesClickable) {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
stringResource(R.string.patches)
)
}
}
extraFields()
}
}
@Composable
private fun Tag(
icon: ImageVector,
text: String
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.outline,
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
}

View File

@ -1,68 +1,99 @@
package app.revanced.manager.ui.component.bundle package app.revanced.manager.ui.component.bundle
import android.webkit.URLUtil.isValidUrl
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.outlined.Commit
import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Sell
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.R.string.auto_update
import app.revanced.manager.R.string.auto_update_description
import app.revanced.manager.R.string.field_not_set
import app.revanced.manager.R.string.patches
import app.revanced.manager.R.string.patches_url
import app.revanced.manager.R.string.view_patches
import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull 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.isDefault
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ExceptionViewerDialog import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.FullscreenDialog import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.TextInputDialog
import app.revanced.manager.ui.component.haptics.HapticSwitch
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BundleInformationDialog( fun BundleInformationDialog(
src: PatchBundleSource,
patchCount: Int,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onDeleteRequest: () -> Unit, onDeleteRequest: () -> Unit,
bundle: PatchBundleSource,
onUpdate: () -> Unit, onUpdate: () -> Unit,
) { ) {
val bundleRepo = koinInject<PatchBundleRepository>()
val networkInfo = koinInject<NetworkInfo>() val networkInfo = koinInject<NetworkInfo>()
val hasNetwork = remember { networkInfo.isConnected() } val hasNetwork = remember { networkInfo.isConnected() }
val composableScope = rememberCoroutineScope() val composableScope = rememberCoroutineScope()
var viewCurrentBundlePatches by remember { mutableStateOf(false) } var viewCurrentBundlePatches by remember { mutableStateOf(false) }
val isLocal = bundle is LocalPatchBundle val isLocal = src is LocalPatchBundle
val state by bundle.state.collectAsStateWithLifecycle() val bundleManifestAttributes = src.patchBundle?.manifestAttributes
val props by remember(bundle) { val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.endpoint } ?: (null to null)
bundle.propsFlow()
}.collectAsStateWithLifecycle(null) fun onAutoUpdateChange(new: Boolean) = composableScope.launch {
val patchCount by bundle.patchCountFlow.collectAsStateWithLifecycle(0) with(bundleRepo) {
val version by bundle.versionFlow.collectAsStateWithLifecycle(null) src.asRemoteOrNull?.setAutoUpdate(new)
}
}
if (viewCurrentBundlePatches) { if (viewCurrentBundlePatches) {
BundlePatchesDialog( BundlePatchesDialog(
src = src,
onDismissRequest = { onDismissRequest = {
viewCurrentBundlePatches = false viewCurrentBundlePatches = false
}, }
bundle = bundle,
) )
} }
FullscreenDialog( FullscreenDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
) { ) {
val bundleName by bundle.nameState
Scaffold( Scaffold(
topBar = { topBar = {
BundleTopBar( BundleTopBar(
title = stringResource(R.string.patch_bundle_field), title = src.name,
onBackClick = onDismissRequest, onBackClick = onDismissRequest,
backIcon = { backIcon = {
Icon( Icon(
@ -71,7 +102,7 @@ fun BundleInformationDialog(
) )
}, },
actions = { actions = {
if (!bundle.isDefault) { if (!src.isDefault) {
IconButton(onClick = onDeleteRequest) { IconButton(onClick = onDeleteRequest) {
Icon( Icon(
Icons.Outlined.DeleteOutline, Icons.Outlined.DeleteOutline,
@ -91,24 +122,112 @@ fun BundleInformationDialog(
) )
}, },
) { paddingValues -> ) { paddingValues ->
BaseBundleDialog( ColumnWithScrollbar(
modifier = Modifier.padding(paddingValues), modifier = Modifier
isDefault = bundle.isDefault, .fillMaxWidth()
name = bundleName, .padding(paddingValues),
remoteUrl = bundle.asRemoteOrNull?.endpoint, ) {
patchCount = patchCount, Column(
version = version, modifier = Modifier.padding(16.dp),
autoUpdate = props?.autoUpdate == true, verticalArrangement = Arrangement.spacedBy(4.dp)
onAutoUpdateChange = { ) {
composableScope.launch { Tag(Icons.Outlined.Sell, src.name)
bundle.asRemoteOrNull?.setAutoUpdate(it) bundleManifestAttributes?.description?.let {
Tag(Icons.Outlined.Description, it)
} }
bundleManifestAttributes?.source?.let {
Tag(Icons.Outlined.Commit, it)
}
bundleManifestAttributes?.author?.let {
Tag(Icons.Outlined.Person, it)
}
bundleManifestAttributes?.contact?.let {
Tag(Icons.AutoMirrored.Outlined.Send, it)
}
bundleManifestAttributes?.website?.let {
Tag(Icons.Outlined.Language, it, isUrl = true)
}
bundleManifestAttributes?.license?.let {
Tag(Icons.Outlined.Gavel, it)
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
if (autoUpdate != null) {
BundleListItem(
headlineText = stringResource(auto_update),
supportingText = stringResource(auto_update_description),
trailingContent = {
HapticSwitch(
checked = autoUpdate,
onCheckedChange = ::onAutoUpdateChange
)
}, },
onPatchesClick = { modifier = Modifier.clickable {
onAutoUpdateChange(!autoUpdate)
}
)
}
endpoint?.takeUnless { src.isDefault }?.let { url ->
var showUrlInputDialog by rememberSaveable {
mutableStateOf(false)
}
if (showUrlInputDialog) {
TextInputDialog(
initial = url,
title = stringResource(patches_url),
onDismissRequest = { showUrlInputDialog = false },
onConfirm = {
showUrlInputDialog = false
TODO("Not implemented.")
},
validator = {
if (it.isEmpty()) return@TextInputDialog false
isValidUrl(it)
}
)
}
BundleListItem(
modifier = Modifier.clickable(
enabled = false,
onClick = {
showUrlInputDialog = true
}
),
headlineText = stringResource(patches_url),
supportingText = url.ifEmpty {
stringResource(field_not_set)
}
)
}
val patchesClickable = patchCount > 0
BundleListItem(
headlineText = stringResource(patches),
supportingText = stringResource(view_patches),
modifier = Modifier.clickable(
enabled = patchesClickable,
onClick = {
viewCurrentBundlePatches = true viewCurrentBundlePatches = true
}, }
extraFields = { )
(state as? PatchBundleSource.State.Failed)?.throwable?.let { ) {
if (patchesClickable) {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
stringResource(patches)
)
}
}
src.error?.let {
var showDialog by rememberSaveable { var showDialog by rememberSaveable {
mutableStateOf(false) mutableStateOf(false)
} }
@ -118,8 +237,8 @@ fun BundleInformationDialog(
) )
BundleListItem( BundleListItem(
headlineText = stringResource(R.string.bundle_error), headlineText = stringResource(R.string.patches_error),
supportingText = stringResource(R.string.bundle_error_description), supportingText = stringResource(R.string.patches_error_description),
trailingContent = { trailingContent = {
Icon( Icon(
Icons.AutoMirrored.Outlined.ArrowRight, Icons.AutoMirrored.Outlined.ArrowRight,
@ -129,16 +248,49 @@ fun BundleInformationDialog(
modifier = Modifier.clickable { showDialog = true } modifier = Modifier.clickable { showDialog = true }
) )
} }
if (src.state is PatchBundleSource.State.Missing && !isLocal) {
if (state is PatchBundleSource.State.Missing && !isLocal) {
BundleListItem( BundleListItem(
headlineText = stringResource(R.string.bundle_error), headlineText = stringResource(R.string.patches_error),
supportingText = stringResource(R.string.bundle_not_downloaded), supportingText = stringResource(R.string.patches_not_downloaded),
modifier = Modifier.clickable(onClick = onUpdate) modifier = Modifier.clickable(onClick = onUpdate)
) )
} }
} }
}
}
}
@Composable
private fun Tag(
icon: ImageVector,
text: String,
isUrl: Boolean = false
) {
val uriHandler = LocalUriHandler.current
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = if (isUrl) {
Modifier
.clickable {
try {
uriHandler.openUri(text)
} catch (_: Exception) {
}
}
} else
Modifier,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = if (isUrl) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
) )
} }
} }
}

View File

@ -24,38 +24,32 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import app.revanced.manager.ui.component.ConfirmDialog import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticCheckbox
import kotlinx.coroutines.flow.map
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun BundleItem( fun BundleItem(
bundle: PatchBundleSource, src: PatchBundleSource,
onDelete: () -> Unit, patchCount: Int,
onUpdate: () -> Unit,
selectable: Boolean, selectable: Boolean,
onSelect: () -> Unit,
isBundleSelected: Boolean, isBundleSelected: Boolean,
toggleSelection: (Boolean) -> Unit, toggleSelection: (Boolean) -> Unit,
onSelect: () -> Unit,
onDelete: () -> Unit,
onUpdate: () -> Unit,
) { ) {
var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) } var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) }
var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) }
val state by bundle.state.collectAsStateWithLifecycle()
val version by bundle.versionFlow.collectAsStateWithLifecycle(null)
val patchCount by bundle.patchCountFlow.collectAsStateWithLifecycle(0)
val name by bundle.nameState
if (viewBundleDialogPage) { if (viewBundleDialogPage) {
BundleInformationDialog( BundleInformationDialog(
src = src,
patchCount = patchCount,
onDismissRequest = { viewBundleDialogPage = false }, onDismissRequest = { viewBundleDialogPage = false },
onDeleteRequest = { showDeleteConfirmationDialog = true }, onDeleteRequest = { showDeleteConfirmationDialog = true },
bundle = bundle,
onUpdate = onUpdate, onUpdate = onUpdate,
) )
} }
@ -67,8 +61,8 @@ fun BundleItem(
onDelete() onDelete()
viewBundleDialogPage = false viewBundleDialogPage = false
}, },
title = stringResource(R.string.bundle_delete_single_dialog_title), title = stringResource(R.string.delete),
description = stringResource(R.string.bundle_delete_single_dialog_description, name), description = stringResource(R.string.patches_delete_single_dialog_description, src.name),
icon = Icons.Outlined.Delete icon = Icons.Outlined.Delete
) )
} }
@ -90,19 +84,19 @@ fun BundleItem(
} }
} else null, } else null,
headlineContent = { Text(name) }, headlineContent = { Text(src.name) },
supportingContent = { supportingContent = {
if (state is PatchBundleSource.State.Loaded) { if (src.state is PatchBundleSource.State.Available) {
Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount)) Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount))
} }
}, },
trailingContent = { trailingContent = {
Row { Row {
val icon = remember(state) { val icon = remember(src.state) {
when (state) { when (src.state) {
is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.bundle_error is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.patches_error
is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.bundle_missing is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.patches_missing
is PatchBundleSource.State.Loaded -> null is PatchBundleSource.State.Available -> null
} }
} }
@ -115,7 +109,7 @@ fun BundleItem(
) )
} }
version?.let { Text(text = it) } src.version?.let { Text(text = it) }
} }
}, },
) )

View File

@ -12,6 +12,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -25,20 +26,26 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.ArrowButton import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.component.FullscreenDialog import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import kotlinx.coroutines.flow.mapNotNull
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BundlePatchesDialog( fun BundlePatchesDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
bundle: PatchBundleSource, src: PatchBundleSource,
) { ) {
var showAllVersions by rememberSaveable { mutableStateOf(false) } var showAllVersions by rememberSaveable { mutableStateOf(false) }
var showOptions by rememberSaveable { mutableStateOf(false) } var showOptions by rememberSaveable { mutableStateOf(false) }
val state by bundle.state.collectAsStateWithLifecycle() val patchBundleRepository: PatchBundleRepository = koinInject()
val patches by remember(src.uid) {
patchBundleRepository.bundleInfoFlow.mapNotNull { it[src.uid]?.patches }
}.collectAsStateWithLifecycle(emptyList())
FullscreenDialog( FullscreenDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -46,7 +53,7 @@ fun BundlePatchesDialog(
Scaffold( Scaffold(
topBar = { topBar = {
BundleTopBar( BundleTopBar(
title = stringResource(R.string.bundle_patches), title = stringResource(R.string.patches),
onBackClick = onDismissRequest, onBackClick = onDismissRequest,
backIcon = { backIcon = {
Icon( Icon(
@ -64,8 +71,7 @@ fun BundlePatchesDialog(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(16.dp) contentPadding = PaddingValues(16.dp)
) { ) {
state.patchBundleOrNull()?.let { bundle -> items(patches) { patch ->
items(bundle.patches) { patch ->
PatchItem( PatchItem(
patch, patch,
showAllVersions, showAllVersions,
@ -78,7 +84,6 @@ fun BundlePatchesDialog(
} }
} }
} }
}
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@ -133,10 +138,10 @@ fun PatchItem(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
PatchInfoChip( PatchInfoChip(
text = "$PACKAGE_ICON ${stringResource(R.string.bundle_view_patches_any_package)}" text = "$PACKAGE_ICON ${stringResource(R.string.patches_view_any_package)}"
) )
PatchInfoChip( PatchInfoChip(
text = "$VERSION_ICON ${stringResource(R.string.bundle_view_patches_any_version)}" text = "$VERSION_ICON ${stringResource(R.string.patches_view_any_version)}"
) )
} }
} else { } else {

View File

@ -12,26 +12,23 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import kotlinx.coroutines.flow.map
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSource?) -> Unit) { fun BundleSelector(sources: List<PatchBundleSource>, onFinish: (PatchBundleSource?) -> Unit) {
LaunchedEffect(bundles) { LaunchedEffect(sources) {
if (bundles.size == 1) { if (sources.size == 1) {
onFinish(bundles[0]) onFinish(sources[0])
} }
} }
if (bundles.size < 2) { if (sources.size < 2) {
return return
} }
@ -50,15 +47,12 @@ fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSourc
.fillMaxWidth() .fillMaxWidth()
) { ) {
Text( Text(
text = "Select bundle", text = stringResource(R.string.select),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} }
bundles.forEach { sources.forEach {
val name by it.nameState
val version by it.versionFlow.collectAsStateWithLifecycle(null)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
@ -70,7 +64,7 @@ fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSourc
} }
) { ) {
Text( Text(
"$name $version", "${it.name} ${it.version}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )

View File

@ -23,10 +23,14 @@ import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.TextHorizontalPadding import app.revanced.manager.ui.component.TextHorizontalPadding
import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.haptics.HapticRadioButton import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.ui.model.BundleType
import app.revanced.manager.util.BIN_MIMETYPE import app.revanced.manager.util.BIN_MIMETYPE
import app.revanced.manager.util.transparentListItemColors import app.revanced.manager.util.transparentListItemColors
private enum class BundleType {
Local,
Remote
}
@Composable @Composable
fun ImportPatchBundleDialog( fun ImportPatchBundleDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
@ -37,7 +41,7 @@ fun ImportPatchBundleDialog(
var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) } var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) }
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) } var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
var remoteUrl by rememberSaveable { mutableStateOf("") } var remoteUrl by rememberSaveable { mutableStateOf("") }
var autoUpdate by rememberSaveable { mutableStateOf(false) } var autoUpdate by rememberSaveable { mutableStateOf(true) }
val patchActivityLauncher = val patchActivityLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
@ -77,7 +81,7 @@ fun ImportPatchBundleDialog(
AlertDialogExtended( AlertDialogExtended(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = {
Text(stringResource(if (currentStep == 0) R.string.select else R.string.add_patch_bundle)) Text(stringResource(if (currentStep == 0) R.string.select else R.string.add_patches))
}, },
text = { text = {
steps[currentStep]() steps[currentStep]()
@ -117,7 +121,7 @@ fun ImportPatchBundleDialog(
} }
@Composable @Composable
fun SelectBundleTypeStep( private fun SelectBundleTypeStep(
bundleType: BundleType, bundleType: BundleType,
onBundleTypeSelected: (BundleType) -> Unit onBundleTypeSelected: (BundleType) -> Unit
) { ) {
@ -126,7 +130,7 @@ fun SelectBundleTypeStep(
) { ) {
Text( Text(
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier.padding(horizontal = 24.dp),
text = stringResource(R.string.select_bundle_type_dialog_description) text = stringResource(R.string.select_patches_type_dialog_description)
) )
Column { Column {
ListItem( ListItem(
@ -136,7 +140,7 @@ fun SelectBundleTypeStep(
), ),
headlineContent = { Text(stringResource(R.string.enter_url)) }, headlineContent = { Text(stringResource(R.string.enter_url)) },
overlineContent = { Text(stringResource(R.string.recommended)) }, overlineContent = { Text(stringResource(R.string.recommended)) },
supportingContent = { Text(stringResource(R.string.remote_bundle_description)) }, supportingContent = { Text(stringResource(R.string.remote_patches_description)) },
leadingContent = { leadingContent = {
HapticRadioButton( HapticRadioButton(
selected = bundleType == BundleType.Remote, selected = bundleType == BundleType.Remote,
@ -152,7 +156,7 @@ fun SelectBundleTypeStep(
onClick = { onBundleTypeSelected(BundleType.Local) } onClick = { onBundleTypeSelected(BundleType.Local) }
), ),
headlineContent = { Text(stringResource(R.string.select_from_storage)) }, headlineContent = { Text(stringResource(R.string.select_from_storage)) },
supportingContent = { Text(stringResource(R.string.local_bundle_description)) }, supportingContent = { Text(stringResource(R.string.local_patches_description)) },
overlineContent = { }, overlineContent = { },
leadingContent = { leadingContent = {
HapticRadioButton( HapticRadioButton(
@ -168,7 +172,7 @@ fun SelectBundleTypeStep(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ImportBundleStep( private fun ImportBundleStep(
bundleType: BundleType, bundleType: BundleType,
patchBundle: Uri?, patchBundle: Uri?,
remoteUrl: String, remoteUrl: String,
@ -185,7 +189,7 @@ fun ImportBundleStep(
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text(stringResource(R.string.patch_bundle_field)) Text(stringResource(R.string.patches))
}, },
supportingContent = { Text(stringResource(if (patchBundle != null) R.string.file_field_set else R.string.file_field_not_set)) }, supportingContent = { Text(stringResource(if (patchBundle != null) R.string.file_field_set else R.string.file_field_not_set)) },
trailingContent = { trailingContent = {
@ -206,11 +210,11 @@ fun ImportBundleStep(
OutlinedTextField( OutlinedTextField(
value = remoteUrl, value = remoteUrl,
onValueChange = onRemoteUrlChange, onValueChange = onRemoteUrlChange,
label = { Text(stringResource(R.string.bundle_url)) } label = { Text(stringResource(R.string.patches_url)) }
) )
} }
Column( Column(
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 8.dp, vertical = 5.dp)
) { ) {
ListItem( ListItem(
modifier = Modifier.clickable( modifier = Modifier.clickable(

View File

@ -9,6 +9,7 @@ import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
@Composable @Composable
fun HapticSwitch( fun HapticSwitch(
@ -20,16 +21,19 @@ fun HapticSwitch(
colors: SwitchColors = SwitchDefaults.colors(), colors: SwitchColors = SwitchDefaults.colors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
val view = LocalView.current
Switch( Switch(
checked = checked, checked = checked,
onCheckedChange = { newChecked -> onCheckedChange = { newChecked ->
val useNewConstants = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE val useNewConstants = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
when { val hapticFeedbackType = when {
newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_ON newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_ON
newChecked -> HapticFeedbackConstants.VIRTUAL_KEY newChecked -> HapticFeedbackConstants.VIRTUAL_KEY
!newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_OFF !newChecked && useNewConstants -> HapticFeedbackConstants.TOGGLE_OFF
!newChecked -> HapticFeedbackConstants.CLOCK_TICK !newChecked -> HapticFeedbackConstants.CLOCK_TICK
else -> {HapticFeedbackConstants.VIRTUAL_KEY}
} }
view.performHapticFeedback(hapticFeedbackType)
onCheckedChange(newChecked) onCheckedChange(newChecked)
}, },
modifier = modifier, modifier = modifier,

View File

@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -74,13 +73,11 @@ import app.revanced.manager.util.saver.snapshotStateListSaver
import app.revanced.manager.util.saver.snapshotStateSetSaver import app.revanced.manager.util.saver.snapshotStateSetSaver
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import app.revanced.manager.util.transparentListItemColors import app.revanced.manager.util.transparentListItemColors
import kotlinx.coroutines.CoroutineScope
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyColumnState
import sh.calvin.reorderable.rememberReorderableLazyListState import sh.calvin.reorderable.rememberReorderableLazyListState
import java.io.Serializable import java.io.Serializable
import kotlin.random.Random import kotlin.random.Random
@ -91,15 +88,28 @@ private class OptionEditorScope<T : Any>(
val option: Option<T>, val option: Option<T>,
val openDialog: () -> Unit, val openDialog: () -> Unit,
val dismissDialog: () -> Unit, val dismissDialog: () -> Unit,
val selectionWarningEnabled: Boolean,
val showSelectionWarning: () -> Unit,
val value: T?, val value: T?,
val setValue: (T?) -> Unit, val setValue: (T?) -> Unit
) { ) {
fun submitDialog(value: T?) { fun submitDialog(value: T?) {
setValue(value) setValue(value)
dismissDialog() dismissDialog()
} }
fun clickAction() = editor.clickAction(this) fun checkSafeguard(block: () -> Unit) {
if (!option.required && selectionWarningEnabled)
showSelectionWarning()
else
block()
}
fun clickAction() {
checkSafeguard {
editor.clickAction(this)
}
}
@Composable @Composable
fun ListItemTrailingContent() = editor.ListItemTrailingContent(this) fun ListItemTrailingContent() = editor.ListItemTrailingContent(this)
@ -113,7 +123,7 @@ private interface OptionEditor<T : Any> {
@Composable @Composable
fun ListItemTrailingContent(scope: OptionEditorScope<T>) { fun ListItemTrailingContent(scope: OptionEditorScope<T>) {
IconButton(onClick = { clickAction(scope) }) { IconButton(onClick = { scope.checkSafeguard { clickAction(scope) } }) {
Icon(Icons.Outlined.Edit, stringResource(R.string.edit)) Icon(Icons.Outlined.Edit, stringResource(R.string.edit))
} }
} }
@ -141,11 +151,14 @@ private inline fun <T : Any> WithOptionEditor(
option: Option<T>, option: Option<T>,
value: T?, value: T?,
noinline setValue: (T?) -> Unit, noinline setValue: (T?) -> Unit,
selectionWarningEnabled: Boolean,
crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {}, crossinline onDismissDialog: @DisallowComposableCalls () -> Unit = {},
block: OptionEditorScope<T>.() -> Unit block: OptionEditorScope<T>.() -> Unit
) { ) {
var showDialog by rememberSaveable { mutableStateOf(false) } var showDialog by rememberSaveable { mutableStateOf(false) }
val scope = remember(editor, option, value, setValue) { var showSelectionWarningDialog by rememberSaveable { mutableStateOf(false) }
val scope = remember(editor, option, value, setValue, selectionWarningEnabled) {
OptionEditorScope( OptionEditorScope(
editor, editor,
option, option,
@ -154,11 +167,18 @@ private inline fun <T : Any> WithOptionEditor(
showDialog = false showDialog = false
onDismissDialog() onDismissDialog()
}, },
selectionWarningEnabled,
showSelectionWarning = { showSelectionWarningDialog = true },
value, value,
setValue setValue
) )
} }
if (showSelectionWarningDialog)
SelectionWarningDialog(
onDismiss = { showSelectionWarningDialog = false }
)
if (showDialog) scope.Dialog() if (showDialog) scope.Dialog()
scope.block() scope.block()
@ -169,6 +189,7 @@ fun <T : Any> OptionItem(
option: Option<T>, option: Option<T>,
value: T?, value: T?,
setValue: (T?) -> Unit, setValue: (T?) -> Unit,
selectionWarningEnabled: Boolean
) { ) {
val editor = remember(option.type, option.presets) { val editor = remember(option.type, option.presets) {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@ -181,7 +202,7 @@ fun <T : Any> OptionItem(
else baseOptionEditor else baseOptionEditor
} }
WithOptionEditor(editor, option, value, setValue) { WithOptionEditor(editor, option, value, setValue, selectionWarningEnabled) {
ListItem( ListItem(
modifier = Modifier.clickable(onClick = ::clickAction), modifier = Modifier.clickable(onClick = ::clickAction),
headlineContent = { Text(option.title) }, headlineContent = { Text(option.title) },
@ -300,7 +321,7 @@ private object StringOptionEditor : OptionEditor<String> {
private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> { private abstract class NumberOptionEditor<T : Number> : OptionEditor<T> {
@Composable @Composable
protected abstract fun NumberDialog( abstract fun NumberDialog(
title: String, title: String,
current: T?, current: T?,
validator: (T?) -> Boolean, validator: (T?) -> Boolean,
@ -354,7 +375,14 @@ private object BooleanOptionEditor : OptionEditor<Boolean> {
@Composable @Composable
override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) { override fun ListItemTrailingContent(scope: OptionEditorScope<Boolean>) {
HapticSwitch(checked = scope.current, onCheckedChange = scope.setValue) HapticSwitch(
checked = scope.current,
onCheckedChange = { value ->
scope.checkSafeguard {
scope.setValue(value)
}
}
)
} }
@Composable @Composable
@ -393,6 +421,7 @@ private class PresetOptionEditor<T : Any>(private val innerEditor: OptionEditor<
scope.option, scope.option,
scope.value, scope.value,
scope.setValue, scope.setValue,
scope.selectionWarningEnabled,
onDismissDialog = scope.dismissDialog onDismissDialog = scope.dismissDialog
) inner@{ ) inner@{
var hidePresetsDialog by rememberSaveable { var hidePresetsDialog by rememberSaveable {
@ -614,7 +643,8 @@ private class ListOptionEditor<T : Serializable>(private val elementEditor: Opti
elementEditor, elementEditor,
elementOption, elementOption,
value = item.value, value = item.value,
setValue = { items[index] = item.copy(value = it) } setValue = { items[index] = item.copy(value = it) },
selectionWarningEnabled = scope.selectionWarningEnabled
) { ) {
ListItem( ListItem(
modifier = Modifier.combinedClickable( modifier = Modifier.combinedClickable(

View File

@ -0,0 +1,17 @@
package app.revanced.manager.ui.component.patches
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import app.revanced.manager.R
import app.revanced.manager.ui.component.SafeguardDialog
@Composable
fun SelectionWarningDialog(
onDismiss: () -> Unit
) {
SafeguardDialog(
onDismiss = onDismiss,
title = R.string.warning,
body = stringResource(R.string.selection_warning_description),
)
}

View File

@ -1,113 +0,0 @@
package app.revanced.manager.ui.model
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.flatMapLatestAndCombine
import kotlinx.coroutines.flow.map
/**
* A data class that contains patch bundle metadata for use by UI code.
*/
data class BundleInfo(
val name: String,
val version: String?,
val uid: Int,
val compatible: List<PatchInfo>,
val incompatible: List<PatchInfo>,
val universal: List<PatchInfo>
) {
val all = sequence {
yieldAll(compatible)
yieldAll(incompatible)
yieldAll(universal)
}
val patchCount get() = compatible.size + incompatible.size + universal.size
fun patchSequence(allowIncompatible: Boolean) = if (allowIncompatible) {
all
} else {
sequence {
yieldAll(compatible)
yieldAll(universal)
}
}
companion object Extensions {
inline fun Iterable<BundleInfo>.toPatchSelection(
allowIncompatible: Boolean,
condition: (Int, PatchInfo) -> Boolean
): PatchSelection = this.associate { bundle ->
val patches =
bundle.patchSequence(allowIncompatible)
.mapNotNullTo(mutableSetOf()) { patch ->
patch.name.takeIf {
condition(
bundle.uid,
patch
)
}
}
bundle.uid to patches
}
fun PatchBundleRepository.bundleInfoFlow(packageName: String, version: String?) =
sources.flatMapLatestAndCombine(
combiner = { it.filterNotNull() }
) { source ->
// Regenerate bundle information whenever this source updates.
source.state.map { state ->
val bundle = state.patchBundleOrNull() ?: return@map null
val compatible = mutableListOf<PatchInfo>()
val incompatible = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
bundle.patches.filter { it.compatibleWith(packageName) }.forEach {
val targetList = when {
it.compatiblePackages == null -> universal
it.supports(
packageName,
version
) -> compatible
else -> incompatible
}
targetList.add(it)
}
BundleInfo(source.getName(), bundle.readManifestAttribute("Version"), source.uid, compatible, incompatible, universal)
}
}
/**
* Algorithm for determining whether all required options have been set.
*/
inline fun Iterable<BundleInfo>.requiredOptionsSet(
crossinline isSelected: (BundleInfo, PatchInfo) -> Boolean,
crossinline optionsForPatch: (BundleInfo, PatchInfo) -> Map<String, Any?>?
) = all bundle@{ bundle ->
bundle
.all
.filter { isSelected(bundle, it) }
.all patch@{
if (it.options.isNullOrEmpty()) return@patch true
val opts by lazy { optionsForPatch(bundle, it).orEmpty() }
it.options.all option@{ option ->
if (!option.required || option.default != null) return@option true
option.key in opts
}
}
}
}
}
enum class BundleType {
Local,
Remote
}

View File

@ -3,59 +3,74 @@ package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import app.revanced.manager.domain.bundles.PatchBundleSource import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.bundle.BundleItem import app.revanced.manager.ui.component.bundle.BundleItem
import app.revanced.manager.ui.viewmodel.BundleListViewModel
import app.revanced.manager.util.EventEffect
import kotlinx.coroutines.flow.Flow
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BundleListScreen( fun BundleListScreen(
onDelete: (PatchBundleSource) -> Unit, viewModel: BundleListViewModel = koinViewModel(),
onUpdate: (PatchBundleSource) -> Unit, eventsFlow: Flow<BundleListViewModel.Event>,
sources: List<PatchBundleSource>, setSelectedSourceCount: (Int) -> Unit
selectedSources: SnapshotStateList<PatchBundleSource>,
bundlesSelectable: Boolean,
) { ) {
val sortedSources = remember(sources) { val patchCounts by viewModel.patchCounts.collectAsStateWithLifecycle(emptyMap())
sources.sortedByDescending { source -> val sources by viewModel.sources.collectAsStateWithLifecycle(emptyList())
source.state.value.patchBundleOrNull()?.patches?.size ?: 0
EventEffect(eventsFlow) {
viewModel.handleEvent(it)
} }
LaunchedEffect(viewModel.selectedSources.size) {
setSelectedSourceCount(viewModel.selectedSources.size)
} }
PullToRefreshBox(
onRefresh = viewModel::refresh,
isRefreshing = viewModel.isRefreshing
) {
LazyColumnWithScrollbar( LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
) { ) {
items( items(
sortedSources, sources,
key = { it.uid } key = { it.uid }
) { source -> ) { source ->
BundleItem( BundleItem(
bundle = source, src = source,
patchCount = patchCounts[source.uid] ?: 0,
onDelete = { onDelete = {
onDelete(source) viewModel.delete(source)
}, },
onUpdate = { onUpdate = {
onUpdate(source) viewModel.update(source)
}, },
selectable = bundlesSelectable, selectable = viewModel.selectedSources.size > 0,
onSelect = { onSelect = {
selectedSources.add(source) viewModel.selectedSources.add(source.uid)
}, },
isBundleSelected = selectedSources.contains(source), isBundleSelected = source.uid in viewModel.selectedSources,
toggleSelection = { bundleIsNotSelected -> toggleSelection = { bundleIsNotSelected ->
if (bundleIsNotSelected) { if (bundleIsNotSelected) {
selectedSources.add(source) viewModel.selectedSources.add(source.uid)
} else { } else {
selectedSources.remove(source) viewModel.selectedSources.remove(source.uid)
} }
} }
) )
} }
} }
} }
}

View File

@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -56,7 +57,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
@ -79,7 +79,7 @@ enum class DashboardPage(
val icon: ImageVector val icon: ImageVector
) { ) {
DASHBOARD(R.string.tab_apps, Icons.Outlined.Apps), DASHBOARD(R.string.tab_apps, Icons.Outlined.Apps),
BUNDLES(R.string.tab_bundles, Icons.Outlined.Source), BUNDLES(R.string.tab_patches, Icons.Outlined.Source),
} }
@SuppressLint("BatteryLife") @SuppressLint("BatteryLife")
@ -93,7 +93,8 @@ fun DashboardScreen(
onDownloaderPluginClick: () -> Unit, onDownloaderPluginClick: () -> Unit,
onAppClick: (String) -> Unit onAppClick: (String) -> Unit
) { ) {
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } } var selectedSourceCount by rememberSaveable { mutableIntStateOf(0) }
val bundlesSelectable by remember { derivedStateOf { selectedSourceCount > 0 } }
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0) val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle( val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle(
false false
@ -160,12 +161,9 @@ fun DashboardScreen(
if (showDeleteConfirmationDialog) { if (showDeleteConfirmationDialog) {
ConfirmDialog( ConfirmDialog(
onDismiss = { showDeleteConfirmationDialog = false }, onDismiss = { showDeleteConfirmationDialog = false },
onConfirm = { onConfirm = vm::deleteSources,
vm.selectedSources.forEach { if (!it.isDefault) vm.delete(it) } title = stringResource(R.string.delete),
vm.cancelSourceSelection() description = stringResource(R.string.patches_delete_multiple_dialog_description),
},
title = stringResource(R.string.bundle_delete_multiple_dialog_title),
description = stringResource(R.string.bundle_delete_multiple_dialog_description),
icon = Icons.Outlined.Delete icon = Icons.Outlined.Delete
) )
} }
@ -174,7 +172,7 @@ fun DashboardScreen(
topBar = { topBar = {
if (bundlesSelectable) { if (bundlesSelectable) {
BundleTopBar( BundleTopBar(
title = stringResource(R.string.bundles_selected, vm.selectedSources.size), title = stringResource(R.string.patches_selected, selectedSourceCount),
onBackClick = vm::cancelSourceSelection, onBackClick = vm::cancelSourceSelection,
backIcon = { backIcon = {
Icon( Icon(
@ -194,10 +192,7 @@ fun DashboardScreen(
) )
} }
IconButton( IconButton(
onClick = { onClick = vm::updateSources
vm.selectedSources.forEach { vm.update(it) }
vm.cancelSourceSelection()
}
) { ) {
Icon( Icon(
Icons.Outlined.Refresh, Icons.Outlined.Refresh,
@ -239,7 +234,7 @@ fun DashboardScreen(
when (pagerState.currentPage) { when (pagerState.currentPage) {
DashboardPage.DASHBOARD.ordinal -> { DashboardPage.DASHBOARD.ordinal -> {
if (availablePatches < 1) { if (availablePatches < 1) {
androidContext.toast(androidContext.getString(R.string.patches_unavailable)) androidContext.toast(androidContext.getString(R.string.no_patch_found))
composableScope.launch { composableScope.launch {
pagerState.animateScrollToPage( pagerState.animateScrollToPage(
DashboardPage.BUNDLES.ordinal DashboardPage.BUNDLES.ordinal
@ -349,18 +344,9 @@ fun DashboardScreen(
} }
} }
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
BundleListScreen( BundleListScreen(
onDelete = { eventsFlow = vm.bundleListEventsFlow,
vm.delete(it) setSelectedSourceCount = { selectedSourceCount = it }
},
onUpdate = {
vm.update(it)
},
sources = sources,
selectedSources = vm.selectedSources,
bundlesSelectable = bundlesSelectable
) )
} }
} }

View File

@ -73,6 +73,7 @@ import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.patches.OptionItem import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.component.patches.SelectionWarningDialog
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_INCOMPATIBLE import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_INCOMPATIBLE
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel.Companion.SHOW_UNIVERSAL
@ -181,7 +182,8 @@ fun PatchesSelectorScreen(
patch = patch, patch = patch,
values = viewModel.getOptions(bundle, patch), values = viewModel.getOptions(bundle, patch),
reset = { viewModel.resetOptions(bundle, patch) }, reset = { viewModel.resetOptions(bundle, patch) },
set = { key, value -> viewModel.setOption(bundle, patch, key, value) } set = { key, value -> viewModel.setOption(bundle, patch, key, value) },
selectionWarningEnabled = viewModel.selectionWarningEnabled
) )
} }
@ -215,9 +217,7 @@ fun PatchesSelectorScreen(
) { patch -> ) { patch ->
PatchItem( PatchItem(
patch = patch, patch = patch,
onOptionsDialog = { onOptionsDialog = { viewModel.optionsDialog = uid to patch },
viewModel.optionsDialog = uid to patch
},
selected = compatible && viewModel.isSelected( selected = compatible && viewModel.isSelected(
uid, uid,
patch patch
@ -389,6 +389,7 @@ fun PatchesSelectorScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.padding(top = 16.dp)
) { ) {
if (bundles.size > 1) { if (bundles.size > 1) {
ScrollableTabRow( ScrollableTabRow(
@ -412,7 +413,7 @@ fun PatchesSelectorScreen(
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
Text( Text(
text = bundle.version!!, text = bundle.version.orEmpty(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -471,17 +472,6 @@ fun PatchesSelectorScreen(
} }
} }
@Composable
private fun SelectionWarningDialog(
onDismiss: () -> Unit
) {
SafeguardDialog(
onDismiss = onDismiss,
title = R.string.warning,
body = stringResource(R.string.selection_warning_description),
)
}
@Composable @Composable
private fun UniversalPatchWarningDialog( private fun UniversalPatchWarningDialog(
onDismiss: () -> Unit onDismiss: () -> Unit
@ -611,6 +601,7 @@ private fun OptionsDialog(
reset: () -> Unit, reset: () -> Unit,
set: (String, Any?) -> Unit, set: (String, Any?) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
selectionWarningEnabled: Boolean
) = FullscreenDialog(onDismissRequest = onDismissRequest) { ) = FullscreenDialog(onDismissRequest = onDismissRequest) {
Scaffold( Scaffold(
topBar = { topBar = {
@ -641,7 +632,8 @@ private fun OptionsDialog(
value = value, value = value,
setValue = { setValue = {
set(key, it) set(key, it)
} },
selectionWarningEnabled = selectionWarningEnabled
) )
} }
} }

View File

@ -30,12 +30,12 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.patcher.patch.Option import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.patches.OptionItem import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
@ -62,6 +62,7 @@ fun RequiredOptionsScreen(
val showContinueButton by remember { val showContinueButton by remember {
derivedStateOf { derivedStateOf {
bundles.requiredOptionsSet( bundles.requiredOptionsSet(
allowIncompatible = vm.allowIncompatiblePatches,
isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) }, isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) },
optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) } optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) }
) )
@ -153,7 +154,8 @@ fun RequiredOptionsScreen(
value = value, value = value,
setValue = { new -> setValue = { new ->
vm.setOption(bundle.uid, it, key, new) vm.setOption(bundle.uid, it, key, new)
} },
selectionWarningEnabled = vm.selectionWarningEnabled
) )
} }
} }

View File

@ -48,13 +48,13 @@ fun DeveloperSettingsScreen(
description = R.string.developer_options_description, description = R.string.developer_options_description,
) )
GroupHeader(stringResource(R.string.patch_bundles_section)) GroupHeader(stringResource(R.string.patches))
SettingsListItem( SettingsListItem(
headlineContent = stringResource(R.string.patch_bundles_force_download), headlineContent = stringResource(R.string.patches_force_download),
modifier = Modifier.clickable(onClick = vm::redownloadBundles) modifier = Modifier.clickable(onClick = vm::redownloadBundles)
) )
SettingsListItem( SettingsListItem(
headlineContent = stringResource(R.string.patch_bundles_reset), headlineContent = stringResource(R.string.patches_reset),
modifier = Modifier.clickable(onClick = vm::redownloadBundles) modifier = Modifier.clickable(onClick = vm::redownloadBundles)
) )
} }

View File

@ -2,7 +2,6 @@ package app.revanced.manager.ui.screen.settings
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -19,9 +18,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -29,13 +26,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.network.downloader.DownloaderPluginState import app.revanced.manager.network.downloader.DownloaderPluginState
@ -57,7 +52,6 @@ fun DownloadsSettingsScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
viewModel: DownloadsViewModel = koinViewModel() viewModel: DownloadsViewModel = koinViewModel()
) { ) {
val pullRefreshState = rememberPullToRefreshState()
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList()) val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle() val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@ -90,28 +84,13 @@ fun DownloadsSettingsScreen(
}, },
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues -> ) { paddingValues ->
Box( PullToRefreshBox(
contentAlignment = Alignment.TopCenter, onRefresh = viewModel::refreshPlugins,
modifier = Modifier
.padding(paddingValues)
.fillMaxWidth()
.zIndex(1f)
) {
PullToRefreshDefaults.Indicator(
state = pullRefreshState,
isRefreshing = viewModel.isRefreshingPlugins
)
}
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.pullToRefresh(
isRefreshing = viewModel.isRefreshingPlugins, isRefreshing = viewModel.isRefreshingPlugins,
state = pullRefreshState, modifier = Modifier.padding(paddingValues)
onRefresh = viewModel::refreshPlugins ) {
) LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize()
) { ) {
item { item {
GroupHeader(stringResource(R.string.downloader_plugins)) GroupHeader(stringResource(R.string.downloader_plugins))
@ -241,6 +220,7 @@ fun DownloadsSettingsScreen(
} }
} }
} }
}
@Composable @Composable
private fun TrustDialog( private fun TrustDialog(

View File

@ -227,12 +227,12 @@ fun ImportExportSettingsScreen(
GroupItem( GroupItem(
onClick = { onClick = {
selectorDialog = { selectorDialog = {
BundleSelector(bundles = patchBundles) { bundle -> BundleSelector(sources = patchBundles) { src ->
bundle?.also { src?.also {
coroutineScope.launch { coroutineScope.launch {
vm.resetDialogState = vm.resetDialogState =
ResetDialogState.PatchSelectionBundle(bundle.getName()) { ResetDialogState.PatchSelectionBundle(it.name) {
vm.resetSelectionForPatchBundle(bundle) vm.resetSelectionForPatchBundle(it)
} }
} }
} }
@ -240,8 +240,8 @@ fun ImportExportSettingsScreen(
} }
} }
}, },
headline = R.string.patch_selection_reset_bundle, headline = R.string.patch_selection_reset_patches,
description = R.string.patch_selection_reset_bundle_description description = R.string.patch_selection_reset_patches_description
) )
} }
} }
@ -283,12 +283,12 @@ fun ImportExportSettingsScreen(
GroupItem( GroupItem(
onClick = { onClick = {
selectorDialog = { selectorDialog = {
BundleSelector(bundles = patchBundles) { bundle -> BundleSelector(sources = patchBundles) { src ->
bundle?.also { src?.also {
coroutineScope.launch { coroutineScope.launch {
vm.resetDialogState = vm.resetDialogState =
ResetDialogState.PatchOptionBundle(bundle.getName()) { ResetDialogState.PatchOptionBundle(src.name) {
vm.resetOptionsForBundle(bundle) vm.resetOptionsForBundle(src)
} }
} }
} }
@ -296,8 +296,8 @@ fun ImportExportSettingsScreen(
} }
} }
}, },
headline = R.string.patch_options_reset_bundle, headline = R.string.patch_options_reset_patches,
description = R.string.patch_options_reset_bundle_description, description = R.string.patch_options_reset_patches_description,
) )
} }
} }

View File

@ -16,6 +16,7 @@ import androidx.compose.ui.res.stringResource
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.ColumnWithScrollbar import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.settings.BooleanItem import app.revanced.manager.ui.component.settings.BooleanItem
import app.revanced.manager.ui.component.settings.SettingsListItem import app.revanced.manager.ui.component.settings.SettingsListItem
import app.revanced.manager.ui.viewmodel.UpdatesSettingsViewModel import app.revanced.manager.ui.viewmodel.UpdatesSettingsViewModel
@ -50,6 +51,8 @@ fun UpdatesSettingsScreen(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
GroupHeader(stringResource(R.string.manager))
SettingsListItem( SettingsListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable {
coroutineScope.launch { coroutineScope.launch {

View File

@ -0,0 +1,76 @@
package app.revanced.manager.ui.viewmodel
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.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.util.mutableStateSetOf
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class BundleListViewModel : ViewModel(), KoinComponent {
private val patchBundleRepository: PatchBundleRepository = get()
val patchCounts = patchBundleRepository.patchCountsFlow
var isRefreshing by mutableStateOf(false)
private set
val sources = combine(
patchBundleRepository.sources,
patchBundleRepository.patchCountsFlow
) { sources, patchCounts ->
isRefreshing = false
sources.sortedByDescending { patchCounts[it.uid] ?: 0 }
}
val selectedSources = mutableStateSetOf<Int>()
fun refresh() = viewModelScope.launch {
isRefreshing = true
patchBundleRepository.reload()
}
private suspend fun getSelectedSources() = patchBundleRepository.sources
.first()
.filter { it.uid in selectedSources }
.also {
selectedSources.clear()
}
fun handleEvent(event: Event) {
when (event) {
Event.CANCEL -> selectedSources.clear()
Event.DELETE_SELECTED -> viewModelScope.launch {
patchBundleRepository.remove(*getSelectedSources().toTypedArray())
}
Event.UPDATE_SELECTED -> viewModelScope.launch {
patchBundleRepository.update(
*getSelectedSources().filterIsInstance<RemotePatchBundle>().toTypedArray(),
showToast = true,
)
}
}
}
fun delete(src: PatchBundleSource) =
viewModelScope.launch { patchBundleRepository.remove(src) }
fun update(src: PatchBundleSource) = viewModelScope.launch {
if (src !is RemotePatchBundle) return@launch
patchBundleRepository.update(src, showToast = true)
}
enum class Event {
DELETE_SELECTED,
UPDATE_SELECTED,
CANCEL,
}
}

View File

@ -1,5 +1,6 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.content.ContentResolver import android.content.ContentResolver
import android.net.Uri import android.net.Uri
@ -24,8 +25,10 @@ import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class DashboardViewModel( class DashboardViewModel(
@ -38,13 +41,12 @@ class DashboardViewModel(
private val pm: PM, private val pm: PM,
) : ViewModel() { ) : ViewModel() {
val availablePatches = val availablePatches =
patchBundleRepository.bundles.map { it.values.sumOf { bundle -> bundle.patches.size } } patchBundleRepository.bundleInfoFlow.map { it.values.sumOf { bundle -> bundle.patches.size } }
private val contentResolver: ContentResolver = app.contentResolver private val contentResolver: ContentResolver = app.contentResolver
private val powerManager = app.getSystemService<PowerManager>()!! private val powerManager = app.getSystemService<PowerManager>()!!
val sources = patchBundleRepository.sources
val selectedSources = mutableStateListOf<PatchBundleSource>()
val newDownloaderPluginsAvailable = downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() } val newDownloaderPluginsAvailable =
downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() }
/** /**
* Android 11 kills the app process after granting the "install apps" permission, which is a problem for the patcher screen. * Android 11 kills the app process after granting the "install apps" permission, which is a problem for the patcher screen.
@ -59,6 +61,9 @@ class DashboardViewModel(
var showBatteryOptimizationsWarning by mutableStateOf(false) var showBatteryOptimizationsWarning by mutableStateOf(false)
private set private set
private val bundleListEventsChannel = Channel<BundleListViewModel.Event>()
val bundleListEventsFlow = bundleListEventsChannel.receiveAsFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
checkForManagerUpdates() checkForManagerUpdates()
@ -70,10 +75,6 @@ class DashboardViewModel(
downloaderPluginRepository.acknowledgeAllNewPlugins() downloaderPluginRepository.acknowledgeAllNewPlugins()
} }
fun dismissUpdateDialog() {
updatedManagerVersion = null
}
private suspend fun checkForManagerUpdates() { private suspend fun checkForManagerUpdates() {
if (!prefs.managerAutoUpdates.get() || !networkInfo.isConnected()) return if (!prefs.managerAutoUpdates.get() || !networkInfo.isConnected()) return
@ -83,7 +84,8 @@ class DashboardViewModel(
} }
fun updateBatteryOptimizationsWarning() { fun updateBatteryOptimizationsWarning() {
showBatteryOptimizationsWarning = !powerManager.isIgnoringBatteryOptimizations(app.packageName) showBatteryOptimizationsWarning =
!powerManager.isIgnoringBatteryOptimizations(app.packageName)
} }
fun setShowManagerUpdateDialogOnLaunch(value: Boolean) { fun setShowManagerUpdateDialogOnLaunch(value: Boolean) {
@ -112,36 +114,20 @@ class DashboardViewModel(
} }
} }
private fun sendEvent(event: BundleListViewModel.Event) {
fun cancelSourceSelection() { viewModelScope.launch { bundleListEventsChannel.send(event) }
selectedSources.clear()
} }
fun createLocalSource(patchBundle: Uri) = fun cancelSourceSelection() = sendEvent(BundleListViewModel.Event.CANCEL)
viewModelScope.launch { fun updateSources() = sendEvent(BundleListViewModel.Event.UPDATE_SELECTED)
contentResolver.openInputStream(patchBundle)!!.use { patchesStream -> fun deleteSources() = sendEvent(BundleListViewModel.Event.DELETE_SELECTED)
patchBundleRepository.createLocal(patchesStream)
} @SuppressLint("Recycle")
fun createLocalSource(patchBundle: Uri) = viewModelScope.launch {
patchBundleRepository.createLocal { contentResolver.openInputStream(patchBundle)!! }
} }
fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch {
viewModelScope.launch { patchBundleRepository.createRemote(apiUrl, autoUpdate) } patchBundleRepository.createRemote(apiUrl, autoUpdate)
fun delete(bundle: PatchBundleSource) =
viewModelScope.launch { patchBundleRepository.remove(bundle) }
fun update(bundle: PatchBundleSource) = viewModelScope.launch {
if (bundle !is RemotePatchBundle) return@launch
uiSafe(
app,
R.string.source_download_fail,
RemotePatchBundle.updateFailMsg
) {
if (bundle.update())
app.toast(app.getString(R.string.bundle_update_success, bundle.getName()))
else
app.toast(app.getString(R.string.bundle_update_unavailable, bundle.getName()))
}
} }
} }

View File

@ -16,7 +16,7 @@ class DeveloperOptionsViewModel(
private val patchBundleRepository: PatchBundleRepository private val patchBundleRepository: PatchBundleRepository
) : ViewModel() { ) : ViewModel() {
fun redownloadBundles() = viewModelScope.launch { fun redownloadBundles() = viewModelScope.launch {
uiSafe(app, R.string.source_download_fail, RemotePatchBundle.updateFailMsg) { uiSafe(app, R.string.patches_download_fail, RemotePatchBundle.updateFailMsg) {
patchBundleRepository.redownloadRemoteBundles() patchBundleRepository.redownloadRemoteBundles()
} }
} }

View File

@ -61,8 +61,8 @@ sealed class ResetDialogState(
) )
class PatchSelectionBundle(dialogOptionName: String, onConfirm: () -> Unit) : ResetDialogState( class PatchSelectionBundle(dialogOptionName: String, onConfirm: () -> Unit) : ResetDialogState(
titleResId = R.string.patch_selection_reset_bundle, titleResId = R.string.patch_selection_reset_patches,
descriptionResId = R.string.patch_selection_reset_bundle_dialog_description, descriptionResId = R.string.patch_selection_reset_patches_dialog_description,
onConfirm = onConfirm, onConfirm = onConfirm,
dialogOptionName = dialogOptionName dialogOptionName = dialogOptionName
) )
@ -81,8 +81,8 @@ sealed class ResetDialogState(
) )
class PatchOptionBundle(dialogOptionName: String, onConfirm: () -> Unit) : ResetDialogState( class PatchOptionBundle(dialogOptionName: String, onConfirm: () -> Unit) : ResetDialogState(
titleResId = R.string.patch_options_reset_bundle, titleResId = R.string.patch_options_reset_patches,
descriptionResId = R.string.patch_options_reset_bundle_dialog_description, descriptionResId = R.string.patch_options_reset_patches_dialog_description,
onConfirm = onConfirm, onConfirm = onConfirm,
dialogOptionName = dialogOptionName dialogOptionName = dialogOptionName
) )

View File

@ -17,10 +17,9 @@ import androidx.lifecycle.viewmodel.compose.saveable
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.model.BundleInfo
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
import app.revanced.manager.util.Options import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection import app.revanced.manager.util.PatchSelection
@ -63,7 +62,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
val allowIncompatiblePatches = val allowIncompatiblePatches =
get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking() get<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking()
val bundlesFlow = val bundlesFlow =
get<PatchBundleRepository>().bundleInfoFlow(packageName, input.app.version) get<PatchBundleRepository>().scopedBundleInfoFlow(packageName, input.app.version)
init { init {
viewModelScope.launch { viewModelScope.launch {
@ -76,11 +75,11 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
return@launch return@launch
} }
fun BundleInfo.hasDefaultPatches() = fun PatchBundleInfo.Scoped.hasDefaultPatches() =
patchSequence(allowIncompatiblePatches).any { it.include } patchSequence(allowIncompatiblePatches).any { it.include }
// Don't show the warning if there are no default patches. // Don't show the warning if there are no default patches.
selectionWarningEnabled = bundlesFlow.first().any(BundleInfo::hasDefaultPatches) selectionWarningEnabled = bundlesFlow.first().any(PatchBundleInfo.Scoped::hasDefaultPatches)
} }
} }
@ -123,7 +122,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
// This is for the required options screen. // This is for the required options screen.
private val requiredOptsPatchesDeferred = viewModelScope.async(start = CoroutineStart.LAZY) { private val requiredOptsPatchesDeferred = viewModelScope.async(start = CoroutineStart.LAZY) {
bundlesFlow.first().map { bundle -> bundlesFlow.first().map { bundle ->
bundle to bundle.all.filter { patch -> bundle to bundle.patchSequence(allowIncompatiblePatches).filter { patch ->
val opts by lazy { val opts by lazy {
getOptions(bundle.uid, patch).orEmpty() getOptions(bundle.uid, patch).orEmpty()
} }
@ -136,14 +135,14 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi
} }
val requiredOptsPatches = flow { emit(requiredOptsPatchesDeferred.await()) } val requiredOptsPatches = flow { emit(requiredOptsPatchesDeferred.await()) }
fun selectionIsValid(bundles: List<BundleInfo>) = bundles.any { bundle -> fun selectionIsValid(bundles: List<PatchBundleInfo.Scoped>) = bundles.any { bundle ->
bundle.patchSequence(allowIncompatiblePatches).any { patch -> bundle.patchSequence(allowIncompatiblePatches).any { patch ->
isSelected(bundle.uid, patch) isSelected(bundle.uid, patch)
} }
} }
fun isSelected(bundle: Int, patch: PatchInfo) = customPatchSelection?.let { selection -> fun isSelected(bundle: Int, patch: PatchInfo) = customPatchSelection?.let { selection ->
selection[bundle]?.contains(patch.name) ?: false selection[bundle]?.contains(patch.name) == true
} ?: patch.include } ?: patch.include
fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch { fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch {

View File

@ -28,15 +28,14 @@ import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.PatchOptionsRepository import app.revanced.manager.domain.repository.PatchOptionsRepository
import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection
import app.revanced.manager.network.downloader.LoadedDownloaderPlugin import app.revanced.manager.network.downloader.LoadedDownloaderPlugin
import app.revanced.manager.network.downloader.ParceledDownloaderData import app.revanced.manager.network.downloader.ParceledDownloaderData
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.plugin.downloader.GetScope import app.revanced.manager.plugin.downloader.GetScope
import app.revanced.manager.plugin.downloader.PluginHostApi import app.revanced.manager.plugin.downloader.PluginHostApi
import app.revanced.manager.plugin.downloader.UserInteractionException import app.revanced.manager.plugin.downloader.UserInteractionException
import app.revanced.manager.ui.model.BundleInfo
import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow
import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection
import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.SelectedApp
import app.revanced.manager.ui.model.navigation.Patcher import app.revanced.manager.ui.model.navigation.Patcher
import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo
@ -125,16 +124,19 @@ class SelectedAppInfoViewModel(
suggestedVersions[input.app.packageName] suggestedVersions[input.app.packageName]
} }
val bundleInfoFlow by derivedStateOf {
bundleRepository.scopedBundleInfoFlow(packageName, selectedApp.version)
}
var options: Options by savedStateHandle.saveable { var options: Options by savedStateHandle.saveable {
val state = mutableStateOf<Options>(emptyMap()) val state = mutableStateOf<Options>(emptyMap())
viewModelScope.launch { viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps. if (!persistConfiguration) return@launch // TODO: save options for patched apps.
val bundlePatches = bundleInfoFlow.first()
.associate { it.uid to it.patches.associateBy { patch -> patch.name } }
options = withContext(Dispatchers.Default) { options = withContext(Dispatchers.Default) {
val bundlePatches = bundleRepository.bundles.first()
.mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } }
optionsRepository.getOptions(packageName, bundlePatches) optionsRepository.getOptions(packageName, bundlePatches)
} }
} }
@ -176,10 +178,6 @@ class SelectedAppInfoViewModel(
} }
} }
val bundleInfoFlow by derivedStateOf {
bundleRepository.bundleInfoFlow(packageName, selectedApp.version)
}
fun showSourceSelector() { fun showSourceSelector() {
dismissSourceSelector() dismissSourceSelector()
showSourceSelector = true showSourceSelector = true
@ -266,9 +264,11 @@ class SelectedAppInfoViewModel(
selectedAppInfo = info selectedAppInfo = info
} }
fun getOptionsFiltered(bundles: List<PatchBundleInfo.Scoped>) = options.filtered(bundles)
suspend fun hasSetRequiredOptions(patchSelection: PatchSelection) = bundleInfoFlow suspend fun hasSetRequiredOptions(patchSelection: PatchSelection) = bundleInfoFlow
.first() .first()
.requiredOptionsSet( .requiredOptionsSet(
allowIncompatible = prefs.disablePatchVersionCompatCheck.get(),
isSelected = { bundle, patch -> patch.name in patchSelection[bundle.uid]!! }, isSelected = { bundle, patch -> patch.name in patchSelection[bundle.uid]!! },
optionsForPatch = { bundle, patch -> options[bundle.uid]?.get(patch.name) }, optionsForPatch = { bundle, patch -> options[bundle.uid]?.get(patch.name) },
) )
@ -283,23 +283,23 @@ class SelectedAppInfoViewModel(
) )
} }
fun getOptionsFiltered(bundles: List<BundleInfo>) = options.filtered(bundles) fun getPatches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
fun getPatches(bundles: List<BundleInfo>, allowIncompatible: Boolean) =
selectionState.patches(bundles, allowIncompatible) selectionState.patches(bundles, allowIncompatible)
fun getCustomPatches( fun getCustomPatches(
bundles: List<BundleInfo>, bundles: List<PatchBundleInfo.Scoped>,
allowIncompatible: Boolean allowIncompatible: Boolean
): PatchSelection? = ): PatchSelection? =
(selectionState as? SelectionState.Customized)?.patches(bundles, allowIncompatible) (selectionState as? SelectionState.Customized)?.patches(bundles, allowIncompatible)
fun updateConfiguration(selection: PatchSelection?, options: Options) = viewModelScope.launch {
val bundles = bundleInfoFlow.first()
fun updateConfiguration(
selection: PatchSelection?,
options: Options
) = viewModelScope.launch {
selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default
val filteredOptions = options.filtered(bundles) val filteredOptions = options.filtered(bundleInfoFlow.first())
this@SelectedAppInfoViewModel.options = filteredOptions this@SelectedAppInfoViewModel.options = filteredOptions
if (!persistConfiguration) return@launch if (!persistConfiguration) return@launch
@ -319,11 +319,12 @@ class SelectedAppInfoViewModel(
/** /**
* Returns a copy with all nonexistent options removed. * Returns a copy with all nonexistent options removed.
*/ */
private fun Options.filtered(bundles: List<BundleInfo>): Options = buildMap options@{ private fun Options.filtered(bundles: List<PatchBundleInfo.Scoped>): Options =
buildMap options@{
bundles.forEach bundles@{ bundle -> bundles.forEach bundles@{ bundle ->
val bundleOptions = this@filtered[bundle.uid] ?: return@bundles val bundleOptions = this@filtered[bundle.uid] ?: return@bundles
val patches = bundle.all.associateBy { it.name } val patches = bundle.patches.associateBy { it.name }
this@options[bundle.uid] = buildMap bundleOptions@{ this@options[bundle.uid] = buildMap bundleOptions@{
bundleOptions.forEach patch@{ (patchName, values) -> bundleOptions.forEach patch@{ (patchName, values) ->
@ -342,11 +343,11 @@ class SelectedAppInfoViewModel(
} }
private sealed interface SelectionState : Parcelable { private sealed interface SelectionState : Parcelable {
fun patches(bundles: List<BundleInfo>, allowIncompatible: Boolean): PatchSelection fun patches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean): PatchSelection
@Parcelize @Parcelize
data class Customized(val patchSelection: PatchSelection) : SelectionState { data class Customized(val patchSelection: PatchSelection) : SelectionState {
override fun patches(bundles: List<BundleInfo>, allowIncompatible: Boolean) = override fun patches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
bundles.toPatchSelection( bundles.toPatchSelection(
allowIncompatible allowIncompatible
) { uid, patch -> ) { uid, patch ->
@ -356,7 +357,7 @@ private sealed interface SelectionState : Parcelable {
@Parcelize @Parcelize
data object Default : SelectionState { data object Default : SelectionState {
override fun patches(bundles: List<BundleInfo>, allowIncompatible: Boolean) = override fun patches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
bundles.toPatchSelection(allowIncompatible) { _, patch -> patch.include } bundles.toPatchSelection(allowIncompatible) { _, patch -> patch.include }
} }
} }

View File

@ -44,10 +44,10 @@ class PM(
) { ) {
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
val appList = patchBundleRepository.bundles.map { bundles -> val appList = patchBundleRepository.bundleInfoFlow.map { bundles ->
val compatibleApps = scope.async { val compatibleApps = scope.async {
val compatiblePackages = bundles.values val compatiblePackages = bundles
.flatMap { it.patches } .flatMap { (_, bundle) -> bundle.patches }
.flatMap { it.compatiblePackages.orEmpty() } .flatMap { it.compatiblePackages.orEmpty() }
.groupingBy { it.packageName } .groupingBy { it.packageName }
.eachCount() .eachCount()

View File

@ -116,10 +116,10 @@ inline fun LifecycleOwner.launchAndRepeatWithViewLifecycle(
*/ */
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
inline fun <T, reified R, C> Flow<Iterable<T>>.flatMapLatestAndCombine( inline fun <T, reified R, C> Flow<Iterable<T>>.flatMapLatestAndCombine(
crossinline combiner: (Array<R>) -> C, crossinline combiner: suspend (Array<R>) -> C,
crossinline transformer: (T) -> Flow<R>, crossinline transformer: suspend (T) -> Flow<R>,
): Flow<C> = flatMapLatest { iterable -> ): Flow<C> = flatMapLatest { iterable ->
combine(iterable.map(transformer)) { combine(iterable.map { transformer(it) }) {
combiner(it) combiner(it)
} }
} }

View File

@ -14,26 +14,24 @@
<string name="dashboard">Dashboard</string> <string name="dashboard">Dashboard</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="select_app">Select an app</string> <string name="select_app">Select an app</string>
<string name="patches_selected">%1$d/%2$d selected</string> <string name="patches_count_selected">%1$d/%2$d selected</string>
<string name="new_downloader_plugins_notification">New downloader plugins available. Click here to configure them.</string> <string name="new_downloader_plugins_notification">New downloader plugins available. Click here to configure them.</string>
<string name="unsupported_architecture_warning">Patching on this device architecture is unsupported and will most likely fail.</string> <string name="unsupported_architecture_warning">Patching on this device architecture is unsupported and will most likely fail.</string>
<string name="import_">Import</string> <string name="import_">Import</string>
<string name="import_bundle">Import patch bundle</string> <string name="import_patches">Import patches</string>
<string name="bundle_patches">Bundle patches</string>
<string name="patch_bundle_field">Patch bundle</string>
<string name="file_field_set">Selected</string> <string name="file_field_set">Selected</string>
<string name="file_field_not_set">Not selected</string> <string name="file_field_not_set">Not selected</string>
<string name="field_not_set">Not set</string> <string name="field_not_set">Not set</string>
<string name="bundle_missing">Missing</string> <string name="patches_missing">Missing</string>
<string name="bundle_error">Error</string> <string name="patches_error">Error</string>
<string name="bundle_error_description">Bundle could not be loaded. Click to view the error</string> <string name="patches_error_description">Patches could not be loaded. Click to view the error</string>
<string name="bundle_not_downloaded">Bundle has not been downloaded. Click here to download it</string> <string name="patches_not_downloaded">Patches has not been downloaded. Click here to download it</string>
<string name="bundle_name_default">Default</string> <string name="patches_name_default">Patches</string>
<string name="bundle_name_fallback">Unnamed</string> <string name="patches_name_fallback">Unnamed</string>
<string name="android_11_bug_dialog_title">Android 11 bug</string> <string name="android_11_bug_dialog_title">Android 11 bug</string>
<string name="android_11_bug_dialog_description">The app installation permission must be granted ahead of time to avoid a bug in the Android 11 system that will negatively affect the user experience.</string> <string name="android_11_bug_dialog_description">The app installation permission must be granted ahead of time to avoid a bug in the Android 11 system that will negatively affect the user experience.</string>
@ -97,8 +95,8 @@
<string name="suggested_version_safeguard">Require suggested app version</string> <string name="suggested_version_safeguard">Require suggested app version</string>
<string name="suggested_version_safeguard_description">Enforce selection of the suggested app version</string> <string name="suggested_version_safeguard_description">Enforce selection of the suggested app version</string>
<string name="suggested_version_safeguard_confirmation">Selecting an app that is not the suggested version may cause unexpected issues.\n\nDo you want to proceed anyways?</string> <string name="suggested_version_safeguard_confirmation">Selecting an app that is not the suggested version may cause unexpected issues.\n\nDo you want to proceed anyways?</string>
<string name="patch_selection_safeguard">Allow changing patch selection</string> <string name="patch_selection_safeguard">Allow changing patch selection and options</string>
<string name="patch_selection_safeguard_description">Do not prevent selecting or deselecting patches</string> <string name="patch_selection_safeguard_description">Do not prevent selecting or deselecting patches and customization of options</string>
<string name="patch_selection_safeguard_confirmation">Changing the selection of patches may cause unexpected issues.\n\nEnable anyways?</string> <string name="patch_selection_safeguard_confirmation">Changing the selection of patches may cause unexpected issues.\n\nEnable anyways?</string>
<string name="universal_patches_safeguard">Allow using universal patches</string> <string name="universal_patches_safeguard">Allow using universal patches</string>
<string name="universal_patches_safeguard_description">Do not prevent using universal patches</string> <string name="universal_patches_safeguard_description">Do not prevent using universal patches</string>
@ -133,22 +131,22 @@
<string name="reset_patch_options">Reset patch options</string> <string name="reset_patch_options">Reset patch options</string>
<string name="reset_patch_options_description">Reset the stored patch options</string> <string name="reset_patch_options_description">Reset the stored patch options</string>
<string name="reset_patch_selection_success">Patch selection has been reset</string> <string name="reset_patch_selection_success">Patch selection has been reset</string>
<string name="patch_selection_reset_all">Reset all patch selection</string> <string name="patch_selection_reset_all">Reset patch selection globally</string>
<string name="patch_selection_reset_all_dialog_description">You are about to reset all the patch selections. You will need to manually select each patch again.</string> <string name="patch_selection_reset_all_dialog_description">You are about to reset all the patch selections. You will need to manually select each patch again.</string>
<string name="patch_selection_reset_all_description">Reset all the patch selections</string> <string name="patch_selection_reset_all_description">Resets all the patch selections</string>
<string name="patch_selection_reset_package">Reset patch selection for app</string> <string name="patch_selection_reset_package">Reset patch selection for app</string>
<string name="patch_selection_reset_package_dialog_description">You are about to reset the patch selection for the app \"%s\". You will have to manually select each patch again.</string> <string name="patch_selection_reset_package_dialog_description">You are about to reset the patch selection for the app \"%s\". You will have to manually select each patch again.</string>
<string name="patch_selection_reset_package_description">Resets patch selection for a single app</string> <string name="patch_selection_reset_package_description">Resets patch selection for a single app</string>
<string name="patch_selection_reset_bundle">Resets patch selection for bundle</string> <string name="patch_selection_reset_patches">Reset patch selection (single)</string>
<string name="patch_selection_reset_bundle_dialog_description">You are about to reset the patch selection for the bundle \"%s\". You will have to manually select each patch again.</string> <string name="patch_selection_reset_patches_dialog_description">You are about to reset the patch selection for \"%s\". You will have to manually select each patch again.</string>
<string name="patch_selection_reset_bundle_description">Resets the patch selection for all patches in a bundle</string> <string name="patch_selection_reset_patches_description">Resets the patch selection for a specific collection of patches</string>
<string name="patch_options_reset_package">Reset patch options for app</string> <string name="patch_options_reset_package">Reset patch options for app</string>
<string name="patch_options_reset_package_dialog_description">You are about to reset the patch options for the app \"%s\". You will have to reapply each option again.</string> <string name="patch_options_reset_package_dialog_description">You are about to reset the patch options for the app \"%s\". You will have to reapply each option again.</string>
<string name="patch_options_reset_package_description">Resets patch options for a single app</string> <string name="patch_options_reset_package_description">Resets patch options for a single app</string>
<string name="patch_options_reset_bundle">Resets patch options for bundle</string> <string name="patch_options_reset_patches">Reset patch options (single)</string>
<string name="patch_options_reset_bundle_dialog_description">You are about to reset the patch options for the bundle \"%s\". You will have to reapply each option again.</string> <string name="patch_options_reset_patches_dialog_description">You are about to reset the patch options for \"%s\". You will have to reapply each option again.</string>
<string name="patch_options_reset_bundle_description">Resets patch options for all patches in a bundle</string> <string name="patch_options_reset_patches_description">Resets the patch options for a specific collection of patches</string>
<string name="patch_options_reset_all">Reset patch options</string> <string name="patch_options_reset_all">Reset patch options globally</string>
<string name="patch_options_reset_all_dialog_description">You are about to reset patch options. You will have to reapply each option again.</string> <string name="patch_options_reset_all_dialog_description">You are about to reset patch options. You will have to reapply each option again.</string>
<string name="patch_options_reset_all_description">Resets all patch options</string> <string name="patch_options_reset_all_description">Resets all patch options</string>
<string name="downloader_plugins">Plugins</string> <string name="downloader_plugins">Plugins</string>
@ -164,7 +162,7 @@
<string name="search_apps">Search apps…</string> <string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string> <string name="loading_body">Loading…</string>
<string name="downloading_patches">Downloading patch bundle…</string> <string name="downloading_patches">Downloading patches</string>
<string name="options">Options</string> <string name="options">Options</string>
<string name="ok">OK</string> <string name="ok">OK</string>
@ -202,8 +200,8 @@
<string name="debug_logs_export_success">Exported logs</string> <string name="debug_logs_export_success">Exported logs</string>
<string name="api_url">API URL</string> <string name="api_url">API URL</string>
<string name="api_url_description">The API used to download necessary files.</string> <string name="api_url_description">The API used to download necessary files.</string>
<string name="api_url_dialog_title">Set custom API URL</string> <string name="api_url_dialog_title">Change API URL</string>
<string name="api_url_dialog_description">Set the API URL of ReVanced Manager. ReVanced Manager uses the API to download patches and updates.</string> <string name="api_url_dialog_description">Change the API URL of ReVanced Manager. ReVanced Manager uses the API to download patches and updates.</string>
<string name="api_url_dialog_warning">ReVanced Manager connects to the API to download patches and updates. Make sure that you trust it.</string> <string name="api_url_dialog_warning">ReVanced Manager connects to the API to download patches and updates. Make sure that you trust it.</string>
<string name="api_url_dialog_save">Set</string> <string name="api_url_dialog_save">Set</string>
<string name="api_url_dialog_reset">Reset API URL</string> <string name="api_url_dialog_reset">Reset API URL</string>
@ -213,26 +211,25 @@
<string name="device_architectures">CPU Architectures</string> <string name="device_architectures">CPU Architectures</string>
<string name="device_memory_limit">Memory limits</string> <string name="device_memory_limit">Memory limits</string>
<string name="device_memory_limit_format">%1$dMB (Normal) - %2$dMB (Large)</string> <string name="device_memory_limit_format">%1$dMB (Normal) - %2$dMB (Large)</string>
<string name="patch_bundles_section">Patch bundles</string> <string name="patches_force_download">Force download all patches</string>
<string name="patch_bundles_force_download">Force download all patch bundles</string> <string name="patches_reset">Reset patches</string>
<string name="patch_bundles_reset">Reset patch bundles</string>
<string name="patching">Patching</string> <string name="patching">Patching</string>
<string name="signing">Signing</string> <string name="signing">Signing</string>
<string name="storage">Storage</string> <string name="storage">Storage</string>
<string name="patches_unavailable">No patches are available. Check your bundles</string> <string name="no_patch_found">No patch can be found. Check your patches</string>
<string name="tab_apps">Apps</string> <string name="tab_apps">Apps</string>
<string name="tab_bundles">Patch bundles</string> <string name="tab_patches">Patches</string>
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="refresh">Refresh</string> <string name="refresh">Refresh</string>
<string name="continue_anyways">Continue anyways</string> <string name="continue_anyways">Continue anyways</string>
<string name="download_another_version">Download another version</string> <string name="download_another_version">Download another version</string>
<string name="download_app">Download app</string> <string name="download_app">Download app</string>
<string name="download_apk">Download APK file</string> <string name="download_apk">Download APK file</string>
<string name="source_download_fail">Failed to download patch bundle: %s</string> <string name="patches_download_fail">Failed to download patches: %s</string>
<string name="source_replace_fail">Failed to load updated patch bundle: %s</string> <string name="patches_replace_fail">Failed to import patches: %s</string>
<string name="no_patched_apps_found">No patched apps found</string> <string name="no_patched_apps_found">No patched apps found</string>
<string name="tap_on_patches">Tap on the patches to get more information about them</string> <string name="tap_on_patches">Tap on the patches to get more information about them</string>
<string name="bundles_selected">%s selected</string> <string name="patches_selected">%s selected</string>
<string name="incompatible_patches">Incompatible patches</string> <string name="incompatible_patches">Incompatible patches</string>
<string name="universal_patches">Universal patches</string> <string name="universal_patches">Universal patches</string>
<string name="patch_selection_reset_toast">Patch selection and options has been reset to recommended defaults</string> <string name="patch_selection_reset_toast">Patch selection and options has been reset to recommended defaults</string>
@ -317,7 +314,8 @@
<string name="patcher_step_group_saving">Saving</string> <string name="patcher_step_group_saving">Saving</string>
<string name="patcher_step_write_patched">Write patched APK file</string> <string name="patcher_step_write_patched">Write patched APK file</string>
<string name="patcher_step_sign_apk">Sign patched APK file</string> <string name="patcher_step_sign_apk">Sign patched APK file</string>
<string name="patcher_notification_message">Patching in progress…</string> <string name="patcher_notification_title">Patching in progress…</string>
<string name="patcher_notification_text">Tap to return to the patcher</string>
<string name="patcher_stop_confirm_title">Stop patcher</string> <string name="patcher_stop_confirm_title">Stop patcher</string>
<string name="patcher_stop_confirm_description">Are you sure you want to stop the patching process?</string> <string name="patcher_stop_confirm_description">Are you sure you want to stop the patching process?</string>
<string name="execute_patches">Execute patches</string> <string name="execute_patches">Execute patches</string>
@ -347,19 +345,13 @@
<string name="submit_feedback_description">Help us improve this application</string> <string name="submit_feedback_description">Help us improve this application</string>
<string name="developer_options">Developer options</string> <string name="developer_options">Developer options</string>
<string name="developer_options_description">Options for debugging issues</string> <string name="developer_options_description">Options for debugging issues</string>
<string name="bundle_input_source_url">Source URL</string> <string name="patches_update_success">Update successful</string>
<string name="bundle_update_success">Successfully updated %s</string> <string name="patches_update_unavailable">No update available</string>
<string name="bundle_update_unavailable">No update available for %s</string> <string name="view_patches">View patches</string>
<string name="bundle_auto_update">Auto update</string> <string name="patches_view_any_version">Any version</string>
<string name="bundle_auto_update_description">Automatically update this bundle when ReVanced starts</string> <string name="patches_view_any_package">Any package</string>
<string name="bundle_view_patches">View patches</string> <string name="patches_delete_single_dialog_description">Are you sure you want to delete \"%s\"?</string>
<string name="bundle_view_patches_any_version">Any version</string> <string name="patches_delete_multiple_dialog_description">Are you sure you want to delete the selected patches?</string>
<string name="bundle_view_patches_any_package">Any package</string>
<string name="bundle_delete_single_dialog_title">Delete bundle</string>
<string name="bundle_delete_multiple_dialog_title">Delete bundles</string>
<string name="bundle_delete_single_dialog_description">Are you sure you want to delete the bundle \"%s\"?</string>
<string name="bundle_delete_multiple_dialog_description">Are you sure you want to delete the selected bundles?</string>
<string name="about_revanced_manager">About ReVanced Manager</string> <string name="about_revanced_manager">About ReVanced Manager</string>
<string name="revanced_manager_description">ReVanced Manager is an Android application that uses ReVanced Patcher to patch Android apps. It allows you to download and patch apps with custom patches, and manage the patching process.</string> <string name="revanced_manager_description">ReVanced Manager is an Android application that uses ReVanced Patcher to patch Android apps. It allows you to download and patch apps with custom patches, and manage the patching process.</string>
@ -413,10 +405,9 @@
<string name="no_contributors_found">No contributors found</string> <string name="no_contributors_found">No contributors found</string>
<string name="select">Select</string> <string name="select">Select</string>
<string name="select_deselect_all">Select or deselect all</string> <string name="select_deselect_all">Select or deselect all</string>
<string name="select_bundle_type_dialog_title">Add new bundle</string> <string name="select_patches_type_dialog_description">Add new patches from URL or local files</string>
<string name="select_bundle_type_dialog_description">Add a new bundle from a URL or storage</string> <string name="local_patches_description">Add patches from local storage.</string>
<string name="local_bundle_description">Import local files from your storage, does not automatically update</string> <string name="remote_patches_description">Add patches from URL. Patches can automatically update.</string>
<string name="remote_bundle_description">Import remote files from a URL, can automatically update</string>
<string name="recommended">Recommended</string> <string name="recommended">Recommended</string>
<string name="installation_failed_dialog_title">Installation failed</string> <string name="installation_failed_dialog_title">Installation failed</string>
@ -441,9 +432,10 @@
<string name="about_device">About device</string> <string name="about_device">About device</string>
<string name="enter_url">Enter URL</string> <string name="enter_url">Enter URL</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="add_patch_bundle">Add patch bundle</string>
<string name="bundle_url">Bundle URL</string>
<string name="auto_update">Auto update</string> <string name="auto_update">Auto update</string>
<string name="add_patches">Add patches</string>
<string name="auto_update_description">Automatically update when a new version is available</string>
<string name="patches_url">Patches URL</string>
<string name="incompatible_patches_dialog">These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details.</string> <string name="incompatible_patches_dialog">These patches are not compatible with the selected app version (%1$s).\n\nClick on the patches to see more details.</string>
<string name="incompatible_patch">Incompatible patch</string> <string name="incompatible_patch">Incompatible patch</string>
<string name="any_version">Any</string> <string name="any_version">Any</string>