Compare commits

..

1 Commits

Author SHA1 Message Date
dada509be9 fix: Actually run the switch vibration 2025-07-06 02:28:05 +07:00
43 changed files with 1113 additions and 1442 deletions

View File

@ -1,78 +0,0 @@
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.selects.whileSelect
import kotlinx.coroutines.selects.onTimeout
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<S> {
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()
override 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<S> {
suspend fun dispatch(action: Action<S>)
}
interface Action<S> {
suspend fun ActionContext<S>.execute(current: S): S
suspend fun catch(exception: Exception) {
Log.e(tag, "Got exception while executing $this", exception)
}
}

View File

@ -1,15 +1,25 @@
package app.revanced.manager.data.room.bundles
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface PatchBundleDao {
@Query("SELECT * FROM patch_bundles")
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")
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")
suspend fun purgeCustomBundles()
@ -22,9 +32,6 @@ interface PatchBundleDao {
@Query("DELETE FROM patch_bundles WHERE uid = :uid")
suspend fun remove(uid: Int)
@Query("SELECT name, version, auto_update, source FROM patch_bundles WHERE uid = :uid")
suspend fun getProps(uid: Int): PatchBundleProperties?
@Upsert
suspend fun upsert(source: PatchBundleEntity)
@Insert
suspend fun add(source: PatchBundleEntity)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,22 @@
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 app.revanced.manager.data.redux.ActionContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository
import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.util.tag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.MutableStateFlow
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.OutputStream
@ -12,32 +24,27 @@ import java.io.OutputStream
* A [PatchBundle] source.
*/
@Stable
sealed class PatchBundleSource(
val name: String,
val uid: Int,
error: Throwable?,
protected val directory: File
) {
sealed class PatchBundleSource(initialName: String, val uid: Int, directory: File) : KoinComponent {
protected val configRepository: PatchBundlePersistenceRepository by inject()
private val app: Application by inject()
protected val patchesFile = directory.resolve("patches.jar")
val state = when {
error != null -> State.Failed(error)
!hasInstalled() -> State.Missing
else -> State.Available(PatchBundle(patchesFile.absolutePath))
}
private val _state = MutableStateFlow(load())
val state = _state.asStateFlow()
val patchBundle get() = (state as? State.Available)?.bundle
val version get() = patchBundle?.manifestAttributes?.version
val isNameOutOfDate get() = patchBundle?.manifestAttributes?.name?.let { it != name } == true
val error get() = (state as? State.Failed)?.throwable
private val _nameFlow = MutableStateFlow(initialName)
val nameFlow =
_nameFlow.map { it.ifEmpty { app.getString(if (isDefault) R.string.bundle_name_default else R.string.bundle_name_fallback) } }
suspend fun ActionContext<*>.deleteLocalFile() = withContext(Dispatchers.IO) {
patchesFile.delete()
}
suspend fun getName() = nameFlow.first()
abstract fun copy(error: Throwable? = this.error, name: String = this.name): PatchBundleSource
val versionFlow = state.map { it.patchBundleOrNull()?.readManifestAttribute("Version") }
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) {
// Android 14+ requires dex containers to be readonly.
@ -49,14 +56,62 @@ sealed class PatchBundleSource(
}
}
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 {
fun patchBundleOrNull(): PatchBundle? = null
data object Missing : State
data class Failed(val throwable: Throwable) : State
data class Available(val bundle: PatchBundle) : State
data class Loaded(val bundle: PatchBundle) : State {
override fun patchBundleOrNull() = bundle
}
}
companion object Extensions {
val PatchBundleSource.isDefault inline get() = uid == 0
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
import app.revanced.manager.data.redux.ActionContext
import androidx.compose.runtime.Stable
import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.network.dto.ReVancedAsset
import app.revanced.manager.network.service.HttpService
@ -8,24 +8,15 @@ import app.revanced.manager.network.utils.getOrThrow
import io.ktor.client.request.url
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.File
sealed class RemotePatchBundle(
name: String,
uid: Int,
protected val versionHash: String?,
error: Throwable?,
directory: File,
val endpoint: String,
val autoUpdate: Boolean,
) : PatchBundleSource(name, uid, error, directory), KoinComponent {
@Stable
sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) :
PatchBundleSource(name, id, directory) {
protected val http: HttpService by inject()
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) {
patchBundleOutputStream().use {
@ -34,72 +25,47 @@ sealed class RemotePatchBundle(
}
}
info.version
saveVersionHash(info.version)
reload()
}
/**
* Downloads the latest version regardless if there is a new update available.
*/
suspend fun ActionContext<*>.downloadLatest() = download(getLatestInfo())
suspend fun downloadLatest() {
download(getLatestInfo())
}
suspend fun ActionContext<*>.update(): String? = withContext(Dispatchers.IO) {
suspend fun update(): Boolean = withContext(Dispatchers.IO) {
val info = getLatestInfo()
if (hasInstalled() && info.version == versionHash)
return@withContext null
if (hasInstalled() && info.version == currentVersionHash())
return@withContext false
download(info)
true
}
suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) {
patchesFile.delete()
reload()
}
suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value)
companion object {
const val updateFailMsg = "Failed to update patches"
const val updateFailMsg = "Failed to update patch bundle(s)"
}
}
class JsonPatchBundle(
name: String,
uid: Int,
versionHash: String?,
error: Throwable?,
directory: File,
endpoint: String,
autoUpdate: Boolean,
) : RemotePatchBundle(name, uid, versionHash, error, directory, endpoint, autoUpdate) {
class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
RemotePatchBundle(name, id, directory, endpoint) {
override suspend fun getLatestInfo() = withContext(Dispatchers.IO) {
http.request<ReVancedAsset> {
url(endpoint)
}.getOrThrow()
}
override fun copy(error: Throwable?, name: String, autoUpdate: Boolean) = JsonPatchBundle(
name,
uid,
versionHash,
error,
directory,
endpoint,
autoUpdate,
)
}
class APIPatchBundle(
name: String,
uid: Int,
versionHash: String?,
error: Throwable?,
directory: File,
endpoint: String,
autoUpdate: Boolean,
) : RemotePatchBundle(name, uid, versionHash, error, directory, endpoint, autoUpdate) {
class APIPatchBundle(name: String, id: Int, directory: File, endpoint: String) :
RemotePatchBundle(name, id, directory, endpoint) {
private val api: ReVancedAPI by inject()
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,8 +40,6 @@ class DownloadedAppRepository(
data: Parcelable,
expectedPackageName: String,
expectedVersion: String?,
appCompatibilityCheck: Boolean,
patchesCompatibilityCheck: Boolean,
onDownload: suspend (downloadProgress: Pair<Long, Long?>) -> Unit,
): File {
// Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here.
@ -98,12 +96,7 @@ class DownloadedAppRepository(
val pkgInfo =
pm.getPackageInfo(targetFile.toFile()) ?: error("Downloaded APK file is invalid")
if (pkgInfo.packageName != expectedPackageName) error("Downloaded APK has the wrong package name. Expected: $expectedPackageName, Actual: ${pkgInfo.packageName}")
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\".")
}
if (expectedVersion != null && pkgInfo.versionName != expectedVersion) error("Downloaded APK has the wrong version. Expected: $expectedVersion, Actual: ${pkgInfo.versionName}")
// Delete the previous copy (if present).
dao.get(pkgInfo.packageName, pkgInfo.versionName!!)?.directory?.let {

View File

@ -0,0 +1,58 @@
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,79 +3,55 @@ package app.revanced.manager.domain.repository
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.annotation.StringRes
import app.revanced.library.mostCommonCompatibleVersions
import app.revanced.manager.R
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.PatchBundleProperties
import app.revanced.manager.data.room.bundles.Source
import app.revanced.manager.domain.bundles.APIPatchBundle
import app.revanced.manager.domain.bundles.JsonPatchBundle
import app.revanced.manager.data.room.bundles.Source as SourceInfo
import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.RemotePatchBundle
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.patcher.patch.PatchInfo
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.flatMapLatestAndCombine
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast
import kotlinx.collections.immutable.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream
import kotlin.collections.joinToString
import kotlin.collections.map
import kotlin.text.ifEmpty
class PatchBundleRepository(
private val app: Application,
private val persistenceRepo: PatchBundlePersistenceRepository,
private val networkInfo: NetworkInfo,
private val prefs: PreferencesManager,
db: AppDatabase,
) {
private val dao = db.patchBundleDao()
private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
private val coroutineScope = CoroutineScope(Dispatchers.Default)
private val store = Store(coroutineScope, State())
private val _sources: MutableStateFlow<Map<Int, PatchBundleSource>> =
MutableStateFlow(emptyMap())
val sources = _sources.map { it.values.toList() }
val sources = store.state.map { it.sources.values.toList() }
val bundles = store.state.map {
it.sources.mapNotNull { (uid, src) ->
uid to (src.patchBundle ?: return@mapNotNull null)
}.toMap()
}
val bundleInfoFlow = store.state.map { it.info }
fun scopedBundleInfoFlow(packageName: String, version: String?) = bundleInfoFlow.map {
it.map { (_, bundleInfo) ->
bundleInfo.forPackage(
packageName,
version
)
val bundles = sources.flatMapLatestAndCombine(
combiner = {
it.mapNotNull { (uid, state) ->
val bundle = state.patchBundleOrNull() ?: return@mapNotNull null
uid to bundle
}.toMap()
}
) {
it.state.map { state -> it.uid to state }
}
val patchCountsFlow = bundleInfoFlow.map { it.mapValues { (_, info) -> info.patches.size } }
val suggestedVersions = bundleInfoFlow.map {
val suggestedVersions = bundles.map {
val allPatches =
it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()
@ -98,103 +74,6 @@ class PatchBundleRepository(
}
}
private suspend inline fun dispatchAction(
name: String,
crossinline block: suspend ActionContext<State>.(current: State) -> State
) {
store.dispatch(object : Action<State> {
override suspend fun ActionContext<State>.execute(current: State): State {
return 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) =
withContext(Dispatchers.Default) {
if (!prefs.suggestedVersionSafeguard.get()) return@withContext true
@ -210,211 +89,96 @@ class PatchBundleRepository(
private fun PatchBundleEntity.load(): PatchBundleSource {
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) {
is SourceInfo.Local -> LocalPatchBundle(actualName, uid, null, dir)
is SourceInfo.API -> APIPatchBundle(
actualName,
uid,
versionHash,
null,
dir,
SourceInfo.API.SENTINEL,
autoUpdate,
)
is SourceInfo.Local -> LocalPatchBundle(name, uid, dir)
is SourceInfo.API -> APIPatchBundle(name, uid, dir, SourceInfo.API.SENTINEL)
is SourceInfo.Remote -> JsonPatchBundle(
actualName,
name,
uid,
versionHash,
null,
dir,
source.url.toString(),
autoUpdate,
source.url.toString()
)
}
}
private suspend fun createEntity(name: String, source: Source, autoUpdate: Boolean = false) =
PatchBundleEntity(
uid = generateUid(),
name = name,
versionHash = null,
source = source,
autoUpdate = autoUpdate
).also {
dao.upsert(it)
suspend fun reload() = withContext(Dispatchers.Default) {
val entities = persistenceRepo.loadConfiguration().onEach {
Log.d(tag, "Bundle: $it")
}
/**
* 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,
)
)
_sources.value = entities.associate {
it.uid to it.load()
}
}
suspend fun reset() = dispatchAction("Reset") { state ->
dao.reset()
state.sources.keys.forEach { directoryOf(it).deleteRecursively() }
doReload()
suspend fun reset() = withContext(Dispatchers.Default) {
persistenceRepo.reset()
_sources.value = emptyMap()
bundlesDir.apply {
deleteRecursively()
mkdirs()
}
reload()
}
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
suspend fun remove(bundle: PatchBundleSource) = withContext(Dispatchers.Default) {
persistenceRepo.delete(bundle.uid)
directoryOf(bundle.uid).deleteRecursively()
dao.remove(it.uid)
directoryOf(it.uid).deleteRecursively()
sources.remove(it.uid)
info.remove(it.uid)
_sources.update {
it.filterKeys { key ->
key != bundle.uid
}
}
}
State(sources.toPersistentMap(), info.toPersistentMap())
private fun addBundle(patchBundle: PatchBundleSource) =
_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) {
val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate)
addBundle(entity.load())
}
private suspend inline fun <reified T> getBundlesByType() =
sources.first().filterIsInstance<T>()
suspend fun reloadApiBundles() {
getBundlesByType<APIPatchBundle>().forEach {
it.deleteLocalFiles()
}
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()))
reload()
}
suspend fun redownloadRemoteBundles() =
getBundlesByType<RemotePatchBundle>().forEach { it.downloadLatest() }
suspend fun updateCheck() =
uiSafe(app, R.string.source_download_fail, "Failed to update bundles") {
coroutineScope {
if (!networkInfo.isSafe()) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope
}
deleteLocalFile()
}
}
doReload()
}
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 reloadApiBundles() = dispatchAction("Reload API bundles") {
this@PatchBundleRepository.sources.first().filterIsInstance<APIPatchBundle>().forEach {
with(it) { deleteLocalFile() }
updateDb(it.uid) { it.copy(versionHash = null) }
}
doReload()
}
suspend fun RemotePatchBundle.setAutoUpdate(value: Boolean) =
dispatchAction("Set auto update ($name, $value)") { state ->
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))
}
suspend fun update(vararg sources: RemotePatchBundle, showToast: Boolean = false) {
val uids = sources.map { it.uid }.toSet()
store.dispatch(Update(showToast = showToast) { it.uid in uids })
}
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true))
/**
* Updates all bundles that should be automatically updated.
*/
suspend fun updateCheck() = store.dispatch(Update { it.autoUpdate })
private inner class Update(
private val force: Boolean = false,
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<State>.execute(
current: State
) = coroutineScope {
if (!networkInfo.isSafe()) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope current
}
val updated = current.sources.values
.filterIsInstance<RemotePatchBundle>()
.filter { predicate(it) }
.map {
async {
Log.d(tag, "Updating patch bundle: ${it.name}")
val newVersion = with(it) {
if (force) downloadLatest() else update()
} ?: return@async null
it to newVersion
getBundlesByType<RemotePatchBundle>().forEach {
launch {
if (!it.getProps().autoUpdate) return@launch
Log.d(tag, "Updating patch bundle: ${it.getName()}")
it.update()
}
}
.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,84 +1,56 @@
package app.revanced.manager.patcher.patch
import kotlinx.parcelize.IgnoredOnParcel
import android.os.Parcelable
import android.util.Log
import app.revanced.manager.util.tag
import app.revanced.patcher.patch.Patch
import app.revanced.patcher.patch.PatchLoader
import kotlinx.parcelize.Parcelize
import java.io.File
import java.io.IOException
import java.util.jar.JarFile
import kotlin.collections.filter
@Parcelize
data class PatchBundle(val patchesJar: String) : Parcelable {
class PatchBundle(val patchesJar: File) {
private val loader = object : Iterable<Patch<*>> {
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].
*/
@IgnoredOnParcel
private val manifest by lazy {
try {
JarFile(patchesJar).use { it.manifest }
} catch (_: IOException) {
null
private val manifest = try {
JarFile(patchesJar).use { it.manifest }
} catch (_: IOException) {
null
}
fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)
/**
* Load all patches compatible with the specified package.
*/
fun patches(packageName: String) = loader.filter { patch ->
val compatiblePackages = patch.compatiblePackages
?: // The patch has no compatibility constraints, which means it is universal.
return@filter true
if (!compatiblePackages.any { (name, _) -> name == packageName }) {
// Patch is not compatible with this package.
return@filter false
}
true
}
@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)
?.takeIf { it.isNotBlank() } // If empty, set it to null instead.
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>) =
PatchLoader.Dex(
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
?: // The patch has no compatibility constraints, which means it is universal.
return@filter true
if (!compatiblePackages.any { (name, _) -> name == packageName }) {
// Patch is not compatible with this package.
return@filter false
}
true
}.toSet()
}
}
}
}

View File

@ -1,145 +0,0 @@
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(
allowUnsupported: Boolean,
condition: (Int, PatchInfo) -> Boolean
): PatchSelection = this.associate { bundle ->
val patches =
bundle.patchSequence(allowUnsupported)
.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,7 +3,6 @@ package app.revanced.manager.patcher.runtime
import android.content.Context
import app.revanced.manager.patcher.Session
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.ui.model.State
import app.revanced.manager.util.Options
@ -24,17 +23,14 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) {
onPatchCompleted: suspend () -> Unit,
onProgress: ProgressEventHandler,
) {
val selectedBundles = selectedPatches.keys
val bundles = bundles()
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 selectedBundles = selectedPatches.keys
val allPatches = bundles.filterKeys { selectedBundles.contains(it) }
.mapValues { (_, bundle) -> bundle.patches(packageName) }
val patchList = selectedPatches.flatMap { (bundle, selected) ->
allPatches[bundle]?.filter { it.name in selected }
allPatches[bundle]?.filter { selected.contains(it.name) }
?: throw IllegalArgumentException("Patch bundle $bundle does not exist")
}

View File

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

View File

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

View File

@ -56,10 +56,11 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
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 patches = (allPatches[config.bundle] ?: return@flatMap emptyList())
.filter { it.name in config.patches }
val bundle = PatchBundle(File(config.bundlePath))
val patches =
bundle.patches(parameters.packageName).filter { it.name in config.patches }
.associateBy { it.name }
config.options.forEach { (patchName, opts) ->

View File

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

View File

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

View File

@ -0,0 +1,181 @@
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,92 +1,68 @@
package app.revanced.manager.ui.component.bundle
import android.webkit.URLUtil.isValidUrl
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.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.outlined.Commit
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.material3.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import app.revanced.manager.ui.component.ExceptionViewerDialog
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 org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BundleInformationDialog(
src: PatchBundleSource,
patchCount: Int,
onDismissRequest: () -> Unit,
onDeleteRequest: () -> Unit,
bundle: PatchBundleSource,
onUpdate: () -> Unit,
) {
val bundleRepo = koinInject<PatchBundleRepository>()
val networkInfo = koinInject<NetworkInfo>()
val hasNetwork = remember { networkInfo.isConnected() }
val composableScope = rememberCoroutineScope()
var viewCurrentBundlePatches by remember { mutableStateOf(false) }
val isLocal = src is LocalPatchBundle
val bundleManifestAttributes = src.patchBundle?.manifestAttributes
val isLocal = bundle is LocalPatchBundle
val state by bundle.state.collectAsStateWithLifecycle()
val props by remember(bundle) {
bundle.propsFlow()
}.collectAsStateWithLifecycle(null)
val patchCount by bundle.patchCountFlow.collectAsStateWithLifecycle(0)
val version by bundle.versionFlow.collectAsStateWithLifecycle(null)
if (viewCurrentBundlePatches) {
BundlePatchesDialog(
src = src,
onDismissRequest = {
viewCurrentBundlePatches = false
}
},
bundle = bundle,
)
}
FullscreenDialog(
onDismissRequest = onDismissRequest,
) {
val bundleName by bundle.nameState
Scaffold(
topBar = {
BundleTopBar(
title = src.name,
title = stringResource(R.string.patch_bundle_field),
onBackClick = onDismissRequest,
backIcon = {
Icon(
@ -95,7 +71,7 @@ fun BundleInformationDialog(
)
},
actions = {
if (!src.isDefault) {
if (!bundle.isDefault) {
IconButton(onClick = onDeleteRequest) {
Icon(
Icons.Outlined.DeleteOutline,
@ -115,185 +91,54 @@ fun BundleInformationDialog(
)
},
) { paddingValues ->
val autoUpdate = src.asRemoteOrNull?.autoUpdate == true
fun onAutoUpdateChange(new: Boolean) = composableScope.launch {
with(bundleRepo) {
src.asRemoteOrNull?.setAutoUpdate(new)
}
}
ColumnWithScrollbar(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
src.name?.let {
Tag(Icons.Outlined.Sell, it)
BaseBundleDialog(
modifier = Modifier.padding(paddingValues),
isDefault = bundle.isDefault,
name = bundleName,
remoteUrl = bundle.asRemoteOrNull?.endpoint,
patchCount = patchCount,
version = version,
autoUpdate = props?.autoUpdate == true,
onAutoUpdateChange = {
composableScope.launch {
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 (src.asRemoteOrNull?.endpoint != null) {
BundleListItem(
headlineText = stringResource(auto_update),
supportingText = stringResource(auto_update_description),
trailingContent = {
HapticSwitch(
checked = autoUpdate,
onCheckedChange = ::onAutoUpdateChange
)
},
modifier = Modifier.clickable {
onAutoUpdateChange(!autoUpdate)
},
onPatchesClick = {
viewCurrentBundlePatches = true
},
extraFields = {
(state as? PatchBundleSource.State.Failed)?.throwable?.let {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
)
}
if (showDialog) ExceptionViewerDialog(
onDismiss = { showDialog = false },
text = remember(it) { it.stackTraceToString() }
)
src.asRemoteOrNull?.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.")
BundleListItem(
headlineText = stringResource(R.string.bundle_error),
supportingText = stringResource(R.string.bundle_error_description),
trailingContent = {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
null
)
},
validator = {
if (it.isEmpty()) return@TextInputDialog false
isValidUrl(it)
}
modifier = Modifier.clickable { showDialog = true }
)
}
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
}
)
) {
if (patchesClickable) {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
stringResource(patches)
if (state is PatchBundleSource.State.Missing && !isLocal) {
BundleListItem(
headlineText = stringResource(R.string.bundle_error),
supportingText = stringResource(R.string.bundle_not_downloaded),
modifier = Modifier.clickable(onClick = onUpdate)
)
}
}
src.error?.let {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
if (showDialog) ExceptionViewerDialog(
onDismiss = { showDialog = false },
text = remember(it) { it.stackTraceToString() }
)
BundleListItem(
headlineText = stringResource(R.string.patches_error),
supportingText = stringResource(R.string.patches_error_description),
trailingContent = {
Icon(
Icons.AutoMirrored.Outlined.ArrowRight,
null
)
},
modifier = Modifier.clickable { showDialog = true }
)
}
if (src.state is PatchBundleSource.State.Missing && !isLocal) {
BundleListItem(
headlineText = stringResource(R.string.patches_error),
supportingText = stringResource(R.string.patches_not_downloaded),
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,32 +24,38 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.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.haptics.HapticCheckbox
import kotlinx.coroutines.flow.map
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BundleItem(
src: PatchBundleSource,
patchCount: Int,
selectable: Boolean,
isBundleSelected: Boolean,
toggleSelection: (Boolean) -> Unit,
onSelect: () -> Unit,
bundle: PatchBundleSource,
onDelete: () -> Unit,
onUpdate: () -> Unit,
selectable: Boolean,
onSelect: () -> Unit,
isBundleSelected: Boolean,
toggleSelection: (Boolean) -> Unit,
) {
var viewBundleDialogPage 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) {
BundleInformationDialog(
src = src,
patchCount = patchCount,
onDismissRequest = { viewBundleDialogPage = false },
onDeleteRequest = { showDeleteConfirmationDialog = true },
bundle = bundle,
onUpdate = onUpdate,
)
}
@ -61,8 +67,8 @@ fun BundleItem(
onDelete()
viewBundleDialogPage = false
},
title = stringResource(R.string.delete),
description = stringResource(R.string.patches_delete_single_dialog_description, src.name),
title = stringResource(R.string.bundle_delete_single_dialog_title),
description = stringResource(R.string.bundle_delete_single_dialog_description, name),
icon = Icons.Outlined.Delete
)
}
@ -84,19 +90,19 @@ fun BundleItem(
}
} else null,
headlineContent = { Text(src.name) },
headlineContent = { Text(name) },
supportingContent = {
if (src.state is PatchBundleSource.State.Available) {
if (state is PatchBundleSource.State.Loaded) {
Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount))
}
},
trailingContent = {
Row {
val icon = remember(src.state) {
when (src.state) {
is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.patches_error
is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.patches_missing
is PatchBundleSource.State.Available -> null
val icon = remember(state) {
when (state) {
is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.bundle_error
is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.bundle_missing
is PatchBundleSource.State.Loaded -> null
}
}
@ -109,7 +115,7 @@ fun BundleItem(
)
}
src.version?.let { Text(text = it) }
version?.let { Text(text = it) }
}
},
)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,113 @@
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

@ -1,85 +1,61 @@
package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
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
fun BundleListScreen(
viewModel: BundleListViewModel = koinViewModel(),
eventsFlow: Flow<BundleListViewModel.Event>,
setSelectedSourceCount: (Int) -> Unit
onDelete: (PatchBundleSource) -> Unit,
onUpdate: (PatchBundleSource) -> Unit,
sources: List<PatchBundleSource>,
selectedSources: SnapshotStateList<PatchBundleSource>,
bundlesSelectable: Boolean,
) {
val patchCounts by viewModel.patchCounts.collectAsStateWithLifecycle(emptyMap())
val sources by viewModel.sources.collectAsStateWithLifecycle(emptyList())
EventEffect(eventsFlow) {
viewModel.handleEvent(it)
}
LaunchedEffect(viewModel.selectedSources.size) {
setSelectedSourceCount(viewModel.selectedSources.size)
val sortedSources = remember(sources) {
sources.sortedByDescending { source ->
source.state.value.patchBundleOrNull()?.patches?.size ?: 0
}
}
PullToRefreshBox(
onRefresh = viewModel::refresh,
isRefreshing = viewModel.isRefreshing
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
items(
sources,
key = { it.uid },
contentType = { 0 },
) { source ->
BundleItem(
src = source,
patchCount = patchCounts[source.uid] ?: 0,
onDelete = {
viewModel.delete(source)
},
onUpdate = {
viewModel.update(source)
},
selectable = viewModel.selectedSources.size > 0,
onSelect = {
viewModel.selectedSources.add(source.uid)
},
isBundleSelected = source.uid in viewModel.selectedSources,
toggleSelection = { bundleIsNotSelected ->
if (bundleIsNotSelected) {
viewModel.selectedSources.add(source.uid)
} else {
viewModel.selectedSources.remove(source.uid)
}
items(
sortedSources,
key = { it.uid }
) { source ->
BundleItem(
bundle = source,
onDelete = {
onDelete(source)
},
onUpdate = {
onUpdate(source)
},
selectable = bundlesSelectable,
onSelect = {
selectedSources.add(source)
},
isBundleSelected = selectedSources.contains(source),
toggleSelection = { bundleIsNotSelected ->
if (bundleIsNotSelected) {
selectedSources.add(source)
} else {
selectedSources.remove(source)
}
)
}
}
)
}
}
}

View File

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

View File

@ -389,7 +389,6 @@ fun PatchesSelectorScreen(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(top = 16.dp)
) {
if (bundles.size > 1) {
ScrollableTabRow(
@ -413,7 +412,7 @@ fun PatchesSelectorScreen(
style = MaterialTheme.typography.bodyMedium
)
Text(
text = bundle.version.orEmpty(),
text = bundle.version!!,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@ -30,12 +30,12 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
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.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
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.util.Options
import app.revanced.manager.util.PatchSelection
@ -62,7 +62,6 @@ fun RequiredOptionsScreen(
val showContinueButton by remember {
derivedStateOf {
bundles.requiredOptionsSet(
allowIncompatible = vm.allowIncompatiblePatches,
isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) },
optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) }
)

View File

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

View File

@ -2,6 +2,7 @@ package app.revanced.manager.ui.screen.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -18,7 +19,9 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -26,11 +29,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.network.downloader.DownloaderPluginState
@ -52,6 +57,7 @@ fun DownloadsSettingsScreen(
onBackClick: () -> Unit,
viewModel: DownloadsViewModel = koinViewModel()
) {
val pullRefreshState = rememberPullToRefreshState()
val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList())
val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@ -84,138 +90,152 @@ fun DownloadsSettingsScreen(
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
) { paddingValues ->
PullToRefreshBox(
onRefresh = viewModel::refreshPlugins,
isRefreshing = viewModel.isRefreshingPlugins,
modifier = Modifier.padding(paddingValues)
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.padding(paddingValues)
.fillMaxWidth()
.zIndex(1f)
) {
LazyColumnWithScrollbar(
modifier = Modifier.fillMaxSize()
) {
item {
GroupHeader(stringResource(R.string.downloader_plugins))
}
pluginStates.forEach { (packageName, state) ->
item(key = packageName) {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
PullToRefreshDefaults.Indicator(
state = pullRefreshState,
isRefreshing = viewModel.isRefreshingPlugins
)
}
fun dismiss() {
showDialog = false
}
LazyColumnWithScrollbar(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.pullToRefresh(
isRefreshing = viewModel.isRefreshingPlugins,
state = pullRefreshState,
onRefresh = viewModel::refreshPlugins
)
) {
item {
GroupHeader(stringResource(R.string.downloader_plugins))
}
pluginStates.forEach { (packageName, state) ->
item(key = packageName) {
var showDialog by rememberSaveable {
mutableStateOf(false)
}
val packageInfo =
fun dismiss() {
showDialog = false
}
val packageInfo =
remember(packageName) {
viewModel.pm.getPackageInfo(
packageName
)
} ?: return@item
if (showDialog) {
val signature =
remember(packageName) {
viewModel.pm.getPackageInfo(
packageName
)
} ?: return@item
if (showDialog) {
val signature =
remember(packageName) {
val androidSignature =
viewModel.pm.getSignature(packageName)
val hash = MessageDigest.getInstance("SHA-256")
.digest(androidSignature.toByteArray())
hash.toHexString(format = HexFormat.UpperCase)
}
when (state) {
is DownloaderPluginState.Loaded -> TrustDialog(
title = R.string.downloader_plugin_revoke_trust_dialog_title,
body = stringResource(
R.string.downloader_plugin_trust_dialog_body,
packageName,
signature
),
onDismiss = ::dismiss,
onConfirm = {
viewModel.revokePluginTrust(packageName)
dismiss()
}
)
is DownloaderPluginState.Failed -> ExceptionViewerDialog(
text = remember(state.throwable) {
state.throwable.stackTraceToString()
},
onDismiss = ::dismiss
)
is DownloaderPluginState.Untrusted -> TrustDialog(
title = R.string.downloader_plugin_trust_dialog_title,
body = stringResource(
R.string.downloader_plugin_trust_dialog_body,
packageName,
signature
),
onDismiss = ::dismiss,
onConfirm = {
viewModel.trustPlugin(packageName)
dismiss()
}
)
val androidSignature =
viewModel.pm.getSignature(packageName)
val hash = MessageDigest.getInstance("SHA-256")
.digest(androidSignature.toByteArray())
hash.toHexString(format = HexFormat.UpperCase)
}
}
SettingsListItem(
modifier = Modifier.clickable { showDialog = true },
headlineContent = {
AppLabel(
packageInfo = packageInfo,
style = MaterialTheme.typography.titleLarge
)
},
supportingContent = stringResource(
when (state) {
is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted
is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed
is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted
when (state) {
is DownloaderPluginState.Loaded -> TrustDialog(
title = R.string.downloader_plugin_revoke_trust_dialog_title,
body = stringResource(
R.string.downloader_plugin_trust_dialog_body,
packageName,
signature
),
onDismiss = ::dismiss,
onConfirm = {
viewModel.revokePluginTrust(packageName)
dismiss()
}
),
trailingContent = { Text(packageInfo.versionName!!) }
)
}
}
if (pluginStates.isEmpty()) {
item {
Text(
stringResource(R.string.downloader_no_plugins_installed),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
)
item {
GroupHeader(stringResource(R.string.downloaded_apps))
}
items(downloadedApps, key = { it.packageName to it.version }) { app ->
val selected = app in viewModel.appSelection
is DownloaderPluginState.Failed -> ExceptionViewerDialog(
text = remember(state.throwable) {
state.throwable.stackTraceToString()
},
onDismiss = ::dismiss
)
is DownloaderPluginState.Untrusted -> TrustDialog(
title = R.string.downloader_plugin_trust_dialog_title,
body = stringResource(
R.string.downloader_plugin_trust_dialog_body,
packageName,
signature
),
onDismiss = ::dismiss,
onConfirm = {
viewModel.trustPlugin(packageName)
dismiss()
}
)
}
}
SettingsListItem(
modifier = Modifier.clickable { viewModel.toggleApp(app) },
headlineContent = app.packageName,
leadingContent = (@Composable {
HapticCheckbox(
checked = selected,
onCheckedChange = { viewModel.toggleApp(app) }
modifier = Modifier.clickable { showDialog = true },
headlineContent = {
AppLabel(
packageInfo = packageInfo,
style = MaterialTheme.typography.titleLarge
)
}).takeIf { viewModel.appSelection.isNotEmpty() },
supportingContent = app.version,
tonalElevation = if (selected) 8.dp else 0.dp
},
supportingContent = stringResource(
when (state) {
is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted
is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed
is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted
}
),
trailingContent = { Text(packageInfo.versionName!!) }
)
}
if (downloadedApps.isEmpty()) {
item {
Text(
stringResource(R.string.downloader_settings_no_apps),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
}
if (pluginStates.isEmpty()) {
item {
Text(
stringResource(R.string.downloader_no_plugins_installed),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
item {
GroupHeader(stringResource(R.string.downloaded_apps))
}
items(downloadedApps, key = { it.packageName to it.version }) { app ->
val selected = app in viewModel.appSelection
SettingsListItem(
modifier = Modifier.clickable { viewModel.toggleApp(app) },
headlineContent = app.packageName,
leadingContent = (@Composable {
HapticCheckbox(
checked = selected,
onCheckedChange = { viewModel.toggleApp(app) }
)
}
}).takeIf { viewModel.appSelection.isNotEmpty() },
supportingContent = app.version,
tonalElevation = if (selected) 8.dp else 0.dp
)
}
if (downloadedApps.isEmpty()) {
item {
Text(
stringResource(R.string.downloader_settings_no_apps),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
}

View File

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

View File

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

View File

@ -1,76 +0,0 @@
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,6 +1,5 @@
package app.revanced.manager.ui.viewmodel
import android.annotation.SuppressLint
import android.app.Application
import android.content.ContentResolver
import android.net.Uri
@ -25,10 +24,8 @@ import app.revanced.manager.network.api.ReVancedAPI
import app.revanced.manager.util.PM
import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
class DashboardViewModel(
@ -41,12 +38,13 @@ class DashboardViewModel(
private val pm: PM,
) : ViewModel() {
val availablePatches =
patchBundleRepository.bundleInfoFlow.map { it.values.sumOf { bundle -> bundle.patches.size } }
patchBundleRepository.bundles.map { it.values.sumOf { bundle -> bundle.patches.size } }
private val contentResolver: ContentResolver = app.contentResolver
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.
@ -61,9 +59,6 @@ class DashboardViewModel(
var showBatteryOptimizationsWarning by mutableStateOf(false)
private set
private val bundleListEventsChannel = Channel<BundleListViewModel.Event>()
val bundleListEventsFlow = bundleListEventsChannel.receiveAsFlow()
init {
viewModelScope.launch {
checkForManagerUpdates()
@ -75,6 +70,10 @@ class DashboardViewModel(
downloaderPluginRepository.acknowledgeAllNewPlugins()
}
fun dismissUpdateDialog() {
updatedManagerVersion = null
}
private suspend fun checkForManagerUpdates() {
if (!prefs.managerAutoUpdates.get() || !networkInfo.isConnected()) return
@ -84,8 +83,7 @@ class DashboardViewModel(
}
fun updateBatteryOptimizationsWarning() {
showBatteryOptimizationsWarning =
!powerManager.isIgnoringBatteryOptimizations(app.packageName)
showBatteryOptimizationsWarning = !powerManager.isIgnoringBatteryOptimizations(app.packageName)
}
fun setShowManagerUpdateDialogOnLaunch(value: Boolean) {
@ -114,20 +112,36 @@ class DashboardViewModel(
}
}
private fun sendEvent(event: BundleListViewModel.Event) {
viewModelScope.launch { bundleListEventsChannel.send(event) }
fun cancelSourceSelection() {
selectedSources.clear()
}
fun cancelSourceSelection() = sendEvent(BundleListViewModel.Event.CANCEL)
fun updateSources() = sendEvent(BundleListViewModel.Event.UPDATE_SELECTED)
fun deleteSources() = sendEvent(BundleListViewModel.Event.DELETE_SELECTED)
fun createLocalSource(patchBundle: Uri) =
viewModelScope.launch {
contentResolver.openInputStream(patchBundle)!!.use { patchesStream ->
patchBundleRepository.createLocal(patchesStream)
}
}
@SuppressLint("Recycle")
fun createLocalSource(patchBundle: Uri) = viewModelScope.launch {
patchBundleRepository.createLocal { contentResolver.openInputStream(patchBundle)!! }
}
fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) =
viewModelScope.launch { patchBundleRepository.createRemote(apiUrl, autoUpdate) }
fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch {
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
) : ViewModel() {
fun redownloadBundles() = viewModelScope.launch {
uiSafe(app, R.string.patches_download_fail, RemotePatchBundle.updateFailMsg) {
uiSafe(app, R.string.source_download_fail, RemotePatchBundle.updateFailMsg) {
patchBundleRepository.redownloadRemoteBundles()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,24 +14,26 @@
<string name="dashboard">Dashboard</string>
<string name="settings">Settings</string>
<string name="select_app">Select an app</string>
<string name="patches_count_selected">%1$d/%2$d selected</string>
<string name="patches_selected">%1$d/%2$d selected</string>
<string name="new_downloader_plugins_notification">New downloader plugins available. Click here to configure them.</string>
<string name="unsupported_architecture_warning">Patching on this device architecture is unsupported and will most likely fail.</string>
<string name="import_">Import</string>
<string name="import_patches">Import patches</string>
<string name="import_bundle">Import patch bundle</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_not_set">Not selected</string>
<string name="field_not_set">Not set</string>
<string name="patches_missing">Missing</string>
<string name="patches_error">Error</string>
<string name="patches_error_description">Patches could not be loaded. Click to view the error</string>
<string name="patches_not_downloaded">Patches has not been downloaded. Click here to download it</string>
<string name="patches_name_default">Patches</string>
<string name="patches_name_fallback">Unnamed</string>
<string name="bundle_missing">Missing</string>
<string name="bundle_error">Error</string>
<string name="bundle_error_description">Bundle 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="bundle_name_default">Default</string>
<string name="bundle_name_fallback">Unnamed</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>
@ -137,15 +139,16 @@
<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_description">Resets patch selection for a single app</string>
<string name="patch_selection_reset_patches">Resets patch selection for a specific patches</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_patches_description">Resets the patch selection for a specific patches</string>
<string name="patch_selection_reset_bundle">Resets patch selection for bundle</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_bundle_description">Resets the patch selection for all patches in a bundle</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_description">Resets patch options for a single app</string>
<string name="patch_options_reset">Reset patch options</string>
<string name="patch_options_reset_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_all">Reset patch options for all</string>
<string name="patch_options_reset_bundle">Resets patch options for bundle</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_bundle_description">Resets patch options for all patches in a bundle</string>
<string name="patch_options_reset_all">Reset patch options</string>
<string name="patch_options_reset_all_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="downloader_plugins">Plugins</string>
@ -161,7 +164,7 @@
<string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string>
<string name="downloading_patches">Downloading patches</string>
<string name="downloading_patches">Downloading patch bundle…</string>
<string name="options">Options</string>
<string name="ok">OK</string>
@ -199,8 +202,8 @@
<string name="debug_logs_export_success">Exported logs</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_dialog_title">Change API URL</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_title">Set custom 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_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_reset">Reset API URL</string>
@ -210,25 +213,26 @@
<string name="device_architectures">CPU Architectures</string>
<string name="device_memory_limit">Memory limits</string>
<string name="device_memory_limit_format">%1$dMB (Normal) - %2$dMB (Large)</string>
<string name="patches_force_download">Force download all patches</string>
<string name="patches_reset">Reset patches</string>
<string name="patch_bundles_section">Patch bundles</string>
<string name="patch_bundles_force_download">Force download all patch bundles</string>
<string name="patch_bundles_reset">Reset patch bundles</string>
<string name="patching">Patching</string>
<string name="signing">Signing</string>
<string name="storage">Storage</string>
<string name="no_patch_found">No patch can be found. Check your patches</string>
<string name="patches_unavailable">No patches are available. Check your bundles</string>
<string name="tab_apps">Apps</string>
<string name="tab_patches">Patches</string>
<string name="tab_bundles">Patch bundles</string>
<string name="delete">Delete</string>
<string name="refresh">Refresh</string>
<string name="continue_anyways">Continue anyways</string>
<string name="download_another_version">Download another version</string>
<string name="download_app">Download app</string>
<string name="download_apk">Download APK file</string>
<string name="patches_download_fail">Failed to download patches: %s</string>
<string name="patches_replace_fail">Failed to import patches: %s</string>
<string name="source_download_fail">Failed to download patch bundle: %s</string>
<string name="source_replace_fail">Failed to load updated patch bundle: %s</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="patches_selected">%s selected</string>
<string name="bundles_selected">%s selected</string>
<string name="incompatible_patches">Incompatible 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>
@ -313,8 +317,7 @@
<string name="patcher_step_group_saving">Saving</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_notification_title">Patching in progress…</string>
<string name="patcher_notification_text">Tap to return to the patcher</string>
<string name="patcher_notification_message">Patching in progress…</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="execute_patches">Execute patches</string>
@ -344,13 +347,19 @@
<string name="submit_feedback_description">Help us improve this application</string>
<string name="developer_options">Developer options</string>
<string name="developer_options_description">Options for debugging issues</string>
<string name="patches_update_success">Update successful</string>
<string name="patches_update_unavailable">No update available</string>
<string name="view_patches">View patches</string>
<string name="patches_view_any_version">Any version</string>
<string name="patches_view_any_package">Any package</string>
<string name="patches_delete_single_dialog_description">Are you sure you want to delete \"%s\"?</string>
<string name="patches_delete_multiple_dialog_description">Are you sure you want to delete the selected patches?</string>
<string name="bundle_input_source_url">Source URL</string>
<string name="bundle_update_success">Successfully updated %s</string>
<string name="bundle_update_unavailable">No update available for %s</string>
<string name="bundle_auto_update">Auto update</string>
<string name="bundle_auto_update_description">Automatically update this bundle when ReVanced starts</string>
<string name="bundle_view_patches">View patches</string>
<string name="bundle_view_patches_any_version">Any version</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="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>
@ -380,7 +389,7 @@
<string name="save_with_count">Save (%1$s)</string>
<string name="update">Update</string>
<string name="empty">Empty</string>
<string name="installing_message">Tap on <b>Update</b> when prompted.\nReVanced Manager will close when updating.</string>
<string name="installing_message">Tap on <b>Update</b> when prompted. \n ReVanced Manager will close when updating.</string>
<string name="no_changelogs_found">No changelogs found</string>
<string name="just_now">Just now</string>
<string name="minutes_ago">%sm ago</string>
@ -404,9 +413,10 @@
<string name="no_contributors_found">No contributors found</string>
<string name="select">Select</string>
<string name="select_deselect_all">Select or deselect all</string>
<string name="select_patches_type_dialog_description">Add new patches from URL or local files</string>
<string name="local_patches_description">Add patches from local storage.</string>
<string name="remote_patches_description">Add patches from URL. Patches can automatically update.</string>
<string name="select_bundle_type_dialog_title">Add new bundle</string>
<string name="select_bundle_type_dialog_description">Add a new bundle from a URL or storage</string>
<string name="local_bundle_description">Import local files from your storage, does not 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="installation_failed_dialog_title">Installation failed</string>
@ -431,10 +441,9 @@
<string name="about_device">About device</string>
<string name="enter_url">Enter URL</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="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_patch">Incompatible patch</string>
<string name="any_version">Any</string>