diff --git a/app/src/main/java/app/revanced/manager/data/redux/Redux.kt b/app/src/main/java/app/revanced/manager/data/redux/Redux.kt new file mode 100644 index 00000000..785dedc4 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/redux/Redux.kt @@ -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(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>(capacity = 10) + private val lock = Mutex() + + suspend fun dispatch(action: Action) = 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 { + suspend fun ActionContext.execute(current: S): S + suspend fun catch(exception: Exception) { + Log.e(tag, "Got exception while executing $this", exception) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt index bf8b6224..38513365 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleDao.kt @@ -1,25 +1,15 @@ package app.revanced.manager.data.room.bundles import androidx.room.* -import kotlinx.coroutines.flow.Flow @Dao interface PatchBundleDao { @Query("SELECT * FROM patch_bundles") suspend fun all(): List - @Query("SELECT version, auto_update FROM patch_bundles WHERE uid = :uid") - fun getPropsById(uid: Int): Flow - @Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid") suspend fun updateVersionHash(uid: Int, patches: String?) - @Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid") - suspend fun setAutoUpdate(uid: Int, value: Boolean) - - @Query("UPDATE patch_bundles SET name = :value WHERE uid = :uid") - suspend fun setName(uid: Int, value: String) - @Query("DELETE FROM patch_bundles WHERE uid != 0") suspend fun purgeCustomBundles() @@ -32,6 +22,9 @@ interface PatchBundleDao { @Query("DELETE FROM patch_bundles WHERE uid = :uid") suspend fun remove(uid: Int) - @Insert - suspend fun add(source: PatchBundleEntity) + @Query("SELECT name, version, auto_update, source FROM patch_bundles WHERE uid = :uid") + suspend fun getProps(uid: Int): PatchBundleProperties? + + @Upsert + suspend fun upsert(source: PatchBundleEntity) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt index 8fa6e8a7..9119d500 100644 --- a/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt +++ b/app/src/main/java/app/revanced/manager/data/room/bundles/PatchBundleEntity.kt @@ -38,7 +38,9 @@ data class PatchBundleEntity( @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 = "source") val source: Source, @ColumnInfo(name = "auto_update") val autoUpdate: Boolean ) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt index 159436d4..5fce77ec 100644 --- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt @@ -15,7 +15,6 @@ val repositoryModule = module { createdAtStart() } singleOf(::NetworkInfo) - singleOf(::PatchBundlePersistenceRepository) singleOf(::PatchSelectionRepository) singleOf(::PatchOptionsRepository) singleOf(::PatchBundleRepository) { diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index 4846510f..6970e886 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -23,4 +23,5 @@ val viewModelModule = module { viewModelOf(::InstalledAppsViewModel) viewModelOf(::InstalledAppInfoViewModel) viewModelOf(::UpdatesSettingsViewModel) + viewModelOf(::BundleListViewModel) } diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt index a940e05d..ec05c8df 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/LocalPatchBundle.kt @@ -1,21 +1,29 @@ package app.revanced.manager.domain.bundles +import app.revanced.manager.data.redux.ActionContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.InputStream -class LocalPatchBundle(name: String, id: Int, directory: File) : - PatchBundleSource(name, id, directory) { - suspend fun replace(patches: InputStream) { +class LocalPatchBundle( + name: String, + uid: Int, + error: Throwable?, + directory: File +) : PatchBundleSource(name, uid, error, directory) { + suspend fun ActionContext.replace(patches: InputStream) { withContext(Dispatchers.IO) { patchBundleOutputStream().use { outputStream -> patches.copyTo(outputStream) } } - - reload()?.also { - saveVersionHash(it.patchBundleManifestAttributes?.version) - } } + + override fun copy(error: Throwable?, name: String) = LocalPatchBundle( + name, + uid, + error, + directory + ) } diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt index bd779dd1..8414e2e1 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/PatchBundleSource.kt @@ -1,22 +1,10 @@ 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.lifecycle.compose.collectAsStateWithLifecycle -import app.revanced.manager.R -import app.revanced.manager.domain.repository.PatchBundlePersistenceRepository +import app.revanced.manager.data.redux.ActionContext import app.revanced.manager.patcher.patch.PatchBundle -import app.revanced.manager.util.tag import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject +import kotlinx.coroutines.withContext import java.io.File import java.io.OutputStream @@ -24,27 +12,32 @@ import java.io.OutputStream * A [PatchBundle] source. */ @Stable -sealed class PatchBundleSource(initialName: String, val uid: Int, directory: File) : KoinComponent { - protected val configRepository: PatchBundlePersistenceRepository by inject() - private val app: Application by inject() +sealed class PatchBundleSource( + val name: String, + val uid: Int, + error: Throwable?, + protected val directory: File +) { protected val patchesFile = directory.resolve("patches.jar") - private val _state = MutableStateFlow(load()) - val state = _state.asStateFlow() + val state = when { + error != null -> State.Failed(error) + !hasInstalled() -> State.Missing + else -> State.Available(PatchBundle(patchesFile.absolutePath)) + } - private val _nameFlow = MutableStateFlow(initialName) - val nameFlow = - _nameFlow.map { it.ifEmpty { app.getString(if (isDefault) R.string.patches_name_default else R.string.patches_name_fallback) } } + val patchBundle get() = (state as? State.Available)?.bundle + val version get() = patchBundle?.manifestAttributes?.version + val isNameOutOfDate get() = patchBundle?.manifestAttributes?.name?.let { it != name } == true + val error get() = (state as? State.Failed)?.throwable - suspend fun getName() = nameFlow.first() + suspend fun ActionContext.deleteLocalFile() = withContext(Dispatchers.IO) { + patchesFile.delete() + } - val versionFlow = state.map { it.patchBundleOrNull()?.patchBundleManifestAttributes?.version } - val patchCountFlow = state.map { it.patchBundleOrNull()?.patches?.size ?: 0 } + abstract fun copy(error: Throwable? = this.error, name: String = this.name): PatchBundleSource - /** - * Returns true if the bundle has been downloaded to local storage. - */ - fun hasInstalled() = patchesFile.exists() + protected fun hasInstalled() = patchesFile.exists() protected fun patchBundleOutputStream(): OutputStream = with(patchesFile) { // 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 { - fun patchBundleOrNull(): PatchBundle? = null - data object Missing : State data class Failed(val throwable: Throwable) : State - data class Loaded(val bundle: PatchBundle) : State { - override fun patchBundleOrNull() = bundle - } + data class Available(val bundle: PatchBundle) : State } companion object Extensions { val PatchBundleSource.isDefault inline get() = uid == 0 val PatchBundleSource.asRemoteOrNull inline get() = this as? RemotePatchBundle - val PatchBundleSource.nameState - @Composable inline get() = nameFlow.collectAsStateWithLifecycle( - "" - ) } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt index 897312f7..ebec8471 100644 --- a/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/domain/bundles/RemotePatchBundle.kt @@ -1,6 +1,6 @@ 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.dto.ReVancedAsset 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File -@Stable -sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpoint: String) : - PatchBundleSource(name, id, directory) { +sealed class RemotePatchBundle( + name: String, + uid: Int, + protected val versionHash: String?, + error: Throwable?, + directory: File, + val endpoint: String, + val autoUpdate: Boolean, +) : PatchBundleSource(name, uid, error, directory), KoinComponent { protected val http: HttpService by inject() protected abstract suspend fun getLatestInfo(): ReVancedAsset + abstract fun copy(error: Throwable? = this.error, name: String = this.name, autoUpdate: Boolean = this.autoUpdate): RemotePatchBundle + override fun copy(error: Throwable?, name: String): RemotePatchBundle = copy(error, name, this.autoUpdate) private suspend fun download(info: ReVancedAsset) = withContext(Dispatchers.IO) { patchBundleOutputStream().use { @@ -25,47 +34,72 @@ sealed class RemotePatchBundle(name: String, id: Int, directory: File, val endpo } } - saveVersionHash(info.version) - reload() + info.version } - 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() - if (hasInstalled() && info.version == currentVersionHash()) - return@withContext false + if (hasInstalled() && info.version == versionHash) + return@withContext null download(info) - true } - suspend fun deleteLocalFiles() = withContext(Dispatchers.Default) { - patchesFile.delete() - reload() - } - - suspend fun setAutoUpdate(value: Boolean) = configRepository.setAutoUpdate(uid, value) - companion object { const val updateFailMsg = "Failed to update patches" } } -class JsonPatchBundle(name: String, id: Int, directory: File, endpoint: String) : - RemotePatchBundle(name, id, directory, endpoint) { +class JsonPatchBundle( + 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) { http.request { url(endpoint) }.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) : - RemotePatchBundle(name, id, directory, endpoint) { +class APIPatchBundle( + 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() override suspend fun getLatestInfo() = api.getPatchesUpdate().getOrThrow() + override fun copy(error: Throwable?, name: String, autoUpdate: Boolean) = APIPatchBundle( + name, + uid, + versionHash, + error, + directory, + endpoint, + autoUpdate, + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt deleted file mode 100644 index 16fb5976..00000000 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundlePersistenceRepository.kt +++ /dev/null @@ -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 { - 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 - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt index 3afe4e39..ab8dbb19 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchBundleRepository.kt @@ -3,55 +3,78 @@ package app.revanced.manager.domain.repository import android.app.Application import android.content.Context import android.util.Log +import androidx.annotation.StringRes import app.revanced.library.mostCommonCompatibleVersions import app.revanced.manager.R import app.revanced.manager.data.platform.NetworkInfo +import app.revanced.manager.data.redux.Action +import app.revanced.manager.data.redux.ActionContext +import app.revanced.manager.data.redux.Store +import app.revanced.manager.data.room.AppDatabase +import app.revanced.manager.data.room.AppDatabase.Companion.generateUid import app.revanced.manager.data.room.bundles.PatchBundleEntity +import app.revanced.manager.data.room.bundles.PatchBundleProperties +import app.revanced.manager.data.room.bundles.Source import app.revanced.manager.domain.bundles.APIPatchBundle import app.revanced.manager.domain.bundles.JsonPatchBundle import app.revanced.manager.data.room.bundles.Source as SourceInfo import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.RemotePatchBundle import app.revanced.manager.domain.bundles.PatchBundleSource +import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.patcher.patch.PatchInfo -import app.revanced.manager.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.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.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.InputStream +import kotlin.collections.joinToString +import kotlin.collections.map +import kotlin.text.ifEmpty class PatchBundleRepository( private val app: Application, - private val persistenceRepo: PatchBundlePersistenceRepository, private val networkInfo: NetworkInfo, private val prefs: PreferencesManager, + db: AppDatabase, ) { + private val dao = db.patchBundleDao() private val bundlesDir = app.getDir("patch_bundles", Context.MODE_PRIVATE) - private val _sources: MutableStateFlow> = - MutableStateFlow(emptyMap()) - val sources = _sources.map { it.values.toList() } + private val store = Store(CoroutineScope(Dispatchers.Default), State()) - val bundles = sources.flatMapLatestAndCombine( - combiner = { - it.mapNotNull { (uid, state) -> - val bundle = state.patchBundleOrNull() ?: return@mapNotNull null - uid to bundle - }.toMap() + val sources = store.state.map { it.sources.values.toList() } + val bundles = store.state.map { + it.sources.mapNotNull { (uid, src) -> + uid to (src.patchBundle ?: return@mapNotNull null) + }.toMap() + } + val bundleInfoFlow = store.state.map { it.info } + + fun scopedBundleInfoFlow(packageName: String, version: String?) = bundleInfoFlow.map { + it.map { (_, bundleInfo) -> + bundleInfo.forPackage( + packageName, + version + ) } - ) { - 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 = 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 { + 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 { + val all = dao.all() + if (all.isEmpty()) { + dao.upsert(defaultSource) + return listOf(defaultSource) + } + + return all + } + + private suspend fun loadMetadata(sources: Map): Map { + // Map bundles -> sources + val map = sources.mapNotNull { (_, src) -> + (src.patchBundle ?: return@mapNotNull null) to src + }.toMap() + + val metadata = try { + PatchBundle.Loader.metadata(map.keys) + } catch (error: Throwable) { + val uids = map.values.map { it.uid } + + dispatchAction("Mark bundles as failed") { state -> + state.copy(sources = state.sources.mutate { + uids.forEach { uid -> + it[uid] = it[uid]?.copy(error = error) ?: return@forEach + } + }) + } + + Log.e(tag, "Failed to load bundles", error) + emptyMap() + } + + return metadata.entries.associate { (bundle, patches) -> + val src = map[bundle]!! + src.uid to PatchBundleInfo.Global( + src.name, + bundle.manifestAttributes?.version, + src.uid, + patches + ) + } + } + suspend fun isVersionAllowed(packageName: String, version: String) = withContext(Dispatchers.Default) { if (!prefs.suggestedVersionSafeguard.get()) return@withContext true @@ -89,96 +206,211 @@ class PatchBundleRepository( private fun PatchBundleEntity.load(): PatchBundleSource { val dir = directoryOf(uid) + val actualName = + name.ifEmpty { app.getString(if (uid == 0) R.string.patches_name_default else R.string.patches_name_fallback) } return when (source) { - is SourceInfo.Local -> LocalPatchBundle(name, uid, dir) - is SourceInfo.API -> APIPatchBundle(name, uid, dir, SourceInfo.API.SENTINEL) - is SourceInfo.Remote -> JsonPatchBundle( - name, + is SourceInfo.Local -> LocalPatchBundle(actualName, uid, null, dir) + is SourceInfo.API -> APIPatchBundle( + actualName, uid, + versionHash, + null, 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) { - val entities = persistenceRepo.loadConfiguration().onEach { - Log.d(tag, "Bundle: $it") + private suspend fun createEntity(name: String, source: Source, autoUpdate: Boolean = false) = + PatchBundleEntity( + uid = generateUid(), + name = name, + versionHash = null, + source = source, + autoUpdate = autoUpdate + ).also { + dao.upsert(it) } - _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) { - persistenceRepo.reset() - _sources.value = emptyMap() - bundlesDir.apply { - deleteRecursively() - mkdirs() - } - - reload() + suspend fun reset() = dispatchAction("Reset") { state -> + dao.reset() + state.sources.keys.forEach { directoryOf(it).deleteRecursively() } + doReload() } - suspend fun remove(bundle: PatchBundleSource) = withContext(Dispatchers.Default) { - persistenceRepo.delete(bundle.uid) - directoryOf(bundle.uid).deleteRecursively() + suspend fun remove(vararg bundles: PatchBundleSource) = + dispatchAction("Remove (${bundles.map { it.uid }.joinToString(",")})") { state -> + val sources = state.sources.toMutableMap() + val info = state.info.toMutableMap() + bundles.forEach { + if (it.isDefault) return@forEach - _sources.update { - it.filterKeys { key -> - key != bundle.uid + dao.remove(it.uid) + directoryOf(it.uid).deleteRecursively() + sources.remove(it.uid) + info.remove(it.uid) } - } - } - private fun addBundle(patchBundle: PatchBundleSource) = - _sources.update { it.toMutableMap().apply { put(patchBundle.uid, patchBundle) } } - - suspend fun createLocal(patches: InputStream) = withContext(Dispatchers.Default) { - val uid = persistenceRepo.create("", SourceInfo.Local).uid - val bundle = LocalPatchBundle("", uid, directoryOf(uid)) - - bundle.replace(patches) - addBundle(bundle) - } - - suspend fun createRemote(url: String, autoUpdate: Boolean) = withContext(Dispatchers.Default) { - val entity = persistenceRepo.create("", SourceInfo.from(url), autoUpdate) - addBundle(entity.load()) - } - - private suspend inline fun getBundlesByType() = - sources.first().filterIsInstance() - - suspend fun reloadApiBundles() { - getBundlesByType().forEach { - it.deleteLocalFiles() + State(sources.toPersistentMap(), info.toPersistentMap()) } - reload() - } - - suspend fun redownloadRemoteBundles() = - getBundlesByType().forEach { it.downloadLatest() } - - suspend fun updateCheck() = - uiSafe(app, R.string.patches_download_fail, "Failed to update bundles") { - coroutineScope { - if (!networkInfo.isSafe()) { - Log.d(tag, "Skipping update check because the network is down or metered.") - return@coroutineScope + suspend fun createLocal(createStream: suspend () -> InputStream) = dispatchAction("Add bundle") { + with(createEntity("", SourceInfo.Local).load() as LocalPatchBundle) { + try { + createStream().use { patches -> replace(patches) } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e(tag, "Got exception while importing bundle", e) + withContext(Dispatchers.Main) { + app.toast(app.getString(R.string.patches_replace_fail, e.simpleMessage())) } - getBundlesByType().forEach { - launch { - if (!it.getProps().autoUpdate) return@launch - Log.d(tag, "Updating patch bundle: ${it.getName()}") - it.update() + deleteLocalFile() + } + } + + doReload() + } + + suspend fun createRemote(url: String, autoUpdate: Boolean) = + dispatchAction("Add bundle ($url)") { state -> + val src = createEntity("", SourceInfo.from(url), autoUpdate).load() as RemotePatchBundle + update(src) + state.copy(sources = state.sources.put(src.uid, src)) + } + + suspend fun reloadApiBundles() = dispatchAction("Reload API bundles") { + this@PatchBundleRepository.sources.first().filterIsInstance().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 { + 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() + .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 = persistentMapOf(), + val info: PersistentMap = persistentMapOf() + ) + + private companion object { + val defaultSource = PatchBundleEntity( + uid = 0, + name = "", + versionHash = null, + source = Source.API, + autoUpdate = false + ) + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt index e894f748..3a1a0172 100644 --- a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundle.kt @@ -1,80 +1,84 @@ package app.revanced.manager.patcher.patch -import android.util.Log -import app.revanced.manager.util.tag -import app.revanced.patcher.patch.Patch -import app.revanced.patcher.patch.PatchLoader +import kotlinx.parcelize.IgnoredOnParcel +import android.os.Parcelable +import app.revanced.patcher.patch.loadPatchesFromDex +import kotlinx.parcelize.Parcelize import java.io.File import java.io.IOException import java.util.jar.JarFile +import kotlin.collections.filter -class PatchBundleManifestAttributes( - val name: String?, - 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> { - private fun load(): Iterable> { - patchesJar.setReadOnly() - return PatchLoader.Dex(setOf(patchesJar)) - } - - override fun iterator(): Iterator> = 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) - +@Parcelize +data class PatchBundle(val patchesJar: String) : Parcelable { /** * The [java.util.jar.Manifest] of [patchesJar]. */ - private val manifest = try { - JarFile(patchesJar).use { it.manifest } - } catch (_: IOException) { - null + @IgnoredOnParcel + private val manifest by lazy { + try { + JarFile(patchesJar).use { it.manifest } + } catch (_: IOException) { + null + } } - val patchBundleManifestAttributes = if(manifest != null) - PatchBundleManifestAttributes( - name = readManifestAttribute("name"), - version = readManifestAttribute("version"), - description = readManifestAttribute("description"), - source = readManifestAttribute("source"), - author = readManifestAttribute("author"), - contact = readManifestAttribute("contact"), - website = readManifestAttribute("website"), - license = readManifestAttribute("license") - ) else + @IgnoredOnParcel + val manifestAttributes by lazy { + if (manifest != null) + ManifestAttributes( + name = readManifestAttribute("name"), + version = readManifestAttribute("version"), + description = readManifestAttribute("description"), + source = readManifestAttribute("source"), + author = readManifestAttribute("author"), + contact = readManifestAttribute("contact"), + website = readManifestAttribute("website"), + license = readManifestAttribute("license") + ) else null + } - private fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)?.takeIf { it.isNotBlank() } // If empty, set it to null instead. + private fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name) + ?.takeIf { it.isNotBlank() } // If empty, set it to null instead. - /** - * Load all patches compatible with the specified package. - */ - fun patches(packageName: String) = loader.filter { patch -> - val compatiblePackages = patch.compatiblePackages - ?: // The patch has no compatibility constraints, which means it is universal. - return@filter true + data class ManifestAttributes( + val name: String?, + val version: String?, + val description: String?, + val source: String?, + val author: String?, + val contact: String?, + val website: String?, + val license: String? + ) - if (!compatiblePackages.any { (name, _) -> name == packageName }) { - // Patch is not compatible with this package. - return@filter false - } + object Loader { + private fun patches(bundles: Iterable) = + loadPatchesFromDex( + bundles.map { File(it.patchesJar) }.toSet() + ).byPatchesFile.mapKeys { (file, _) -> + val absPath = file.absolutePath + bundles.single { absPath == it.patchesJar } + } - true + fun metadata(bundles: Iterable) = + patches(bundles).mapValues { (_, patches) -> patches.map(::PatchInfo) } + + fun patches(bundles: Iterable, 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() + } } } diff --git a/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt new file mode 100644 index 00000000..e31cecf6 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/patcher/patch/PatchBundleInfo.kt @@ -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 + + /** + * 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 + ) : 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() + val incompatible = mutableListOf() + val universal = mutableListOf() + + 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, + val compatible: List, + val incompatible: List, + val universal: List + ) : PatchBundleInfo() { + fun patchSequence(allowIncompatible: Boolean) = if (allowIncompatible) { + patches.asSequence() + } else { + sequence { + yieldAll(compatible) + yieldAll(universal) + } + } + } + + companion object Extensions { + inline fun Iterable.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.requiredOptionsSet( + allowIncompatible: Boolean, + crossinline isSelected: (Scoped, PatchInfo) -> Boolean, + crossinline optionsForPatch: (Scoped, PatchInfo) -> Map? + ) = 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 + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt index eb50bd35..50a96a1f 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/CoroutineRuntime.kt @@ -3,6 +3,7 @@ package app.revanced.manager.patcher.runtime import android.content.Context import app.revanced.manager.patcher.Session import app.revanced.manager.patcher.logger.Logger +import app.revanced.manager.patcher.patch.PatchBundle import app.revanced.manager.patcher.worker.ProgressEventHandler import app.revanced.manager.ui.model.State import app.revanced.manager.util.Options @@ -23,14 +24,17 @@ class CoroutineRuntime(private val context: Context) : Runtime(context) { onPatchCompleted: suspend () -> Unit, onProgress: ProgressEventHandler, ) { - val bundles = bundles() - val selectedBundles = selectedPatches.keys - val allPatches = bundles.filterKeys { selectedBundles.contains(it) } - .mapValues { (_, bundle) -> bundle.patches(packageName) } + val bundles = bundles() + val uids = bundles.entries.associate { (key, value) -> value to key } + + val allPatches = + PatchBundle.Loader.patches(bundles.values, packageName) + .mapKeys { (b, _) -> uids[b]!! } + .filterKeys { it in selectedBundles } val 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") } diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt index 95f32fd5..2e026298 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/ProcessRuntime.kt @@ -142,8 +142,6 @@ class ProcessRuntime(private val context: Context) : Runtime(context) { } } - val bundles = bundles() - val parameters = Parameters( aaptPath = aaptPath, frameworkDir = frameworkPath, @@ -151,13 +149,11 @@ class ProcessRuntime(private val context: Context) : Runtime(context) { packageName = packageName, inputFile = inputFile, outputFile = outputFile, - configurations = selectedPatches.map { (id, patches) -> - val bundle = bundles[id]!! - + configurations = bundles().map { (uid, bundle) -> PatchConfiguration( - bundle.patchesJar.absolutePath, - patches, - options[id].orEmpty() + bundle, + selectedPatches[uid].orEmpty(), + options[uid].orEmpty() ) } ) diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt index b00d558a..9cdd99e9 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/process/Parameters.kt @@ -1,6 +1,7 @@ package app.revanced.manager.patcher.runtime.process import android.os.Parcelable +import app.revanced.manager.patcher.patch.PatchBundle import kotlinx.parcelize.Parcelize import kotlinx.parcelize.RawValue @@ -17,7 +18,7 @@ data class Parameters( @Parcelize data class PatchConfiguration( - val bundlePath: String, + val bundle: PatchBundle, val patches: Set, val options: @RawValue Map> ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt index 82508b31..f117f201 100644 --- a/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt +++ b/app/src/main/java/app/revanced/manager/patcher/runtime/process/PatcherProcess.kt @@ -56,11 +56,10 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() { logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB") + val allPatches = PatchBundle.Loader.patches(parameters.configurations.map { it.bundle }, parameters.packageName) val patchList = parameters.configurations.flatMap { config -> - val bundle = PatchBundle(File(config.bundlePath)) - - val patches = - bundle.patches(parameters.packageName).filter { it.name in config.patches } + val patches = (allPatches[config.bundle] ?: return@flatMap emptyList()) + .filter { it.name in config.patches } .associateBy { it.name } config.options.forEach { (patchName, opts) -> diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt deleted file mode 100644 index 3e07af24..00000000 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BaseBundleDialog.kt +++ /dev/null @@ -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, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt index 20d2d7c4..9e0bb7c9 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleInformationDialog.kt @@ -1,69 +1,99 @@ package app.revanced.manager.ui.component.bundle +import android.webkit.URLUtil.isValidUrl import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.ArrowRight +import androidx.compose.material.icons.automirrored.outlined.Send +import androidx.compose.material.icons.outlined.Commit import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Gavel +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Sell import androidx.compose.material.icons.outlined.Update import androidx.compose.material3.* +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.ui.unit.dp import app.revanced.manager.R +import app.revanced.manager.R.string.auto_update +import app.revanced.manager.R.string.auto_update_description +import app.revanced.manager.R.string.field_not_set +import app.revanced.manager.R.string.patches +import app.revanced.manager.R.string.patches_url +import app.revanced.manager.R.string.view_patches import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.domain.bundles.LocalPatchBundle import app.revanced.manager.domain.bundles.PatchBundleSource import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.asRemoteOrNull import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault -import app.revanced.manager.domain.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.FullscreenDialog +import app.revanced.manager.ui.component.TextInputDialog +import app.revanced.manager.ui.component.haptics.HapticSwitch import kotlinx.coroutines.launch import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable fun BundleInformationDialog( + src: PatchBundleSource, + patchCount: Int, onDismissRequest: () -> Unit, onDeleteRequest: () -> Unit, - bundle: PatchBundleSource, onUpdate: () -> Unit, ) { + val bundleRepo = koinInject() val networkInfo = koinInject() val hasNetwork = remember { networkInfo.isConnected() } val composableScope = rememberCoroutineScope() var viewCurrentBundlePatches by remember { mutableStateOf(false) } - val isLocal = bundle is LocalPatchBundle - val state by bundle.state.collectAsStateWithLifecycle() - val props by remember(bundle) { - bundle.propsFlow() - }.collectAsStateWithLifecycle(null) - val patchCount by bundle.patchCountFlow.collectAsStateWithLifecycle(0) - val version by bundle.versionFlow.collectAsStateWithLifecycle(null) - val bundleManifestAttributes = state.patchBundleOrNull()?.patchBundleManifestAttributes + val isLocal = src is LocalPatchBundle + val bundleManifestAttributes = src.patchBundle?.manifestAttributes + val (autoUpdate, endpoint) = src.asRemoteOrNull?.let { it.autoUpdate to it.endpoint } ?: (null to null) + + fun onAutoUpdateChange(new: Boolean) = composableScope.launch { + with(bundleRepo) { + src.asRemoteOrNull?.setAutoUpdate(new) + } + } if (viewCurrentBundlePatches) { BundlePatchesDialog( + src = src, onDismissRequest = { viewCurrentBundlePatches = false - }, - bundle = bundle + } ) } FullscreenDialog( onDismissRequest = onDismissRequest, ) { - val bundleName by bundle.nameState - Scaffold( topBar = { BundleTopBar( - title = bundleName, + title = src.name, onBackClick = onDismissRequest, backIcon = { Icon( @@ -72,7 +102,7 @@ fun BundleInformationDialog( ) }, actions = { - if (!bundle.isDefault) { + if (!src.isDefault) { IconButton(onClick = onDeleteRequest) { Icon( Icons.Outlined.DeleteOutline, @@ -92,54 +122,175 @@ fun BundleInformationDialog( ) }, ) { paddingValues -> - BaseBundleDialog( - modifier = Modifier.padding(paddingValues), - isDefault = bundle.isDefault, - remoteUrl = bundle.asRemoteOrNull?.endpoint, - patchCount = patchCount, - version = version, - autoUpdate = props?.autoUpdate == true, - bundleManifestAttributes = bundleManifestAttributes, - onAutoUpdateChange = { - composableScope.launch { - bundle.asRemoteOrNull?.setAutoUpdate(it) + ColumnWithScrollbar( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Tag(Icons.Outlined.Sell, src.name) + bundleManifestAttributes?.description?.let { + Tag(Icons.Outlined.Description, it) } - }, - onPatchesClick = { - viewCurrentBundlePatches = true - }, - extraFields = { - (state as? PatchBundleSource.State.Failed)?.throwable?.let { - var showDialog by rememberSaveable { - mutableStateOf(false) + bundleManifestAttributes?.source?.let { + Tag(Icons.Outlined.Commit, it) + } + bundleManifestAttributes?.author?.let { + Tag(Icons.Outlined.Person, it) + } + bundleManifestAttributes?.contact?.let { + Tag(Icons.AutoMirrored.Outlined.Send, it) + } + bundleManifestAttributes?.website?.let { + Tag(Icons.Outlined.Language, it, isUrl = true) + } + bundleManifestAttributes?.license?.let { + Tag(Icons.Outlined.Gavel, it) + } + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + + if (autoUpdate != null) { + BundleListItem( + headlineText = stringResource(auto_update), + supportingText = stringResource(auto_update_description), + trailingContent = { + HapticSwitch( + checked = autoUpdate, + onCheckedChange = ::onAutoUpdateChange + ) + }, + modifier = Modifier.clickable { + onAutoUpdateChange(!autoUpdate) } - 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 - ) + endpoint?.takeUnless { src.isDefault }?.let { url -> + var showUrlInputDialog by rememberSaveable { + mutableStateOf(false) + } + if (showUrlInputDialog) { + TextInputDialog( + initial = url, + title = stringResource(patches_url), + onDismissRequest = { showUrlInputDialog = false }, + onConfirm = { + showUrlInputDialog = false + TODO("Not implemented.") }, - modifier = Modifier.clickable { showDialog = true } + validator = { + if (it.isEmpty()) return@TextInputDialog false + + isValidUrl(it) + } ) } - if (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) + BundleListItem( + modifier = Modifier.clickable( + enabled = false, + onClick = { + showUrlInputDialog = true + } + ), + headlineText = stringResource(patches_url), + supportingText = url.ifEmpty { + stringResource(field_not_set) + } + ) + } + + val patchesClickable = patchCount > 0 + BundleListItem( + headlineText = stringResource(patches), + supportingText = stringResource(view_patches), + modifier = Modifier.clickable( + enabled = patchesClickable, + onClick = { + viewCurrentBundlePatches = true + } + ) + ) { + if (patchesClickable) { + Icon( + Icons.AutoMirrored.Outlined.ArrowRight, + stringResource(patches) ) } } - ) + + 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, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt index 3501e26e..958616af 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleItem.kt @@ -24,38 +24,32 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.bundles.PatchBundleSource -import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState import app.revanced.manager.ui.component.ConfirmDialog import app.revanced.manager.ui.component.haptics.HapticCheckbox -import kotlinx.coroutines.flow.map @OptIn(ExperimentalFoundationApi::class) @Composable fun BundleItem( - bundle: PatchBundleSource, - onDelete: () -> Unit, - onUpdate: () -> Unit, + src: PatchBundleSource, + patchCount: Int, selectable: Boolean, - onSelect: () -> Unit, isBundleSelected: Boolean, toggleSelection: (Boolean) -> Unit, + onSelect: () -> Unit, + onDelete: () -> Unit, + onUpdate: () -> Unit, ) { var viewBundleDialogPage by rememberSaveable { mutableStateOf(false) } var showDeleteConfirmationDialog by rememberSaveable { mutableStateOf(false) } - val state by bundle.state.collectAsStateWithLifecycle() - - val version by bundle.versionFlow.collectAsStateWithLifecycle(null) - val patchCount by bundle.patchCountFlow.collectAsStateWithLifecycle(0) - val name by bundle.nameState if (viewBundleDialogPage) { BundleInformationDialog( + src = src, + patchCount = patchCount, onDismissRequest = { viewBundleDialogPage = false }, onDeleteRequest = { showDeleteConfirmationDialog = true }, - bundle = bundle, onUpdate = onUpdate, ) } @@ -68,7 +62,7 @@ fun BundleItem( viewBundleDialogPage = false }, 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 ) } @@ -90,19 +84,19 @@ fun BundleItem( } } else null, - headlineContent = { Text(name) }, + headlineContent = { Text(src.name) }, supportingContent = { - if (state is PatchBundleSource.State.Loaded) { + if (src.state is PatchBundleSource.State.Available) { Text(pluralStringResource(R.plurals.patch_count, patchCount, patchCount)) } }, trailingContent = { Row { - val icon = remember(state) { - when (state) { + val icon = remember(src.state) { + when (src.state) { is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.patches_error is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.patches_missing - is PatchBundleSource.State.Loaded -> null + is PatchBundleSource.State.Available -> null } } @@ -115,7 +109,7 @@ fun BundleItem( ) } - version?.let { Text(text = it) } + src.version?.let { Text(text = it) } } }, ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt index 7d28237d..1055578c 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundlePatchesDialog.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -25,20 +26,26 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.bundles.PatchBundleSource +import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.ui.component.ArrowButton import app.revanced.manager.ui.component.FullscreenDialog import app.revanced.manager.ui.component.LazyColumnWithScrollbar +import kotlinx.coroutines.flow.mapNotNull +import org.koin.compose.koinInject @OptIn(ExperimentalMaterial3Api::class) @Composable fun BundlePatchesDialog( onDismissRequest: () -> Unit, - bundle: PatchBundleSource, + src: PatchBundleSource, ) { var showAllVersions 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( onDismissRequest = onDismissRequest, @@ -64,16 +71,14 @@ fun BundlePatchesDialog( verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(16.dp) ) { - state.patchBundleOrNull()?.let { bundle -> - items(bundle.patches) { patch -> - PatchItem( - patch, - showAllVersions, - onExpandVersions = { showAllVersions = !showAllVersions }, - showOptions, - onExpandOptions = { showOptions = !showOptions } - ) - } + items(patches) { patch -> + PatchItem( + patch, + showAllVersions, + onExpandVersions = { showAllVersions = !showAllVersions }, + showOptions, + onExpandOptions = { showOptions = !showOptions } + ) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt index bcad3fe9..7bcb1017 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/BundleSelector.kt @@ -12,26 +12,23 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.domain.bundles.PatchBundleSource -import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.nameState @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BundleSelector(bundles: List, onFinish: (PatchBundleSource?) -> Unit) { - LaunchedEffect(bundles) { - if (bundles.size == 1) { - onFinish(bundles[0]) +fun BundleSelector(sources: List, onFinish: (PatchBundleSource?) -> Unit) { + LaunchedEffect(sources) { + if (sources.size == 1) { + onFinish(sources[0]) } } - if (bundles.size < 2) { + if (sources.size < 2) { return } @@ -55,10 +52,7 @@ fun BundleSelector(bundles: List, onFinish: (PatchBundleSourc color = MaterialTheme.colorScheme.onSurface ) } - bundles.forEach { - val name by it.nameState - val version by it.versionFlow.collectAsStateWithLifecycle(null) - + sources.forEach { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, @@ -70,7 +64,7 @@ fun BundleSelector(bundles: List, onFinish: (PatchBundleSourc } ) { Text( - "$name $version", + "${it.name} ${it.version}", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface ) diff --git a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt index 1a05d866..943a9f9c 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/bundle/ImportBundleDialog.kt @@ -23,10 +23,14 @@ import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.TextHorizontalPadding import app.revanced.manager.ui.component.haptics.HapticCheckbox import app.revanced.manager.ui.component.haptics.HapticRadioButton -import app.revanced.manager.ui.model.BundleType import app.revanced.manager.util.BIN_MIMETYPE import app.revanced.manager.util.transparentListItemColors +private enum class BundleType { + Local, + Remote +} + @Composable fun ImportPatchBundleDialog( onDismiss: () -> Unit, @@ -37,7 +41,7 @@ fun ImportPatchBundleDialog( var bundleType by rememberSaveable { mutableStateOf(BundleType.Remote) } var patchBundle by rememberSaveable { mutableStateOf(null) } var remoteUrl by rememberSaveable { mutableStateOf("") } - var autoUpdate by rememberSaveable { mutableStateOf(false) } + var autoUpdate by rememberSaveable { mutableStateOf(true) } val patchActivityLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> @@ -117,7 +121,7 @@ fun ImportPatchBundleDialog( } @Composable -fun SelectBundleTypeStep( +private fun SelectBundleTypeStep( bundleType: BundleType, onBundleTypeSelected: (BundleType) -> Unit ) { @@ -168,7 +172,7 @@ fun SelectBundleTypeStep( @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ImportBundleStep( +private fun ImportBundleStep( bundleType: BundleType, patchBundle: Uri?, remoteUrl: String, diff --git a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt b/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt deleted file mode 100644 index 0fb01a76..00000000 --- a/app/src/main/java/app/revanced/manager/ui/model/BundleInfo.kt +++ /dev/null @@ -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, - val incompatible: List, - val universal: List -) { - 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.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() - val incompatible = mutableListOf() - val universal = mutableListOf() - - 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.requiredOptionsSet( - crossinline isSelected: (BundleInfo, PatchInfo) -> Boolean, - crossinline optionsForPatch: (BundleInfo, PatchInfo) -> Map? - ) = 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 -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt index 8704a064..4f23271c 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/BundleListScreen.kt @@ -3,59 +3,74 @@ package app.revanced.manager.ui.screen import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.fillMaxSize 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.remember -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment 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.bundle.BundleItem +import app.revanced.manager.ui.viewmodel.BundleListViewModel +import app.revanced.manager.util.EventEffect +import kotlinx.coroutines.flow.Flow +import org.koin.androidx.compose.koinViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun BundleListScreen( - onDelete: (PatchBundleSource) -> Unit, - onUpdate: (PatchBundleSource) -> Unit, - sources: List, - selectedSources: SnapshotStateList, - bundlesSelectable: Boolean, + viewModel: BundleListViewModel = koinViewModel(), + eventsFlow: Flow, + setSelectedSourceCount: (Int) -> Unit ) { - val sortedSources = remember(sources) { - sources.sortedByDescending { source -> - source.state.value.patchBundleOrNull()?.patches?.size ?: 0 - } + val patchCounts by viewModel.patchCounts.collectAsStateWithLifecycle(emptyMap()) + val sources by viewModel.sources.collectAsStateWithLifecycle(emptyList()) + + EventEffect(eventsFlow) { + viewModel.handleEvent(it) + } + LaunchedEffect(viewModel.selectedSources.size) { + setSelectedSourceCount(viewModel.selectedSources.size) } - LazyColumnWithScrollbar( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, + PullToRefreshBox( + onRefresh = viewModel::refresh, + isRefreshing = viewModel.isRefreshing ) { - items( - sortedSources, - key = { it.uid } - ) { source -> - BundleItem( - bundle = source, - onDelete = { - onDelete(source) - }, - onUpdate = { - onUpdate(source) - }, - selectable = bundlesSelectable, - onSelect = { - selectedSources.add(source) - }, - isBundleSelected = selectedSources.contains(source), - toggleSelection = { bundleIsNotSelected -> - if (bundleIsNotSelected) { - selectedSources.add(source) - } else { - selectedSources.remove(source) + LazyColumnWithScrollbar( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + items( + sources, + key = { it.uid } + ) { source -> + BundleItem( + src = source, + patchCount = patchCounts[source.uid] ?: 0, + onDelete = { + viewModel.delete(source) + }, + onUpdate = { + viewModel.update(source) + }, + selectable = viewModel.selectedSources.size > 0, + onSelect = { + viewModel.selectedSources.add(source.uid) + }, + isBundleSelected = source.uid in viewModel.selectedSources, + toggleSelection = { bundleIsNotSelected -> + if (bundleIsNotSelected) { + viewModel.selectedSources.add(source.uid) + } else { + viewModel.selectedSources.remove(source.uid) + } } - } - ) + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt index 03eff51f..0496c1a9 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/DashboardScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -56,7 +57,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R -import app.revanced.manager.domain.bundles.PatchBundleSource.Extensions.isDefault import app.revanced.manager.patcher.aapt.Aapt import app.revanced.manager.ui.component.AlertDialogExtended import app.revanced.manager.ui.component.AppTopBar @@ -93,7 +93,8 @@ fun DashboardScreen( onDownloaderPluginClick: () -> 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 showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle( false @@ -160,10 +161,7 @@ fun DashboardScreen( if (showDeleteConfirmationDialog) { ConfirmDialog( onDismiss = { showDeleteConfirmationDialog = false }, - onConfirm = { - vm.selectedSources.forEach { if (!it.isDefault) vm.delete(it) } - vm.cancelSourceSelection() - }, + onConfirm = vm::deleteSources, title = stringResource(R.string.delete), description = stringResource(R.string.patches_delete_multiple_dialog_description), icon = Icons.Outlined.Delete @@ -174,7 +172,7 @@ fun DashboardScreen( topBar = { if (bundlesSelectable) { BundleTopBar( - title = stringResource(R.string.patches_selected, vm.selectedSources.size), + title = stringResource(R.string.patches_selected, selectedSourceCount), onBackClick = vm::cancelSourceSelection, backIcon = { Icon( @@ -194,10 +192,7 @@ fun DashboardScreen( ) } IconButton( - onClick = { - vm.selectedSources.forEach { vm.update(it) } - vm.cancelSourceSelection() - } + onClick = vm::updateSources ) { Icon( Icons.Outlined.Refresh, @@ -349,18 +344,9 @@ fun DashboardScreen( } } - val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) - BundleListScreen( - onDelete = { - vm.delete(it) - }, - onUpdate = { - vm.update(it) - }, - sources = sources, - selectedSources = vm.selectedSources, - bundlesSelectable = bundlesSelectable + eventsFlow = vm.bundleListEventsFlow, + setSelectedSourceCount = { selectedSourceCount = it } ) } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index 2bb29e4a..db0667a0 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -413,7 +413,7 @@ fun PatchesSelectorScreen( style = MaterialTheme.typography.bodyMedium ) Text( - text = bundle.version!!, + text = bundle.version.orEmpty(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt index 1daa4c5f..4f054589 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/RequiredOptionsScreen.kt @@ -30,12 +30,12 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.patcher.patch.Option +import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.LazyColumnWithScrollbar import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton import app.revanced.manager.ui.component.haptics.HapticTab import app.revanced.manager.ui.component.patches.OptionItem -import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection @@ -62,6 +62,7 @@ fun RequiredOptionsScreen( val showContinueButton by remember { derivedStateOf { bundles.requiredOptionsSet( + allowIncompatible = vm.allowIncompatiblePatches, isSelected = { bundle, patch -> vm.isSelected(bundle.uid, patch) }, optionsForPatch = { bundle, patch -> vm.getOptions(bundle.uid, patch) } ) diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt index 3d57ee50..bbe5a4cd 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/DownloadsSettingsScreen.kt @@ -2,7 +2,6 @@ package app.revanced.manager.ui.screen.settings import androidx.annotation.StringRes import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -19,9 +18,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults -import androidx.compose.material3.pulltorefresh.pullToRefresh -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -29,13 +26,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.network.downloader.DownloaderPluginState @@ -57,7 +52,6 @@ fun DownloadsSettingsScreen( onBackClick: () -> Unit, viewModel: DownloadsViewModel = koinViewModel() ) { - val pullRefreshState = rememberPullToRefreshState() val downloadedApps by viewModel.downloadedApps.collectAsStateWithLifecycle(emptyList()) val pluginStates by viewModel.downloaderPluginStates.collectAsStateWithLifecycle() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) @@ -90,152 +84,138 @@ fun DownloadsSettingsScreen( }, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), ) { paddingValues -> - Box( - contentAlignment = Alignment.TopCenter, - modifier = Modifier - .padding(paddingValues) - .fillMaxWidth() - .zIndex(1f) + PullToRefreshBox( + onRefresh = viewModel::refreshPlugins, + isRefreshing = viewModel.isRefreshingPlugins, + modifier = Modifier.padding(paddingValues) ) { - PullToRefreshDefaults.Indicator( - state = pullRefreshState, - isRefreshing = viewModel.isRefreshingPlugins - ) - } - - LazyColumnWithScrollbar( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .pullToRefresh( - isRefreshing = viewModel.isRefreshingPlugins, - state = pullRefreshState, - onRefresh = viewModel::refreshPlugins - ) - ) { - item { - GroupHeader(stringResource(R.string.downloader_plugins)) - } - pluginStates.forEach { (packageName, state) -> - item(key = packageName) { - var showDialog by rememberSaveable { - mutableStateOf(false) - } - - 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() - } - ) + LazyColumnWithScrollbar( + modifier = Modifier.fillMaxSize() + ) { + 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() + } + ) + } + } + + SettingsListItem( + modifier = Modifier.clickable { showDialog = true }, + headlineContent = { + AppLabel( + packageInfo = packageInfo, + style = MaterialTheme.typography.titleLarge + ) + }, + supportingContent = stringResource( + when (state) { + is DownloaderPluginState.Loaded -> R.string.downloader_plugin_state_trusted + is DownloaderPluginState.Failed -> R.string.downloader_plugin_state_failed + is DownloaderPluginState.Untrusted -> R.string.downloader_plugin_state_untrusted + } + ), + trailingContent = { Text(packageInfo.versionName!!) } + ) } + } + if (pluginStates.isEmpty()) { + item { + Text( + stringResource(R.string.downloader_no_plugins_installed), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + + item { + GroupHeader(stringResource(R.string.downloaded_apps)) + } + items(downloadedApps, key = { it.packageName to it.version }) { app -> + val selected = app in viewModel.appSelection SettingsListItem( - modifier = Modifier.clickable { showDialog = true }, - headlineContent = { - AppLabel( - packageInfo = packageInfo, - style = MaterialTheme.typography.titleLarge + modifier = Modifier.clickable { viewModel.toggleApp(app) }, + headlineContent = app.packageName, + leadingContent = (@Composable { + HapticCheckbox( + checked = selected, + onCheckedChange = { viewModel.toggleApp(app) } ) - }, - 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!!) } + }).takeIf { viewModel.appSelection.isNotEmpty() }, + supportingContent = app.version, + tonalElevation = if (selected) 8.dp else 0.dp ) } - } - if (pluginStates.isEmpty()) { - item { - Text( - stringResource(R.string.downloader_no_plugins_installed), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - } - - item { - GroupHeader(stringResource(R.string.downloaded_apps)) - } - items(downloadedApps, key = { it.packageName to it.version }) { app -> - val selected = app in viewModel.appSelection - - SettingsListItem( - modifier = Modifier.clickable { viewModel.toggleApp(app) }, - headlineContent = app.packageName, - leadingContent = (@Composable { - HapticCheckbox( - checked = selected, - onCheckedChange = { viewModel.toggleApp(app) } + if (downloadedApps.isEmpty()) { + item { + Text( + stringResource(R.string.downloader_settings_no_apps), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center ) - }).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 - ) + } } } } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt index 9383d44a..181e8dcd 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt @@ -227,12 +227,12 @@ fun ImportExportSettingsScreen( GroupItem( onClick = { selectorDialog = { - BundleSelector(bundles = patchBundles) { bundle -> - bundle?.also { + BundleSelector(sources = patchBundles) { src -> + src?.also { coroutineScope.launch { vm.resetDialogState = - ResetDialogState.PatchSelectionBundle(bundle.getName()) { - vm.resetSelectionForPatchBundle(bundle) + ResetDialogState.PatchSelectionBundle(it.name) { + vm.resetSelectionForPatchBundle(it) } } } @@ -283,12 +283,12 @@ fun ImportExportSettingsScreen( GroupItem( onClick = { selectorDialog = { - BundleSelector(bundles = patchBundles) { bundle -> - bundle?.also { + BundleSelector(sources = patchBundles) { src -> + src?.also { coroutineScope.launch { vm.resetDialogState = - ResetDialogState.PatchOptionBundle(bundle.getName()) { - vm.resetOptionsForBundle(bundle) + ResetDialogState.PatchOptionBundle(src.name) { + vm.resetOptionsForBundle(src) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt new file mode 100644 index 00000000..49bc0c4b --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/BundleListViewModel.kt @@ -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() + + 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().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, + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt index ae38bd29..26f86092 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/DashboardViewModel.kt @@ -1,5 +1,6 @@ package app.revanced.manager.ui.viewmodel +import android.annotation.SuppressLint import android.app.Application import android.content.ContentResolver 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.toast import app.revanced.manager.util.uiSafe +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch class DashboardViewModel( @@ -38,13 +41,12 @@ class DashboardViewModel( private val pm: PM, ) : ViewModel() { 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 powerManager = app.getSystemService()!! - val sources = patchBundleRepository.sources - val selectedSources = mutableStateListOf() - 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. @@ -59,6 +61,9 @@ class DashboardViewModel( var showBatteryOptimizationsWarning by mutableStateOf(false) private set + private val bundleListEventsChannel = Channel() + val bundleListEventsFlow = bundleListEventsChannel.receiveAsFlow() + init { viewModelScope.launch { checkForManagerUpdates() @@ -70,10 +75,6 @@ class DashboardViewModel( downloaderPluginRepository.acknowledgeAllNewPlugins() } - fun dismissUpdateDialog() { - updatedManagerVersion = null - } - private suspend fun checkForManagerUpdates() { if (!prefs.managerAutoUpdates.get() || !networkInfo.isConnected()) return @@ -83,7 +84,8 @@ class DashboardViewModel( } fun updateBatteryOptimizationsWarning() { - showBatteryOptimizationsWarning = !powerManager.isIgnoringBatteryOptimizations(app.packageName) + showBatteryOptimizationsWarning = + !powerManager.isIgnoringBatteryOptimizations(app.packageName) } fun setShowManagerUpdateDialogOnLaunch(value: Boolean) { @@ -112,36 +114,20 @@ class DashboardViewModel( } } - - fun cancelSourceSelection() { - selectedSources.clear() + private fun sendEvent(event: BundleListViewModel.Event) { + viewModelScope.launch { bundleListEventsChannel.send(event) } } - fun createLocalSource(patchBundle: Uri) = - viewModelScope.launch { - contentResolver.openInputStream(patchBundle)!!.use { patchesStream -> - patchBundleRepository.createLocal(patchesStream) - } - } + fun cancelSourceSelection() = sendEvent(BundleListViewModel.Event.CANCEL) + fun updateSources() = sendEvent(BundleListViewModel.Event.UPDATE_SELECTED) + fun deleteSources() = sendEvent(BundleListViewModel.Event.DELETE_SELECTED) - fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = - viewModelScope.launch { patchBundleRepository.createRemote(apiUrl, autoUpdate) } + @SuppressLint("Recycle") + fun createLocalSource(patchBundle: Uri) = viewModelScope.launch { + patchBundleRepository.createLocal { contentResolver.openInputStream(patchBundle)!! } + } - fun delete(bundle: PatchBundleSource) = - viewModelScope.launch { patchBundleRepository.remove(bundle) } - - fun update(bundle: PatchBundleSource) = viewModelScope.launch { - if (bundle !is RemotePatchBundle) return@launch - - uiSafe( - app, - R.string.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())) - } + fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch { + patchBundleRepository.createRemote(apiUrl, autoUpdate) } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index dc43c28b..fb458d43 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -17,10 +17,9 @@ import androidx.lifecycle.viewmodel.compose.saveable import app.revanced.manager.R import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.patcher.patch.PatchBundleInfo +import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection import app.revanced.manager.patcher.patch.PatchInfo -import app.revanced.manager.ui.model.BundleInfo -import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow -import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo import app.revanced.manager.util.Options import app.revanced.manager.util.PatchSelection @@ -63,7 +62,7 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi val allowIncompatiblePatches = get().disablePatchVersionCompatCheck.getBlocking() val bundlesFlow = - get().bundleInfoFlow(packageName, input.app.version) + get().scopedBundleInfoFlow(packageName, input.app.version) init { viewModelScope.launch { @@ -76,11 +75,11 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi return@launch } - fun BundleInfo.hasDefaultPatches() = + fun PatchBundleInfo.Scoped.hasDefaultPatches() = patchSequence(allowIncompatiblePatches).any { it.include } // 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. private val requiredOptsPatchesDeferred = viewModelScope.async(start = CoroutineStart.LAZY) { bundlesFlow.first().map { bundle -> - bundle to bundle.all.filter { patch -> + bundle to bundle.patchSequence(allowIncompatiblePatches).filter { patch -> val opts by lazy { getOptions(bundle.uid, patch).orEmpty() } @@ -136,14 +135,14 @@ class PatchesSelectorViewModel(input: SelectedApplicationInfo.PatchesSelector.Vi } val requiredOptsPatches = flow { emit(requiredOptsPatchesDeferred.await()) } - fun selectionIsValid(bundles: List) = bundles.any { bundle -> + fun selectionIsValid(bundles: List) = bundles.any { bundle -> bundle.patchSequence(allowIncompatiblePatches).any { patch -> isSelected(bundle.uid, patch) } } fun isSelected(bundle: Int, patch: PatchInfo) = customPatchSelection?.let { selection -> - selection[bundle]?.contains(patch.name) ?: false + selection[bundle]?.contains(patch.name) == true } ?: patch.include fun togglePatch(bundle: Int, patch: PatchInfo) = viewModelScope.launch { diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt index 1b9b08c2..8b9003a9 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SelectedAppInfoViewModel.kt @@ -28,15 +28,14 @@ import app.revanced.manager.domain.repository.InstalledAppRepository import app.revanced.manager.domain.repository.PatchBundleRepository import app.revanced.manager.domain.repository.PatchOptionsRepository import app.revanced.manager.domain.repository.PatchSelectionRepository +import app.revanced.manager.patcher.patch.PatchBundleInfo +import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.toPatchSelection import app.revanced.manager.network.downloader.LoadedDownloaderPlugin import app.revanced.manager.network.downloader.ParceledDownloaderData +import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet import app.revanced.manager.plugin.downloader.GetScope import app.revanced.manager.plugin.downloader.PluginHostApi import app.revanced.manager.plugin.downloader.UserInteractionException -import app.revanced.manager.ui.model.BundleInfo -import app.revanced.manager.ui.model.BundleInfo.Extensions.bundleInfoFlow -import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet -import app.revanced.manager.ui.model.BundleInfo.Extensions.toPatchSelection import app.revanced.manager.ui.model.SelectedApp import app.revanced.manager.ui.model.navigation.Patcher import app.revanced.manager.ui.model.navigation.SelectedApplicationInfo @@ -125,16 +124,19 @@ class SelectedAppInfoViewModel( suggestedVersions[input.app.packageName] } + val bundleInfoFlow by derivedStateOf { + bundleRepository.scopedBundleInfoFlow(packageName, selectedApp.version) + } + var options: Options by savedStateHandle.saveable { val state = mutableStateOf(emptyMap()) viewModelScope.launch { if (!persistConfiguration) return@launch // TODO: save options for patched apps. + val bundlePatches = bundleInfoFlow.first() + .associate { it.uid to it.patches.associateBy { patch -> patch.name } } options = withContext(Dispatchers.Default) { - val bundlePatches = bundleRepository.bundles.first() - .mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } } - optionsRepository.getOptions(packageName, bundlePatches) } } @@ -176,10 +178,6 @@ class SelectedAppInfoViewModel( } } - val bundleInfoFlow by derivedStateOf { - bundleRepository.bundleInfoFlow(packageName, selectedApp.version) - } - fun showSourceSelector() { dismissSourceSelector() showSourceSelector = true @@ -266,9 +264,11 @@ class SelectedAppInfoViewModel( selectedAppInfo = info } + fun getOptionsFiltered(bundles: List) = options.filtered(bundles) suspend fun hasSetRequiredOptions(patchSelection: PatchSelection) = bundleInfoFlow .first() .requiredOptionsSet( + allowIncompatible = prefs.disablePatchVersionCompatCheck.get(), isSelected = { bundle, patch -> patch.name in patchSelection[bundle.uid]!! }, optionsForPatch = { bundle, patch -> options[bundle.uid]?.get(patch.name) }, ) @@ -283,23 +283,23 @@ class SelectedAppInfoViewModel( ) } - fun getOptionsFiltered(bundles: List) = options.filtered(bundles) - - fun getPatches(bundles: List, allowIncompatible: Boolean) = + fun getPatches(bundles: List, allowIncompatible: Boolean) = selectionState.patches(bundles, allowIncompatible) fun getCustomPatches( - bundles: List, + bundles: List, allowIncompatible: Boolean ): PatchSelection? = (selectionState as? SelectionState.Customized)?.patches(bundles, allowIncompatible) - fun updateConfiguration(selection: PatchSelection?, options: Options) = viewModelScope.launch { - val bundles = bundleInfoFlow.first() + fun updateConfiguration( + selection: PatchSelection?, + options: Options + ) = viewModelScope.launch { selectionState = selection?.let(SelectionState::Customized) ?: SelectionState.Default - val filteredOptions = options.filtered(bundles) + val filteredOptions = options.filtered(bundleInfoFlow.first()) this@SelectedAppInfoViewModel.options = filteredOptions if (!persistConfiguration) return@launch @@ -319,34 +319,35 @@ class SelectedAppInfoViewModel( /** * Returns a copy with all nonexistent options removed. */ - private fun Options.filtered(bundles: List): Options = buildMap options@{ - bundles.forEach bundles@{ bundle -> - val bundleOptions = this@filtered[bundle.uid] ?: return@bundles + private fun Options.filtered(bundles: List): Options = + buildMap options@{ + 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@{ - bundleOptions.forEach patch@{ (patchName, values) -> - // Get all valid option keys for the patch. - val validOptionKeys = - patches[patchName]?.options?.map { it.key }?.toSet() ?: return@patch + this@options[bundle.uid] = buildMap bundleOptions@{ + bundleOptions.forEach patch@{ (patchName, values) -> + // Get all valid option keys for the patch. + val validOptionKeys = + patches[patchName]?.options?.map { it.key }?.toSet() ?: return@patch - this@bundleOptions[patchName] = values.filterKeys { key -> - key in validOptionKeys + this@bundleOptions[patchName] = values.filterKeys { key -> + key in validOptionKeys + } } } } } - } } } private sealed interface SelectionState : Parcelable { - fun patches(bundles: List, allowIncompatible: Boolean): PatchSelection + fun patches(bundles: List, allowIncompatible: Boolean): PatchSelection @Parcelize data class Customized(val patchSelection: PatchSelection) : SelectionState { - override fun patches(bundles: List, allowIncompatible: Boolean) = + override fun patches(bundles: List, allowIncompatible: Boolean) = bundles.toPatchSelection( allowIncompatible ) { uid, patch -> @@ -356,7 +357,7 @@ private sealed interface SelectionState : Parcelable { @Parcelize data object Default : SelectionState { - override fun patches(bundles: List, allowIncompatible: Boolean) = + override fun patches(bundles: List, allowIncompatible: Boolean) = bundles.toPatchSelection(allowIncompatible) { _, patch -> patch.include } } } diff --git a/app/src/main/java/app/revanced/manager/util/PM.kt b/app/src/main/java/app/revanced/manager/util/PM.kt index 3ac633af..c2f16402 100644 --- a/app/src/main/java/app/revanced/manager/util/PM.kt +++ b/app/src/main/java/app/revanced/manager/util/PM.kt @@ -44,10 +44,10 @@ class PM( ) { private val scope = CoroutineScope(Dispatchers.IO) - val appList = patchBundleRepository.bundles.map { bundles -> + val appList = patchBundleRepository.bundleInfoFlow.map { bundles -> val compatibleApps = scope.async { - val compatiblePackages = bundles.values - .flatMap { it.patches } + val compatiblePackages = bundles + .flatMap { (_, bundle) -> bundle.patches } .flatMap { it.compatiblePackages.orEmpty() } .groupingBy { it.packageName } .eachCount() diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index 2c509aa5..5b2dfa33 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -116,10 +116,10 @@ inline fun LifecycleOwner.launchAndRepeatWithViewLifecycle( */ @OptIn(ExperimentalCoroutinesApi::class) inline fun Flow>.flatMapLatestAndCombine( - crossinline combiner: (Array) -> C, - crossinline transformer: (T) -> Flow, + crossinline combiner: suspend (Array) -> C, + crossinline transformer: suspend (T) -> Flow, ): Flow = flatMapLatest { iterable -> - combine(iterable.map(transformer)) { + combine(iterable.map { transformer(it) }) { combiner(it) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 60bd51c4..79f9daee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,7 +225,7 @@ Download app Download APK file Failed to download patches: %s - Failed to load updated patches: %s + Failed to import patches: %s No patched apps found Tap on the patches to get more information about them %s selected @@ -344,8 +344,8 @@ Help us improve this application Developer options Options for debugging issues - Successfully updated %s - No update available for %s + Update successful + No update available View patches Any version Any package