Merge branch 'compose-dev' into build/spotless

This commit is contained in:
Pun Butrach
2025-07-15 23:04:30 +07:00
50 changed files with 1516 additions and 1165 deletions

View File

@@ -40,10 +40,6 @@ dependencies {
// Placeholder
implementation(libs.placeholder.material3)
// HTML Scraper
implementation(libs.skrapeit.dsl)
implementation(libs.skrapeit.parser)
// Coil (async image loading, network image)
implementation(libs.coil.compose)
implementation(libs.coil.appiconloader)

View File

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

View File

@@ -1,25 +1,15 @@
package app.revanced.manager.data.room.bundles
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface PatchBundleDao {
@Query("SELECT * FROM patch_bundles")
suspend fun all(): List<PatchBundleEntity>
@Query("SELECT version, auto_update FROM patch_bundles WHERE uid = :uid")
fun getPropsById(uid: Int): Flow<BundleProperties?>
@Query("UPDATE patch_bundles SET version = :patches WHERE uid = :uid")
suspend fun updateVersionHash(uid: Int, patches: String?)
@Query("UPDATE patch_bundles SET auto_update = :value WHERE uid = :uid")
suspend fun setAutoUpdate(uid: Int, value: Boolean)
@Query("UPDATE patch_bundles SET name = :value WHERE uid = :uid")
suspend fun setName(uid: Int, value: String)
@Query("DELETE FROM patch_bundles WHERE uid != 0")
suspend fun purgeCustomBundles()
@@ -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)
}

View File

@@ -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
)

View File

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

View File

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

View File

@@ -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.readManifestAttribute("Version"))
}
}
override fun copy(error: Throwable?, name: String) = LocalPatchBundle(
name,
uid,
error,
directory
)
}

View File

@@ -1,22 +1,10 @@
package app.revanced.manager.domain.bundles
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.bundle_name_default else R.string.bundle_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()?.readManifestAttribute("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.readManifestAttribute("Name")?.let { setName(it) }
}
return bundle
}
/**
* Create a flow that emits the [app.revanced.manager.data.room.bundles.BundleProperties] of this [PatchBundleSource].
* The flow will emit null if the associated [PatchBundleSource] is deleted.
*/
fun propsFlow() = configRepository.getProps(uid).flowOn(Dispatchers.Default)
suspend fun getProps() = propsFlow().first()!!
suspend fun currentVersionHash() = getProps().versionHash
protected suspend fun saveVersionHash(version: String?) =
configRepository.updateVersionHash(uid, version)
suspend fun setName(name: String) {
configRepository.setName(uid, name)
_nameFlow.value = name
}
sealed interface State {
fun patchBundleOrNull(): PatchBundle? = null
data object Missing : State
data class Failed(val throwable: Throwable) : State
data class 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(
""
)
}
}

View File

@@ -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 patch bundle(s)"
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<ReVancedAsset> {
url(endpoint)
}.getOrThrow()
}
override fun copy(error: Throwable?, name: String, autoUpdate: Boolean) = JsonPatchBundle(
name,
uid,
versionHash,
error,
directory,
endpoint,
autoUpdate,
)
}
class APIPatchBundle(name: String, 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,
)
}

View File

@@ -40,6 +40,8 @@ class DownloadedAppRepository(
data: Parcelable,
expectedPackageName: String,
expectedVersion: String?,
appCompatibilityCheck: Boolean,
patchesCompatibilityCheck: Boolean,
onDownload: suspend (downloadProgress: Pair<Long, Long?>) -> Unit,
): File {
// Converted integers cannot contain / or .. unlike the package name or version, so they are safer to use here.
@@ -96,7 +98,12 @@ class DownloadedAppRepository(
val pkgInfo =
pm.getPackageInfo(targetFile.toFile()) ?: error("Downloaded APK file is invalid")
if (pkgInfo.packageName != expectedPackageName) error("Downloaded APK has the wrong package name. Expected: $expectedPackageName, Actual: ${pkgInfo.packageName}")
if (expectedVersion != null && pkgInfo.versionName != expectedVersion) error("Downloaded APK has the wrong version. Expected: $expectedVersion, Actual: ${pkgInfo.versionName}")
expectedVersion?.let {
if (
pkgInfo.versionName != expectedVersion &&
(appCompatibilityCheck || patchesCompatibilityCheck)
) error("The selected app version ($pkgInfo.versionName) doesn't match the suggested version. Please use the suggested version ($expectedVersion), or adjust your settings by disabling \"Require suggested app version\" and enabling \"Disable version compatibility check\".")
}
// Delete the previous copy (if present).
dao.get(pkgInfo.packageName, pkgInfo.versionName!!)?.directory?.let {

View File

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

View File

@@ -3,55 +3,78 @@ package app.revanced.manager.domain.repository
import android.app.Application
import android.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<Map<Int, PatchBundleSource>> =
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<State> {
override suspend fun ActionContext.execute(current: State) = block(current)
override fun toString() = name
})
}
/**
* Performs a reload. Do not call this outside of a store action.
*/
private suspend fun doReload(): State {
val entities = loadFromDb().onEach {
Log.d(tag, "Bundle: $it")
}
val sources = entities.associate { it.uid to it.load() }.toPersistentMap()
val hasOutOfDateNames = sources.values.any { it.isNameOutOfDate }
if (hasOutOfDateNames) dispatchAction(
"Sync names"
) { state ->
val nameChanges = state.sources.mapNotNull { (_, src) ->
if (!src.isNameOutOfDate) return@mapNotNull null
val newName = src.patchBundle?.manifestAttributes?.name?.takeIf { it != src.name }
?: return@mapNotNull null
src.uid to newName
}
val sources = state.sources.toMutableMap()
val info = state.info.toMutableMap()
nameChanges.forEach { (uid, name) ->
updateDb(uid) { it.copy(name = name) }
sources[uid] = sources[uid]!!.copy(name = name)
info[uid] = info[uid]?.copy(name = name) ?: return@forEach
}
State(sources.toPersistentMap(), info.toPersistentMap())
}
val info = loadMetadata(sources).toPersistentMap()
return State(sources, info)
}
suspend fun reload() = dispatchAction("Full reload") {
doReload()
}
private suspend fun loadFromDb(): List<PatchBundleEntity> {
val all = dao.all()
if (all.isEmpty()) {
dao.upsert(defaultSource)
return listOf(defaultSource)
}
return all
}
private suspend fun loadMetadata(sources: Map<Int, PatchBundleSource>): Map<Int, PatchBundleInfo.Global> {
// Map bundles -> sources
val map = sources.mapNotNull { (_, src) ->
(src.patchBundle ?: return@mapNotNull null) to src
}.toMap()
val metadata = try {
PatchBundle.Loader.metadata(map.keys)
} catch (error: Throwable) {
val uids = map.values.map { it.uid }
dispatchAction("Mark bundles as failed") { state ->
state.copy(sources = state.sources.mutate {
uids.forEach { uid ->
it[uid] = it[uid]?.copy(error = error) ?: return@forEach
}
})
}
Log.e(tag, "Failed to load bundles", error)
emptyMap()
}
return metadata.entries.associate { (bundle, patches) ->
val src = map[bundle]!!
src.uid to PatchBundleInfo.Global(
src.name,
bundle.manifestAttributes?.version,
src.uid,
patches
)
}
}
suspend fun isVersionAllowed(packageName: String, version: String) =
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 <reified T> getBundlesByType() =
sources.first().filterIsInstance<T>()
suspend fun reloadApiBundles() {
getBundlesByType<APIPatchBundle>().forEach {
it.deleteLocalFiles()
State(sources.toPersistentMap(), info.toPersistentMap())
}
reload()
}
suspend fun redownloadRemoteBundles() =
getBundlesByType<RemotePatchBundle>().forEach { it.downloadLatest() }
suspend fun updateCheck() =
uiSafe(app, R.string.source_download_fail, "Failed to update bundles") {
coroutineScope {
if (!networkInfo.isSafe()) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope
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<RemotePatchBundle>().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<APIPatchBundle>().forEach {
with(it) { deleteLocalFile() }
updateDb(it.uid) { it.copy(versionHash = null) }
}
doReload()
}
suspend fun RemotePatchBundle.setAutoUpdate(value: Boolean) =
dispatchAction("Set auto update ($name, $value)") { state ->
updateDb(uid) { it.copy(autoUpdate = value) }
val newSrc = (state.sources[uid] as? RemotePatchBundle)?.copy(autoUpdate = value)
?: return@dispatchAction state
state.copy(sources = state.sources.put(uid, newSrc))
}
suspend fun update(vararg sources: RemotePatchBundle, showToast: Boolean = false) {
val uids = sources.map { it.uid }.toSet()
store.dispatch(Update(showToast = showToast) { it.uid in uids })
}
suspend fun redownloadRemoteBundles() = store.dispatch(Update(force = true))
/**
* Updates all bundles that should be automatically updated.
*/
suspend fun updateCheck() = store.dispatch(Update { it.autoUpdate })
private inner class Update(
private val force: Boolean = false,
private val showToast: Boolean = false,
private val predicate: (bundle: RemotePatchBundle) -> Boolean = { true },
) : Action<State> {
private suspend fun toast(@StringRes id: Int, vararg args: Any?) =
withContext(Dispatchers.Main) { app.toast(app.getString(id, *args)) }
override fun toString() = if (force) "Redownload remote bundles" else "Update check"
override suspend fun ActionContext.execute(
current: State
) = coroutineScope {
if (!networkInfo.isSafe()) {
Log.d(tag, "Skipping update check because the network is down or metered.")
return@coroutineScope current
}
val updated = current.sources.values
.filterIsInstance<RemotePatchBundle>()
.filter { predicate(it) }
.map {
async {
Log.d(tag, "Updating patch bundle: ${it.name}")
val newVersion = with(it) {
if (force) downloadLatest() else update()
} ?: return@async null
it to newVersion
}
}
.awaitAll()
.filterNotNull()
.toMap()
if (updated.isEmpty()) {
if (showToast) toast(R.string.patches_update_unavailable)
return@coroutineScope current
}
updated.forEach { (src, newVersionHash) ->
val name = src.patchBundle?.manifestAttributes?.name ?: src.name
updateDb(src.uid) {
it.copy(versionHash = newVersionHash, name = name)
}
}
if (showToast) toast(R.string.patches_update_success)
doReload()
}
override suspend fun catch(exception: Exception) {
Log.e(tag, "Failed to update patches", exception)
toast(R.string.patches_download_fail, exception.simpleMessage())
}
}
data class State(
val sources: PersistentMap<Int, PatchBundleSource> = persistentMapOf(),
val info: PersistentMap<Int, PatchBundleInfo.Global> = persistentMapOf()
)
private companion object {
val defaultSource = PatchBundleEntity(
uid = 0,
name = "",
versionHash = null,
source = Source.API,
autoUpdate = false
)
}
}

View File

@@ -17,7 +17,6 @@ import io.ktor.http.isSuccess
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.core.isNotEmpty
import io.ktor.utils.io.core.readBytes
import it.skrape.core.htmlDocument
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json

View File

@@ -1,56 +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 PatchBundle(val patchesJar: File) {
private val loader = object : Iterable<Patch<*>> {
private fun load(): Iterable<Patch<*>> {
patchesJar.setReadOnly()
return PatchLoader.Dex(setOf(patchesJar))
}
override fun iterator(): Iterator<Patch<*>> = load().iterator()
}
init {
Log.d(tag, "Loaded patch bundle: $patchesJar")
}
/**
* A list containing the metadata of every patch inside this bundle.
*/
val patches = loader.map(::PatchInfo)
@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
}
fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)
/**
* Load all patches compatible with the specified package.
*/
fun patches(packageName: String) = loader.filter { patch ->
val compatiblePackages = patch.compatiblePackages
?: // The patch has no compatibility constraints, which means it is universal.
return@filter true
if (!compatiblePackages.any { (name, _) -> name == packageName }) {
// Patch is not compatible with this package.
return@filter false
@IgnoredOnParcel
private val manifest by lazy {
try {
JarFile(patchesJar).use { it.manifest }
} catch (_: IOException) {
null
}
true
}
}
@IgnoredOnParcel
val manifestAttributes by lazy {
if (manifest != null)
ManifestAttributes(
name = readManifestAttribute("name"),
version = readManifestAttribute("version"),
description = readManifestAttribute("description"),
source = readManifestAttribute("source"),
author = readManifestAttribute("author"),
contact = readManifestAttribute("contact"),
website = readManifestAttribute("website"),
license = readManifestAttribute("license")
) else
null
}
private fun readManifestAttribute(name: String) = manifest?.mainAttributes?.getValue(name)
?.takeIf { it.isNotBlank() } // If empty, set it to null instead.
data class ManifestAttributes(
val name: String?,
val version: String?,
val description: String?,
val source: String?,
val author: String?,
val contact: String?,
val website: String?,
val license: String?
)
object Loader {
private fun patches(bundles: Iterable<PatchBundle>) =
loadPatchesFromDex(
bundles.map { File(it.patchesJar) }.toSet()
).byPatchesFile.mapKeys { (file, _) ->
val absPath = file.absolutePath
bundles.single { absPath == it.patchesJar }
}
fun metadata(bundles: Iterable<PatchBundle>) =
patches(bundles).mapValues { (_, patches) -> patches.map(::PatchInfo) }
fun patches(bundles: Iterable<PatchBundle>, packageName: String) =
patches(bundles).mapValues { (_, patches) ->
patches.filter { patch ->
val compatiblePackages = patch.compatiblePackages
?: // The patch has no compatibility constraints, which means it is universal.
return@filter true
if (!compatiblePackages.any { (name, _) -> name == packageName }) {
// Patch is not compatible with this package.
return@filter false
}
true
}.toSet()
}
}
}

View File

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

View File

@@ -3,6 +3,7 @@ package app.revanced.manager.patcher.runtime
import android.content.Context
import 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")
}

View File

@@ -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()
)
}
)

View File

@@ -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<String>,
val options: @RawValue Map<String, Map<String, Any?>>
) : Parcelable

View File

@@ -56,11 +56,10 @@ class PatcherProcess(private val context: Context) : IPatcherProcess.Stub() {
logger.info("Memory limit: ${Runtime.getRuntime().maxMemory() / (1024 * 1024)}MB")
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) ->

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,68 +1,99 @@
package app.revanced.manager.ui.component.bundle
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<PatchBundleRepository>()
val networkInfo = koinInject<NetworkInfo>()
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 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 = stringResource(R.string.patch_bundle_field),
title = src.name,
onBackClick = onDismissRequest,
backIcon = {
Icon(
@@ -71,7 +102,7 @@ fun BundleInformationDialog(
)
},
actions = {
if (!bundle.isDefault) {
if (!src.isDefault) {
IconButton(onClick = onDeleteRequest) {
Icon(
Icons.Outlined.DeleteOutline,
@@ -91,54 +122,175 @@ fun BundleInformationDialog(
)
},
) { paddingValues ->
BaseBundleDialog(
modifier = Modifier.padding(paddingValues),
isDefault = bundle.isDefault,
name = bundleName,
remoteUrl = bundle.asRemoteOrNull?.endpoint,
patchCount = patchCount,
version = version,
autoUpdate = props?.autoUpdate == true,
onAutoUpdateChange = {
composableScope.launch {
bundle.asRemoteOrNull?.setAutoUpdate(it)
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.bundle_error),
supportingText = stringResource(R.string.bundle_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.bundle_error),
supportingText = stringResource(R.string.bundle_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,
)
}
}

View File

@@ -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,
)
}
@@ -67,8 +61,8 @@ fun BundleItem(
onDelete()
viewBundleDialogPage = false
},
title = stringResource(R.string.bundle_delete_single_dialog_title),
description = stringResource(R.string.bundle_delete_single_dialog_description, name),
title = stringResource(R.string.delete),
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) {
is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.bundle_error
is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.bundle_missing
is PatchBundleSource.State.Loaded -> null
val icon = remember(src.state) {
when (src.state) {
is PatchBundleSource.State.Failed -> Icons.Outlined.ErrorOutline to R.string.patches_error
is PatchBundleSource.State.Missing -> Icons.Outlined.Warning to R.string.patches_missing
is PatchBundleSource.State.Available -> null
}
}
@@ -115,7 +109,7 @@ fun BundleItem(
)
}
version?.let { Text(text = it) }
src.version?.let { Text(text = it) }
}
},
)

View File

@@ -12,6 +12,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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,
@@ -46,7 +53,7 @@ fun BundlePatchesDialog(
Scaffold(
topBar = {
BundleTopBar(
title = stringResource(R.string.bundle_patches),
title = stringResource(R.string.patches),
onBackClick = onDismissRequest,
backIcon = {
Icon(
@@ -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 }
)
}
}
}
@@ -133,10 +138,10 @@ fun PatchItem(
verticalAlignment = Alignment.CenterVertically
) {
PatchInfoChip(
text = "$PACKAGE_ICON ${stringResource(R.string.bundle_view_patches_any_package)}"
text = "$PACKAGE_ICON ${stringResource(R.string.patches_view_any_package)}"
)
PatchInfoChip(
text = "$VERSION_ICON ${stringResource(R.string.bundle_view_patches_any_version)}"
text = "$VERSION_ICON ${stringResource(R.string.patches_view_any_version)}"
)
}
} else {

View File

@@ -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.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import 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 kotlinx.coroutines.flow.map
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSource?) -> Unit) {
LaunchedEffect(bundles) {
if (bundles.size == 1) {
onFinish(bundles[0])
fun BundleSelector(sources: List<PatchBundleSource>, onFinish: (PatchBundleSource?) -> Unit) {
LaunchedEffect(sources) {
if (sources.size == 1) {
onFinish(sources[0])
}
}
if (bundles.size < 2) {
if (sources.size < 2) {
return
}
@@ -50,15 +47,12 @@ fun BundleSelector(bundles: List<PatchBundleSource>, onFinish: (PatchBundleSourc
.fillMaxWidth()
) {
Text(
text = "Select bundle",
text = stringResource(R.string.select),
style = MaterialTheme.typography.titleLarge,
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<PatchBundleSource>, onFinish: (PatchBundleSourc
}
) {
Text(
"$name $version",
"${it.name} ${it.version}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,59 +3,74 @@ package app.revanced.manager.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.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<PatchBundleSource>,
selectedSources: SnapshotStateList<PatchBundleSource>,
bundlesSelectable: Boolean,
viewModel: BundleListViewModel = koinViewModel(),
eventsFlow: Flow<BundleListViewModel.Event>,
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)
}
}
}
)
)
}
}
}
}

View File

@@ -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
@@ -79,7 +79,7 @@ enum class DashboardPage(
val icon: ImageVector
) {
DASHBOARD(R.string.tab_apps, Icons.Outlined.Apps),
BUNDLES(R.string.tab_bundles, Icons.Outlined.Source),
BUNDLES(R.string.tab_patches, Icons.Outlined.Source),
}
@SuppressLint("BatteryLife")
@@ -93,7 +93,8 @@ fun DashboardScreen(
onDownloaderPluginClick: () -> Unit,
onAppClick: (String) -> Unit
) {
val bundlesSelectable by remember { derivedStateOf { vm.selectedSources.size > 0 } }
var selectedSourceCount by rememberSaveable { mutableIntStateOf(0) }
val bundlesSelectable by remember { derivedStateOf { selectedSourceCount > 0 } }
val availablePatches by vm.availablePatches.collectAsStateWithLifecycle(0)
val showNewDownloaderPluginsNotification by vm.newDownloaderPluginsAvailable.collectAsStateWithLifecycle(
false
@@ -160,12 +161,9 @@ fun DashboardScreen(
if (showDeleteConfirmationDialog) {
ConfirmDialog(
onDismiss = { showDeleteConfirmationDialog = false },
onConfirm = {
vm.selectedSources.forEach { if (!it.isDefault) vm.delete(it) }
vm.cancelSourceSelection()
},
title = stringResource(R.string.bundle_delete_multiple_dialog_title),
description = stringResource(R.string.bundle_delete_multiple_dialog_description),
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.bundles_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,
@@ -239,7 +234,7 @@ fun DashboardScreen(
when (pagerState.currentPage) {
DashboardPage.DASHBOARD.ordinal -> {
if (availablePatches < 1) {
androidContext.toast(androidContext.getString(R.string.patches_unavailable))
androidContext.toast(androidContext.getString(R.string.no_patch_found))
composableScope.launch {
pagerState.animateScrollToPage(
DashboardPage.BUNDLES.ordinal
@@ -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 }
)
}
}

View File

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

View File

@@ -30,12 +30,12 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R
import app.revanced.manager.patcher.patch.Option
import app.revanced.manager.patcher.patch.PatchBundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.LazyColumnWithScrollbar
import app.revanced.manager.ui.component.haptics.HapticExtendedFloatingActionButton
import app.revanced.manager.ui.component.haptics.HapticTab
import app.revanced.manager.ui.component.patches.OptionItem
import app.revanced.manager.ui.model.BundleInfo.Extensions.requiredOptionsSet
import app.revanced.manager.ui.viewmodel.PatchesSelectorViewModel
import app.revanced.manager.util.Options
import app.revanced.manager.util.PatchSelection
@@ -62,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) }
)
@@ -153,7 +154,8 @@ fun RequiredOptionsScreen(
value = value,
setValue = { new ->
vm.setOption(bundle.uid, it, key, new)
}
},
selectionWarningEnabled = vm.selectionWarningEnabled
)
}
}

View File

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

View File

@@ -2,7 +2,6 @@ package app.revanced.manager.ui.screen.settings
import androidx.annotation.StringRes
import androidx.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
)
}
}
}
}

View File

@@ -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)
}
}
}
@@ -240,8 +240,8 @@ fun ImportExportSettingsScreen(
}
}
},
headline = R.string.patch_selection_reset_bundle,
description = R.string.patch_selection_reset_bundle_description
headline = R.string.patch_selection_reset_patches,
description = R.string.patch_selection_reset_patches_description
)
}
}
@@ -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)
}
}
}
@@ -296,8 +296,8 @@ fun ImportExportSettingsScreen(
}
}
},
headline = R.string.patch_options_reset_bundle,
description = R.string.patch_options_reset_bundle_description,
headline = R.string.patch_options_reset_patches,
description = R.string.patch_options_reset_patches_description,
)
}
}

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
package app.revanced.manager.ui.viewmodel
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<PowerManager>()!!
val sources = patchBundleRepository.sources
val selectedSources = mutableStateListOf<PatchBundleSource>()
val newDownloaderPluginsAvailable = downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() }
val newDownloaderPluginsAvailable =
downloaderPluginRepository.newPluginPackageNames.map { it.isNotEmpty() }
/**
* Android 11 kills the app process after granting the "install apps" permission, which is a problem for the patcher screen.
@@ -59,6 +61,9 @@ class DashboardViewModel(
var showBatteryOptimizationsWarning by mutableStateOf(false)
private set
private val bundleListEventsChannel = Channel<BundleListViewModel.Event>()
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.source_download_fail,
RemotePatchBundle.updateFailMsg
) {
if (bundle.update())
app.toast(app.getString(R.string.bundle_update_success, bundle.getName()))
else
app.toast(app.getString(R.string.bundle_update_unavailable, bundle.getName()))
}
fun createRemoteSource(apiUrl: String, autoUpdate: Boolean) = viewModelScope.launch {
patchBundleRepository.createRemote(apiUrl, autoUpdate)
}
}

View File

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

View File

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

View File

@@ -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<PreferencesManager>().disablePatchVersionCompatCheck.getBlocking()
val bundlesFlow =
get<PatchBundleRepository>().bundleInfoFlow(packageName, input.app.version)
get<PatchBundleRepository>().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<BundleInfo>) = bundles.any { bundle ->
fun selectionIsValid(bundles: List<PatchBundleInfo.Scoped>) = 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 {

View File

@@ -28,15 +28,14 @@ import app.revanced.manager.domain.repository.InstalledAppRepository
import app.revanced.manager.domain.repository.PatchBundleRepository
import app.revanced.manager.domain.repository.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<Options>(emptyMap())
viewModelScope.launch {
if (!persistConfiguration) return@launch // TODO: save options for patched apps.
val bundlePatches = bundleInfoFlow.first()
.associate { it.uid to it.patches.associateBy { patch -> patch.name } }
options = withContext(Dispatchers.Default) {
val bundlePatches = bundleRepository.bundles.first()
.mapValues { (_, bundle) -> bundle.patches.associateBy { it.name } }
optionsRepository.getOptions(packageName, bundlePatches)
}
}
@@ -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<PatchBundleInfo.Scoped>) = options.filtered(bundles)
suspend fun hasSetRequiredOptions(patchSelection: PatchSelection) = bundleInfoFlow
.first()
.requiredOptionsSet(
allowIncompatible = prefs.disablePatchVersionCompatCheck.get(),
isSelected = { bundle, patch -> patch.name in patchSelection[bundle.uid]!! },
optionsForPatch = { bundle, patch -> options[bundle.uid]?.get(patch.name) },
)
@@ -283,23 +283,23 @@ class SelectedAppInfoViewModel(
)
}
fun getOptionsFiltered(bundles: List<BundleInfo>) = options.filtered(bundles)
fun getPatches(bundles: List<BundleInfo>, allowIncompatible: Boolean) =
fun getPatches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
selectionState.patches(bundles, allowIncompatible)
fun getCustomPatches(
bundles: List<BundleInfo>,
bundles: List<PatchBundleInfo.Scoped>,
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<BundleInfo>): Options = buildMap options@{
bundles.forEach bundles@{ bundle ->
val bundleOptions = this@filtered[bundle.uid] ?: return@bundles
private fun Options.filtered(bundles: List<PatchBundleInfo.Scoped>): 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<BundleInfo>, allowIncompatible: Boolean): PatchSelection
fun patches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean): PatchSelection
@Parcelize
data class Customized(val patchSelection: PatchSelection) : SelectionState {
override fun patches(bundles: List<BundleInfo>, allowIncompatible: Boolean) =
override fun patches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
bundles.toPatchSelection(
allowIncompatible
) { uid, patch ->
@@ -356,7 +357,7 @@ private sealed interface SelectionState : Parcelable {
@Parcelize
data object Default : SelectionState {
override fun patches(bundles: List<BundleInfo>, allowIncompatible: Boolean) =
override fun patches(bundles: List<PatchBundleInfo.Scoped>, allowIncompatible: Boolean) =
bundles.toPatchSelection(allowIncompatible) { _, patch -> patch.include }
}
}

View File

@@ -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()

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ dev-tools-gradle-plugin = "2.1.10-1.0.29"
about-libraries-gradle-plugin = "12.1.2"
coil = "2.7.0"
app-icon-loader-coil = "1.5.0"
skrapeit = "1.2.2"
libsu = "6.0.0"
scrollbars = "1.0.4"
enumutil = "1.1.1"
@@ -100,10 +99,6 @@ ktor-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "k
ktor-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
# HTML Scraper
skrapeit-dsl = { group = "it.skrape", name = "skrapeit-dsl", version.ref = "skrapeit" }
skrapeit-parser = { group = "it.skrape", name = "skrapeit-html-parser", version.ref = "skrapeit" }
# Markdown
markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }