feat: allow bundles to use classes from other bundles (#1951)

This commit is contained in:
Ax333l
2025-07-15 14:28:40 +02:00
committed by oSumAtrIX
parent a8820a4daf
commit af8e2b44c0
36 changed files with 1324 additions and 1062 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,55 +3,78 @@ package app.revanced.manager.domain.repository
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.annotation.StringRes
import app.revanced.library.mostCommonCompatibleVersions import app.revanced.library.mostCommonCompatibleVersions
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.data.redux.Action
import app.revanced.manager.data.redux.ActionContext
import app.revanced.manager.data.redux.Store
import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.bundles.PatchBundleEntity import app.revanced.manager.data.room.bundles.PatchBundleEntity
import app.revanced.manager.data.room.bundles.PatchBundleProperties
import app.revanced.manager.data.room.bundles.Source
import app.revanced.manager.domain.bundles.APIPatchBundle import app.revanced.manager.domain.bundles.APIPatchBundle
import app.revanced.manager.domain.bundles.JsonPatchBundle import app.revanced.manager.domain.bundles.JsonPatchBundle
import app.revanced.manager.data.room.bundles.Source as SourceInfo import app.revanced.manager.data.room.bundles.Source as SourceInfo
import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.domain.bundles.RemotePatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.flatMapLatestAndCombine import app.revanced.manager.patcher.patch.PatchBundle
import app.revanced.manager.patcher.patch.PatchBundleInfo
import app.revanced.manager.util.simpleMessage
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import app.revanced.manager.util.uiSafe import app.revanced.manager.util.toast
import kotlinx.collections.immutable.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.InputStream import java.io.InputStream
import kotlin.collections.joinToString
import kotlin.collections.map
import kotlin.text.ifEmpty
class PatchBundleRepository( class PatchBundleRepository(
private val app: Application, private val app: Application,
private val persistenceRepo: PatchBundlePersistenceRepository,
private val networkInfo: NetworkInfo, private val networkInfo: NetworkInfo,
private val prefs: PreferencesManager, private val prefs: PreferencesManager,
db: AppDatabase,
) { ) {
private val dao = db.patchBundleDao()
private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE) private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE)
private val _sources: MutableStateFlow<Map<Int, PatchBundleSource>> = private val store = Store(CoroutineScope(Dispatchers.Default), State())
MutableStateFlow(emptyMap())
val sources = _sources.map { it.values.toList() }
val bundles = sources.flatMapLatestAndCombine( val sources = store.state.map { it.sources.values.toList() }
combiner = { val bundles = store.state.map {
it.mapNotNull { (uid, state) -> it.sources.mapNotNull { (uid, src) ->
val bundle = state.patchBundleOrNull() ?: return@mapNotNull null uid to (src.patchBundle ?: return@mapNotNull null)
uid to bundle }.toMap()
}.toMap() }
val bundleInfoFlow = store.state.map { it.info }
fun scopedBundleInfoFlow(packageName: String, version: String?) = bundleInfoFlow.map {
it.map { (_, bundleInfo) ->
bundleInfo.forPackage(
packageName,
version
)
} }
) {
it.state.map { state -> it.uid to state }
} }
val suggestedVersions = bundles.map { val patchCountsFlow = bundleInfoFlow.map { it.mapValues { (_, info) -> info.patches.size } }
val suggestedVersions = bundleInfoFlow.map {
val allPatches = val allPatches =
it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet() it.values.flatMap { bundle -> bundle.patches.map(PatchInfo::toPatcherPatch) }.toSet()
@@ -74,6 +97,100 @@ class PatchBundleRepository(
} }
} }
private suspend inline fun dispatchAction(
name: String,
crossinline block: suspend ActionContext.(current: State) -> State
) {
store.dispatch(object : Action<State> {
override suspend fun ActionContext.execute(current: State) = block(current)
override fun toString() = name
})
}
/**
* Performs a reload. Do not call this outside of a store action.
*/
private suspend fun doReload(): State {
val entities = loadFromDb().onEach {
Log.d(tag, "Bundle: $it")
}
val sources = entities.associate { it.uid to it.load() }.toPersistentMap()
val hasOutOfDateNames = sources.values.any { it.isNameOutOfDate }
if (hasOutOfDateNames) dispatchAction(
"Sync names"
) { state ->
val nameChanges = state.sources.mapNotNull { (_, src) ->
if (!src.isNameOutOfDate) return@mapNotNull null
val newName = src.patchBundle?.manifestAttributes?.name?.takeIf { it != src.name }
?: return@mapNotNull null
src.uid to newName
}
val sources = state.sources.toMutableMap()
val info = state.info.toMutableMap()
nameChanges.forEach { (uid, name) ->
updateDb(uid) { it.copy(name = name) }
sources[uid] = sources[uid]!!.copy(name = name)
info[uid] = info[uid]?.copy(name = name) ?: return@forEach
}
State(sources.toPersistentMap(), info.toPersistentMap())
}
val info = loadMetadata(sources).toPersistentMap()
return State(sources, info)
}
suspend fun reload() = dispatchAction("Full reload") {
doReload()
}
private suspend fun loadFromDb(): List<PatchBundleEntity> {
val all = dao.all()
if (all.isEmpty()) {
dao.upsert(defaultSource)
return listOf(defaultSource)
}
return all
}
private suspend fun loadMetadata(sources: Map<Int, PatchBundleSource>): Map<Int, PatchBundleInfo.Global> {
// Map bundles -> sources
val map = sources.mapNotNull { (_, src) ->
(src.patchBundle ?: return@mapNotNull null) to src
}.toMap()
val metadata = try {
PatchBundle.Loader.metadata(map.keys)
} catch (error: Throwable) {
val uids = map.values.map { it.uid }
dispatchAction("Mark bundles as failed") { state ->
state.copy(sources = state.sources.mutate {
uids.forEach { uid ->
it[uid] = it[uid]?.copy(error = error) ?: return@forEach
}
})
}
Log.e(tag, "Failed to load bundles", error)
emptyMap()
}
return metadata.entries.associate { (bundle, patches) ->
val src = map[bundle]!!
src.uid to PatchBundleInfo.Global(
src.name,
bundle.manifestAttributes?.version,
src.uid,
patches
)
}
}
suspend fun isVersionAllowed(packageName: String, version: String) = suspend fun isVersionAllowed(packageName: String, version: String) =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
if (!prefs.suggestedVersionSafeguard.get()) return@withContext true if (!prefs.suggestedVersionSafeguard.get()) return@withContext true
@@ -89,96 +206,211 @@ class PatchBundleRepository(
private fun PatchBundleEntity.load(): PatchBundleSource { private fun PatchBundleEntity.load(): PatchBundleSource {
val dir = directoryOf(uid) val dir = directoryOf(uid)
val actualName =
name.ifEmpty { app.getString(if (uid == 0) R.string.patches_name_default else R.string.patches_name_fallback) }
return when (source) { return when (source) {
is SourceInfo.Local -> LocalPatchBundle(name, uid, dir) is SourceInfo.Local -> LocalPatchBundle(actualName, uid, null, dir)
is SourceInfo.API -> APIPatchBundle(name, uid, dir, SourceInfo.API.SENTINEL) is SourceInfo.API -> APIPatchBundle(
is SourceInfo.Remote -> JsonPatchBundle( actualName,
name,
uid, uid,
versionHash,
null,
dir, dir,
source.url.toString() SourceInfo.API.SENTINEL,
autoUpdate,
)
is SourceInfo.Remote -> JsonPatchBundle(
actualName,
uid,
versionHash,
null,
dir,
source.url.toString(),
autoUpdate,
) )
} }
} }
suspend fun reload() = withContext(Dispatchers.Default) { private suspend fun createEntity(name: String, source: Source, autoUpdate: Boolean = false) =
val entities = persistenceRepo.loadConfiguration().onEach { PatchBundleEntity(
Log.d(tag, "Bundle: $it") uid = generateUid(),
name = name,
versionHash = null,
source = source,
autoUpdate = autoUpdate
).also {
dao.upsert(it)
} }
_sources.value = entities.associate { /**
it.uid to it.load() * Updates a patch bundle in the database. Do not use this outside an action.
} */
private suspend fun updateDb(
uid: Int,
block: (PatchBundleProperties) -> PatchBundleProperties
) {
val previous = dao.getProps(uid)!!
val new = block(previous)
dao.upsert(
PatchBundleEntity(
uid = uid,
name = new.name,
versionHash = new.versionHash,
source = new.source,
autoUpdate = new.autoUpdate,
)
)
} }
suspend fun reset() = withContext(Dispatchers.Default) { suspend fun reset() = dispatchAction("Reset") { state ->
persistenceRepo.reset() dao.reset()
_sources.value = emptyMap() state.sources.keys.forEach { directoryOf(it).deleteRecursively() }
bundlesDir.apply { doReload()
deleteRecursively()
mkdirs()
}
reload()
} }
suspend fun remove(bundle: PatchBundleSource) = withContext(Dispatchers.Default) { suspend fun remove(vararg bundles: PatchBundleSource) =
persistenceRepo.delete(bundle.uid) dispatchAction("Remove (${bundles.map { it.uid }.joinToString(",")})") { state ->
directoryOf(bundle.uid).deleteRecursively() val sources = state.sources.toMutableMap()
val info = state.info.toMutableMap()
bundles.forEach {
if (it.isDefault) return@forEach
_sources.update { dao.remove(it.uid)
it.filterKeys { key -> directoryOf(it.uid).deleteRecursively()
key != bundle.uid sources.remove(it.uid)
info.remove(it.uid)
} }
}
}
private fun addBundle(patchBundle: PatchBundleSource) = State(sources.toPersistentMap(), info.toPersistentMap())
_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()
} }
reload() suspend fun createLocal(createStream: suspend () -> InputStream) = dispatchAction("Add bundle") {
} with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) {
try {
suspend fun redownloadRemoteBundles() = createStream().use { patches -> replace(patches) }
getBundlesByType<RemotePatchBundle>().forEach { it.downloadLatest() } } catch (e: Exception) {
if (e is CancellationException) throw e
suspend fun updateCheck() = Log.e(tag, "Got exception while importing bundle", e)
uiSafe(app, R.string.patches_download_fail, "Failed to update bundles") { withContext(Dispatchers.Main) {
coroutineScope { app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage()))
if (!networkInfo.isSafe()) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope
} }
getBundlesByType<RemotePatchBundle>().forEach { deleteLocalFile()
launch { }
if (!it.getProps().autoUpdate) return@launch }
Log.d(tag, "Updating patch bundle: ${it.getName()}")
it.update() 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.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
} }
} }
.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,80 +1,84 @@
package app.revanced.manager.patcher.patch package app.revanced.manager.patcher.patch
import android.util.Log import kotlinx.parcelize.IgnoredOnParcel
import app.revanced.manager.util.tag import android.os.Parcelable
import app.revanced.patcher.patch.Patch import app.revanced.patcher.patch.loadPatchesFromDex
import app.revanced.patcher.patch.PatchLoader import kotlinx.parcelize.Parcelize
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.jar.JarFile import java.util.jar.JarFile
import kotlin.collections.filter
class PatchBundleManifestAttributes( @Parcelize
val name: String?, data class PatchBundle(val patchesJar: String) : Parcelable {
val version: String?,
val description: String?,
val source: String?,
val author: String?,
val contact: String?,
val website: String?,
val license: String?
)
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]. * The [java.util.jar.Manifest] of [patchesJar].
*/ */
private val manifest = try { @IgnoredOnParcel
JarFile(patchesJar).use { it.manifest } private val manifest by lazy {
} catch (_: IOException) { try {
null JarFile(patchesJar).use { it.manifest }
} catch (_: IOException) {
null
}
} }
val patchBundleManifestAttributes = if(manifest != null) @IgnoredOnParcel
PatchBundleManifestAttributes( val manifestAttributes by lazy {
name = readManifestAttribute("name"), if (manifest != null)
version = readManifestAttribute("version"), ManifestAttributes(
description = readManifestAttribute("description"), name = readManifestAttribute("name"),
source = readManifestAttribute("source"), version = readManifestAttribute("version"),
author = readManifestAttribute("author"), description = readManifestAttribute("description"),
contact = readManifestAttribute("contact"), source = readManifestAttribute("source"),
website = readManifestAttribute("website"), author = readManifestAttribute("author"),
license = readManifestAttribute("license") contact = readManifestAttribute("contact"),
) else website = readManifestAttribute("website"),
license = readManifestAttribute("license")
) else
null null
}
private fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)?.takeIf { it.isNotBlank() } // If empty, set it to null instead. private fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)
?.takeIf { it.isNotBlank() } // If empty, set it to null instead.
/** data class ManifestAttributes(
* Load all patches compatible with the specified package. val name: String?,
*/ val version: String?,
fun patches(packageName: String) = loader.filter { patch -> val description: String?,
val compatiblePackages = patch.compatiblePackages val source: String?,
?: // The patch has no compatibility constraints, which means it is universal. val author: String?,
return@filter true val contact: String?,
val website: String?,
val license: String?
)
if (!compatiblePackages.any { (name, _) -> name == packageName }) { object Loader {
// Patch is not compatible with this package. private fun patches(bundles: Iterable<PatchBundle>) =
return@filter false loadPatchesFromDex(
} bundles.map { File(it.patchesJar) }.toSet()
).byPatchesFile.mapKeys { (file, _) ->
val absPath = file.absolutePath
bundles.single { absPath == it.patchesJar }
}
true 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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,198 +0,0 @@
package app.revanced.manager.ui.component.bundle
import android.webkit.URLUtil
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.outlined.ArrowRight
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.outlined.Commit
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.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.revanced.manager.R
import app.revanced.manager.patcher.patch.PatchBundleManifestAttributes
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,
remoteUrl: String?,
onRemoteUrlChange: ((String) -> Unit)? = null,
patchCount: Int,
version: String?,
autoUpdate: Boolean,
bundleManifestAttributes: PatchBundleManifestAttributes?,
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)
) {
version?.let {
Tag(Icons.Outlined.Sell, 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 (remoteUrl != null) {
BundleListItem(
headlineText = stringResource(R.string.auto_update),
supportingText = stringResource(R.string.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.patches_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.patches_url),
supportingText = url.ifEmpty {
stringResource(R.string.field_not_set)
}
)
}
val patchesClickable = patchCount > 0
BundleListItem(
headlineText = stringResource(R.string.patches),
supportingText = stringResource(R.string.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,
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

@@ -1,69 +1,99 @@
package app.revanced.manager.ui.component.bundle package app.revanced.manager.ui.component.bundle
import android.webkit.URLUtil.isValidUrl
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.outlined.ArrowRight import androidx.compose.material.icons.automirrored.outlined.ArrowRight
import androidx.compose.material.icons.automirrored.outlined.Send
import androidx.compose.material.icons.outlined.Commit
import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Gavel
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Sell
import androidx.compose.material.icons.outlined.Update import androidx.compose.material.icons.outlined.Update
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.unit.dp
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.R.string.auto_update
import app.revanced.manager.R.string.auto_update_description
import app.revanced.manager.R.string.field_not_set
import app.revanced.manager.R.string.patches
import app.revanced.manager.R.string.patches_url
import app.revanced.manager.R.string.view_patches
import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.data.platform.NetworkInfo
import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.LocalPatchBundle
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.ui.component.ColumnWithScrollbar
import app.revanced.manager.ui.component.ExceptionViewerDialog import app.revanced.manager.ui.component.ExceptionViewerDialog
import app.revanced.manager.ui.component.FullscreenDialog import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.TextInputDialog
import app.revanced.manager.ui.component.haptics.HapticSwitch
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BundleInformationDialog( fun BundleInformationDialog(
src: PatchBundleSource,
patchCount: Int,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onDeleteRequest: () -> Unit, onDeleteRequest: () -> Unit,
bundle: PatchBundleSource,
onUpdate: () -> Unit, onUpdate: () -> Unit,
) { ) {
val bundleRepo = koinInject<PatchBundleRepository>()
val networkInfo = koinInject<NetworkInfo>() val networkInfo = koinInject<NetworkInfo>()
val hasNetwork = remember { networkInfo.isConnected() } val hasNetwork = remember { networkInfo.isConnected() }
val composableScope = rememberCoroutineScope() val composableScope = rememberCoroutineScope()
var viewCurrentBundlePatches by remember { mutableStateOf(false) } var viewCurrentBundlePatches by remember { mutableStateOf(false) }
val isLocal = bundle is LocalPatchBundle val isLocal = src is LocalPatchBundle
val state by bundle.state.collectAsStateWithLifecycle() val bundleManifestAttributes = src.patchBundle?.manifestAttributes
val props by remember(bundle) { val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.endpoint } ?: (null to null)
bundle.propsFlow()
}.collectAsStateWithLifecycle(null) fun onAutoUpdateChange(new: Boolean) = composableScope.launch {
val patchCount by bundle.patchCountFlow.collectAsStateWithLifecycle(0) with(bundleRepo) {
val version by bundle.versionFlow.collectAsStateWithLifecycle(null) src.asRemoteOrNull?.setAutoUpdate(new)
val bundleManifestAttributes = state.patchBundleOrNull()?.patchBundleManifestAttributes }
}
if (viewCurrentBundlePatches) { if (viewCurrentBundlePatches) {
BundlePatchesDialog( BundlePatchesDialog(
src = src,
onDismissRequest = { onDismissRequest = {
viewCurrentBundlePatches = false viewCurrentBundlePatches = false
}, }
bundle = bundle
) )
} }
FullscreenDialog( FullscreenDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
) { ) {
val bundleName by bundle.nameState
Scaffold( Scaffold(
topBar = { topBar = {
BundleTopBar( BundleTopBar(
title = bundleName, title = src.name,
onBackClick = onDismissRequest, onBackClick = onDismissRequest,
backIcon = { backIcon = {
Icon( Icon(
@@ -72,7 +102,7 @@ fun BundleInformationDialog(
) )
}, },
actions = { actions = {
if (!bundle.isDefault) { if (!src.isDefault) {
IconButton(onClick = onDeleteRequest) { IconButton(onClick = onDeleteRequest) {
Icon( Icon(
Icons.Outlined.DeleteOutline, Icons.Outlined.DeleteOutline,
@@ -92,54 +122,175 @@ fun BundleInformationDialog(
) )
}, },
) { paddingValues -> ) { paddingValues ->
BaseBundleDialog( ColumnWithScrollbar(
modifier = Modifier.padding(paddingValues), modifier = Modifier
isDefault = bundle.isDefault, .fillMaxWidth()
remoteUrl = bundle.asRemoteOrNull?.endpoint, .padding(paddingValues),
patchCount = patchCount, ) {
version = version, Column(
autoUpdate = props?.autoUpdate == true, modifier = Modifier.padding(16.dp),
bundleManifestAttributes = bundleManifestAttributes, verticalArrangement = Arrangement.spacedBy(4.dp)
onAutoUpdateChange = { ) {
composableScope.launch { Tag(Icons.Outlined.Sell, src.name)
bundle.asRemoteOrNull?.setAutoUpdate(it) bundleManifestAttributes?.description?.let {
Tag(Icons.Outlined.Description, it)
} }
}, bundleManifestAttributes?.source?.let {
onPatchesClick = { Tag(Icons.Outlined.Commit, it)
viewCurrentBundlePatches = true }
}, bundleManifestAttributes?.author?.let {
extraFields = { Tag(Icons.Outlined.Person, it)
(state as? PatchBundleSource.State.Failed)?.throwable?.let { }
var showDialog by rememberSaveable { bundleManifestAttributes?.contact?.let {
mutableStateOf(false) Tag(Icons.AutoMirrored.Outlined.Send, it)
}
bundleManifestAttributes?.website?.let {
Tag(Icons.Outlined.Language, it, isUrl = true)
}
bundleManifestAttributes?.license?.let {
Tag(Icons.Outlined.Gavel, it)
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
if (autoUpdate != null) {
BundleListItem(
headlineText = stringResource(auto_update),
supportingText = stringResource(auto_update_description),
trailingContent = {
HapticSwitch(
checked = autoUpdate,
onCheckedChange = ::onAutoUpdateChange
)
},
modifier = Modifier.clickable {
onAutoUpdateChange(!autoUpdate)
} }
if (showDialog) ExceptionViewerDialog( )
onDismiss = { showDialog = false }, }
text = remember(it) { it.stackTraceToString() }
)
BundleListItem( endpoint?.takeUnless { src.isDefault }?.let { url ->
headlineText = stringResource(R.string.patches_error), var showUrlInputDialog by rememberSaveable {
supportingText = stringResource(R.string.patches_error_description), mutableStateOf(false)
trailingContent = { }
Icon( if (showUrlInputDialog) {
Icons.AutoMirrored.Outlined.ArrowRight, TextInputDialog(
null initial = url,
) title = stringResource(patches_url),
onDismissRequest = { showUrlInputDialog = false },
onConfirm = {
showUrlInputDialog = false
TODO("Not implemented.")
}, },
modifier = Modifier.clickable { showDialog = true } validator = {
if (it.isEmpty()) return@TextInputDialog false
isValidUrl(it)
}
) )
} }
if (state is PatchBundleSource.State.Missing && !isLocal) { BundleListItem(
BundleListItem( modifier = Modifier.clickable(
headlineText = stringResource(R.string.patches_error), enabled = false,
supportingText = stringResource(R.string.patches_not_downloaded), onClick = {
modifier = Modifier.clickable(onClick = onUpdate) 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)
) )
} }
} }
)
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,38 +24,32 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState
import app.revanced.manager.ui.component.ConfirmDialog import app.revanced.manager.ui.component.ConfirmDialog
import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticCheckbox
import kotlinx.coroutines.flow.map
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun BundleItem( fun BundleItem(
bundle: PatchBundleSource, src: PatchBundleSource,
onDelete: () -> Unit, patchCount: Int,
onUpdate: () -> Unit,
selectable: Boolean, selectable: Boolean,
onSelect: () -> Unit,
isBundleSelected: Boolean, isBundleSelected: Boolean,
toggleSelection: (Boolean) -> Unit, toggleSelection: (Boolean) -> Unit,
onSelect: () -> Unit,
onDelete: () -> Unit,
onUpdate: () -> Unit,
) { ) {
var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) } var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) }
var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) }
val state by bundle.state.collectAsStateWithLifecycle()
val version by bundle.versionFlow.collectAsStateWithLifecycle(null)
val patchCount by bundle.patchCountFlow.collectAsStateWithLifecycle(0)
val name by bundle.nameState
if (viewBundleDialogPage) { if (viewBundleDialogPage) {
BundleInformationDialog( BundleInformationDialog(
src = src,
patchCount = patchCount,
onDismissRequest = { viewBundleDialogPage = false }, onDismissRequest = { viewBundleDialogPage = false },
onDeleteRequest = { showDeleteConfirmationDialog = true }, onDeleteRequest = { showDeleteConfirmationDialog = true },
bundle = bundle,
onUpdate = onUpdate, onUpdate = onUpdate,
) )
} }
@@ -68,7 +62,7 @@ fun BundleItem(
viewBundleDialogPage = false viewBundleDialogPage = false
}, },
title = stringResource(R.string.delete), title = stringResource(R.string.delete),
description = stringResource(R.string.patches_delete_single_dialog_description, name), description = stringResource(R.string.patches_delete_single_dialog_description, src.name),
icon = Icons.Outlined.Delete icon = Icons.Outlined.Delete
) )
} }
@@ -90,19 +84,19 @@ fun BundleItem(
} }
} else null, } else null,
headlineContent = { Text(name) }, headlineContent = { Text(src.name) },
supportingContent = { supportingContent = {
if (state is PatchBundleSource.State.Loaded) { if (src.state is PatchBundleSource.State.Available) {
Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount)) Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount))
} }
}, },
trailingContent = { trailingContent = {
Row { Row {
val icon = remember(state) { val icon = remember(src.state) {
when (state) { when (src.state) {
is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.patches_error 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.Missing -> Icons.Outlined.Warning to R.string.patches_missing
is PatchBundleSource.State.Loaded -> null is PatchBundleSource.State.Available -> null
} }
} }
@@ -115,7 +109,7 @@ fun BundleItem(
) )
} }
version?.let { Text(text = it) } src.version?.let { Text(text = it) }
} }
}, },
) )

View File

@@ -12,6 +12,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -25,20 +26,26 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.component.ArrowButton import app.revanced.manager.ui.component.ArrowButton
import app.revanced.manager.ui.component.FullscreenDialog import app.revanced.manager.ui.component.FullscreenDialog
import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import kotlinx.coroutines.flow.mapNotNull
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BundlePatchesDialog( fun BundlePatchesDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
bundle: PatchBundleSource, src: PatchBundleSource,
) { ) {
var showAllVersions by rememberSaveable { mutableStateOf(false) } var showAllVersions by rememberSaveable { mutableStateOf(false) }
var showOptions by rememberSaveable { mutableStateOf(false) } var showOptions by rememberSaveable { mutableStateOf(false) }
val state by bundle.state.collectAsStateWithLifecycle() val patchBundleRepository: PatchBundleRepository = koinInject()
val patches by remember(src.uid) {
patchBundleRepository.bundleInfoFlow.mapNotNull { it[src.uid]?.patches }
}.collectAsStateWithLifecycle(emptyList())
FullscreenDialog( FullscreenDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@@ -64,16 +71,14 @@ fun BundlePatchesDialog(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(16.dp) contentPadding = PaddingValues(16.dp)
) { ) {
state.patchBundleOrNull()?.let { bundle -> items(patches) { patch ->
items(bundle.patches) { patch -> PatchItem(
PatchItem( patch,
patch, showAllVersions,
showAllVersions, onExpandVersions = { showAllVersions = !showAllVersions },
onExpandVersions = { showAllVersions = !showAllVersions }, showOptions,
showOptions, onExpandOptions = { showOptions = !showOptions }
onExpandOptions = { showOptions = !showOptions } )
)
}
} }
} }
} }

View File

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

View File

@@ -23,10 +23,14 @@ import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.TextHorizontalPadding import app.revanced.manager.ui.component.TextHorizontalPadding
import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticCheckbox
import app.revanced.manager.ui.component.haptics.HapticRadioButton import app.revanced.manager.ui.component.haptics.HapticRadioButton
import app.revanced.manager.ui.model.BundleType
import app.revanced.manager.util.BIN_MIMETYPE import app.revanced.manager.util.BIN_MIMETYPE
import app.revanced.manager.util.transparentListItemColors import app.revanced.manager.util.transparentListItemColors
private enum class BundleType {
Local,
Remote
}
@Composable @Composable
fun ImportPatchBundleDialog( fun ImportPatchBundleDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -37,7 +41,7 @@ fun ImportPatchBundleDialog(
var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) } var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) }
var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) } var patchBundle by rememberSaveable { mutableStateOf<Uri?>(null) }
var remoteUrl by rememberSaveable { mutableStateOf("") } var remoteUrl by rememberSaveable { mutableStateOf("") }
var autoUpdate by rememberSaveable { mutableStateOf(false) } var autoUpdate by rememberSaveable { mutableStateOf(true) }
val patchActivityLauncher = val patchActivityLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
@@ -117,7 +121,7 @@ fun ImportPatchBundleDialog(
} }
@Composable @Composable
fun SelectBundleTypeStep( private fun SelectBundleTypeStep(
bundleType: BundleType, bundleType: BundleType,
onBundleTypeSelected: (BundleType) -> Unit onBundleTypeSelected: (BundleType) -> Unit
) { ) {
@@ -168,7 +172,7 @@ fun SelectBundleTypeStep(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ImportBundleStep( private fun ImportBundleStep(
bundleType: BundleType, bundleType: BundleType,
patchBundle: Uri?, patchBundle: Uri?,
remoteUrl: String, remoteUrl: String,

View File

@@ -1,111 +0,0 @@
package app.revanced.manager.ui.model
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.util.PatchSelection
import app.revanced.manager.util.flatMapLatestAndCombine
import kotlinx.coroutines.flow.map
/**
* A data class that contains patch bundle metadata for use by UI code.
*/
data class BundleInfo(
val name: String,
val version: String?,
val uid: Int,
val compatible: List<PatchInfo>,
val incompatible: List<PatchInfo>,
val universal: List<PatchInfo>
) {
val all = sequence {
yieldAll(compatible)
yieldAll(incompatible)
yieldAll(universal)
}
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.patchBundleManifestAttributes?.version, source.uid, compatible, incompatible, universal)
}
}
/**
* Algorithm for determining whether all required options have been set.
*/
inline fun Iterable<BundleInfo>.requiredOptionsSet(
crossinline isSelected: (BundleInfo, PatchInfo) -> Boolean,
crossinline optionsForPatch: (BundleInfo, PatchInfo) -> Map<String, Any?>?
) = all bundle@{ bundle ->
bundle
.all
.filter { isSelected(bundle, it) }
.all patch@{
if (it.options.isNullOrEmpty()) return@patch true
val opts by lazy { optionsForPatch(bundle, it).orEmpty() }
it.options.all option@{ option ->
if (!option.required || option.default != null) return@option true
option.key in opts
}
}
}
}
}
enum class BundleType {
Local,
Remote
}

View File

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

View File

@@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -56,7 +57,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault
import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.patcher.aapt.Aapt
import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.AlertDialogExtended
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
@@ -93,7 +93,8 @@ fun DashboardScreen(
onDownloaderPluginClick: () -> Unit, onDownloaderPluginClick: () -> Unit,
onAppClick: (String) -> Unit onAppClick: (String) -> Unit
) { ) {
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.isNotEmpty() } } var selectedSourceCount by rememberSaveable { mutableIntStateOf(0) }
val bundlesSelectable by remember { derivedStateOf { selectedSourceCount > 0 } }
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0) val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle( val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle(
false false
@@ -160,10 +161,7 @@ fun DashboardScreen(
if (showDeleteConfirmationDialog) { if (showDeleteConfirmationDialog) {
ConfirmDialog( ConfirmDialog(
onDismiss = { showDeleteConfirmationDialog = false }, onDismiss = { showDeleteConfirmationDialog = false },
onConfirm = { onConfirm = vm::deleteSources,
vm.selectedSources.forEach { if (!it.isDefault) vm.delete(it) }
vm.cancelSourceSelection()
},
title = stringResource(R.string.delete), title = stringResource(R.string.delete),
description = stringResource(R.string.patches_delete_multiple_dialog_description), description = stringResource(R.string.patches_delete_multiple_dialog_description),
icon = Icons.Outlined.Delete icon = Icons.Outlined.Delete
@@ -174,7 +172,7 @@ fun DashboardScreen(
topBar = { topBar = {
if (bundlesSelectable) { if (bundlesSelectable) {
BundleTopBar( BundleTopBar(
title = stringResource(R.string.patches_selected, vm.selectedSources.size), title = stringResource(R.string.patches_selected, selectedSourceCount),
onBackClick = vm::cancelSourceSelection, onBackClick = vm::cancelSourceSelection,
backIcon = { backIcon = {
Icon( Icon(
@@ -194,10 +192,7 @@ fun DashboardScreen(
) )
} }
IconButton( IconButton(
onClick = { onClick = vm::updateSources
vm.selectedSources.forEach { vm.update(it) }
vm.cancelSourceSelection()
}
) { ) {
Icon( Icon(
Icons.Outlined.Refresh, Icons.Outlined.Refresh,
@@ -349,18 +344,9 @@ fun DashboardScreen(
} }
} }
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
BundleListScreen( BundleListScreen(
onDelete = { eventsFlow = vm.bundleListEventsFlow,
vm.delete(it) setSelectedSourceCount = { selectedSourceCount = it }
},
onUpdate = {
vm.update(it)
},
sources = sources,
selectedSources = vm.selectedSources,
bundlesSelectable = bundlesSelectable
) )
} }
} }

View File

@@ -413,7 +413,7 @@ fun PatchesSelectorScreen(
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
Text( Text(
text = bundle.version!!, text = bundle.version.orEmpty(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -225,7 +225,7 @@
<string name="download_app">Download app</string> <string name="download_app">Download app</string>
<string name="download_apk">Download APK file</string> <string name="download_apk">Download APK file</string>
<string name="patches_download_fail">Failed to download patches: %s</string> <string name="patches_download_fail">Failed to download patches: %s</string>
<string name="patches_replace_fail">Failed to load updated patches: %s</string> <string name="patches_replace_fail">Failed to import patches: %s</string>
<string name="no_patched_apps_found">No patched apps found</string> <string name="no_patched_apps_found">No patched apps found</string>
<string name="tap_on_patches">Tap on the patches to get more information about them</string> <string name="tap_on_patches">Tap on the patches to get more information about them</string>
<string name="patches_selected">%s selected</string> <string name="patches_selected">%s selected</string>
@@ -344,8 +344,8 @@
<string name="submit_feedback_description">Help us improve this application</string> <string name="submit_feedback_description">Help us improve this application</string>
<string name="developer_options">Developer options</string> <string name="developer_options">Developer options</string>
<string name="developer_options_description">Options for debugging issues</string> <string name="developer_options_description">Options for debugging issues</string>
<string name="patches_update_success">Successfully updated %s</string> <string name="patches_update_success">Update successful</string>
<string name="patches_update_unavailable">No update available for %s</string> <string name="patches_update_unavailable">No update available</string>
<string name="view_patches">View patches</string> <string name="view_patches">View patches</string>
<string name="patches_view_any_version">Any version</string> <string name="patches_view_any_version">Any version</string>
<string name="patches_view_any_package">Any package</string> <string name="patches_view_any_package">Any package</string>