feat: save patch selection using room db (#38)

This commit is contained in:
Ax333l 2023-06-22 12:20:30 +02:00 committed by GitHub
parent 1ffaf43b82
commit f0edf35206
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 766 additions and 150 deletions

View File

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "b40f3b048880f3f3c9361f6d1c4aaea5", "identityHash": "dadad726e82673e2a4c266bf7a7c8af1",
"entities": [ "entities": [
{ {
"tableName": "sources", "tableName": "sources",
@ -57,12 +57,106 @@
} }
], ],
"foreignKeys": [] "foreignKeys": []
},
{
"tableName": "patch_selections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER NOT NULL, `source` INTEGER NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`source`) REFERENCES `sources`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "source",
"columnName": "source",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "packageName",
"columnName": "package_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_patch_selections_source_package_name",
"unique": true,
"columnNames": [
"source",
"package_name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_selections_source_package_name` ON `${TABLE_NAME}` (`source`, `package_name`)"
}
],
"foreignKeys": [
{
"table": "sources",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"source"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "selected_patches",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`selection` INTEGER NOT NULL, `patch_name` TEXT NOT NULL, PRIMARY KEY(`selection`, `patch_name`), FOREIGN KEY(`selection`) REFERENCES `patch_selections`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "selection",
"columnName": "selection",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "patchName",
"columnName": "patch_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"selection",
"patch_name"
]
},
"indices": [],
"foreignKeys": [
{
"table": "patch_selections",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"selection"
],
"referencedColumns": [
"uid"
]
}
]
} }
], ],
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b40f3b048880f3f3c9361f6d1c4aaea5')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dadad726e82673e2a4c266bf7a7c8af1')"
] ]
} }
} }

View File

@ -3,11 +3,20 @@ package app.revanced.manager.data.room
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import app.revanced.manager.data.room.selection.PatchSelection
import app.revanced.manager.data.room.selection.SelectedPatch
import app.revanced.manager.data.room.selection.SelectionDao
import app.revanced.manager.data.room.sources.SourceEntity import app.revanced.manager.data.room.sources.SourceEntity
import app.revanced.manager.data.room.sources.SourceDao import app.revanced.manager.data.room.sources.SourceDao
import kotlin.random.Random
@Database(entities = [SourceEntity::class], version = 1) @Database(entities = [SourceEntity::class, PatchSelection::class, SelectedPatch::class], version = 1)
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun sourceDao(): SourceDao abstract fun sourceDao(): SourceDao
abstract fun selectionDao(): SelectionDao
companion object {
fun generateUid() = Random.Default.nextInt()
}
} }

View File

@ -0,0 +1,24 @@
package app.revanced.manager.data.room.selection
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import app.revanced.manager.data.room.sources.SourceEntity
@Entity(
tableName = "patch_selections",
foreignKeys = [ForeignKey(
SourceEntity::class,
parentColumns = ["uid"],
childColumns = ["source"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index(value = ["source", "package_name"], unique = true)]
)
data class PatchSelection(
@PrimaryKey val uid: Int,
@ColumnInfo(name = "source") val source: Int,
@ColumnInfo(name = "package_name") val packageName: String
)

View File

@ -0,0 +1,20 @@
package app.revanced.manager.data.room.selection
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Entity(
tableName = "selected_patches",
primaryKeys = ["selection", "patch_name"],
foreignKeys = [ForeignKey(
PatchSelection::class,
parentColumns = ["uid"],
childColumns = ["selection"],
onDelete = ForeignKey.CASCADE
)]
)
data class SelectedPatch(
@ColumnInfo(name = "selection") val selection: Int,
@ColumnInfo(name = "patch_name") val patchName: String
)

View File

@ -0,0 +1,53 @@
package app.revanced.manager.data.room.selection
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapInfo
import androidx.room.Query
import androidx.room.Transaction
@Dao
abstract class SelectionDao {
@Transaction
@MapInfo(keyColumn = "source", valueColumn = "patch_name")
@Query(
"SELECT source, patch_name FROM patch_selections" +
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
" WHERE package_name = :packageName"
)
abstract suspend fun getSelectedPatches(packageName: String): Map<Int, List<String>>
@Transaction
@MapInfo(keyColumn = "package_name", valueColumn = "patch_name")
@Query(
"SELECT package_name, patch_name FROM patch_selections" +
" LEFT JOIN selected_patches ON uid = selected_patches.selection" +
" WHERE source = :sourceUid"
)
abstract suspend fun exportSelection(sourceUid: Int): Map<String, List<String>>
@Query("SELECT uid FROM patch_selections WHERE source = :sourceUid AND package_name = :packageName")
abstract suspend fun getSelectionId(sourceUid: Int, packageName: String): Int?
@Insert
abstract suspend fun createSelection(selection: PatchSelection)
@Query("DELETE FROM patch_selections WHERE source = :uid")
abstract suspend fun clearForSource(uid: Int)
@Query("DELETE FROM patch_selections")
abstract suspend fun reset()
@Insert
protected abstract suspend fun selectPatches(patches: List<SelectedPatch>)
@Query("DELETE FROM selected_patches WHERE selection = :selectionId")
protected abstract suspend fun clearSelection(selectionId: Int)
@Transaction
open suspend fun updateSelections(selections: Map<Int, Set<String>>) =
selections.map { (selectionUid, patches) ->
clearSelection(selectionUid)
selectPatches(patches.map { SelectedPatch(selectionUid, it) })
}
}

View File

@ -1,5 +1,6 @@
package app.revanced.manager.di package app.revanced.manager.di
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.ReVancedRepository import app.revanced.manager.domain.repository.ReVancedRepository
import app.revanced.manager.network.api.ManagerAPI import app.revanced.manager.network.api.ManagerAPI
import app.revanced.manager.domain.repository.SourcePersistenceRepository import app.revanced.manager.domain.repository.SourcePersistenceRepository
@ -11,5 +12,6 @@ val repositoryModule = module {
singleOf(::ReVancedRepository) singleOf(::ReVancedRepository)
singleOf(::ManagerAPI) singleOf(::ManagerAPI)
singleOf(::SourcePersistenceRepository) singleOf(::SourcePersistenceRepository)
singleOf(::PatchSelectionRepository)
singleOf(::SourceRepository) singleOf(::SourceRepository)
} }

View File

@ -0,0 +1,44 @@
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.selection.PatchSelection
import app.revanced.manager.domain.sources.Source
class PatchSelectionRepository(db: AppDatabase) {
private val dao = db.selectionDao()
private suspend fun getOrCreateSelection(sourceUid: Int, packageName: String) =
dao.getSelectionId(sourceUid, packageName) ?: PatchSelection(
uid = generateUid(),
source = sourceUid,
packageName = packageName
).also { dao.createSelection(it) }.uid
suspend fun getSelection(packageName: String): Map<Int, Set<String>> =
dao.getSelectedPatches(packageName).mapValues { it.value.toSet() }
suspend fun updateSelection(packageName: String, selection: Map<Int, Set<String>>) =
dao.updateSelections(selection.mapKeys { (sourceUid, _) ->
getOrCreateSelection(
sourceUid,
packageName
)
})
suspend fun reset() = dao.reset()
suspend fun export(source: Source): SerializedSelection = dao.exportSelection(source.uid)
suspend fun import(source: Source, selection: SerializedSelection) {
dao.clearForSource(source.uid)
dao.updateSelections(selection.entries.associate { (packageName, patches) ->
getOrCreateSelection(source.uid, packageName) to patches.toSet()
})
}
}
/**
* A [Map] of package name -> selected patches.
*/
typealias SerializedSelection = Map<String, List<String>>

View File

@ -1,19 +1,17 @@
package app.revanced.manager.domain.repository package app.revanced.manager.domain.repository
import app.revanced.manager.data.room.AppDatabase import app.revanced.manager.data.room.AppDatabase
import app.revanced.manager.data.room.AppDatabase.Companion.generateUid
import app.revanced.manager.data.room.sources.SourceEntity import app.revanced.manager.data.room.sources.SourceEntity
import app.revanced.manager.data.room.sources.SourceLocation import app.revanced.manager.data.room.sources.SourceLocation
import app.revanced.manager.data.room.sources.VersionInfo import app.revanced.manager.data.room.sources.VersionInfo
import app.revanced.manager.util.apiURL import app.revanced.manager.util.apiURL
import kotlin.random.Random
import io.ktor.http.* import io.ktor.http.*
class SourcePersistenceRepository(db: AppDatabase) { class SourcePersistenceRepository(db: AppDatabase) {
private val dao = db.sourceDao() private val dao = db.sourceDao()
private companion object { private companion object {
fun generateUid() = Random.Default.nextInt()
val defaultSource = SourceEntity( val defaultSource = SourceEntity(
uid = generateUid(), uid = generateUid(),
name = "Official", name = "Official",

View File

@ -7,14 +7,13 @@ import app.revanced.manager.data.room.sources.SourceLocation
import app.revanced.manager.domain.sources.LocalSource import app.revanced.manager.domain.sources.LocalSource
import app.revanced.manager.domain.sources.RemoteSource import app.revanced.manager.domain.sources.RemoteSource
import app.revanced.manager.domain.sources.Source import app.revanced.manager.domain.sources.Source
import app.revanced.manager.util.flatMapLatestAndCombine
import app.revanced.manager.util.tag import app.revanced.manager.util.tag
import io.ktor.http.* import io.ktor.http.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@ -23,16 +22,13 @@ import java.io.InputStream
class SourceRepository(app: Application, private val persistenceRepo: SourcePersistenceRepository) { class SourceRepository(app: Application, private val persistenceRepo: SourcePersistenceRepository) {
private val sourcesDir = app.dataDir.resolve("sources").also { it.mkdirs() } private val sourcesDir = app.dataDir.resolve("sources").also { it.mkdirs() }
private val _sources: MutableStateFlow<Map<String, Source>> = MutableStateFlow(emptyMap()) private val _sources: MutableStateFlow<Map<Int, Source>> = MutableStateFlow(emptyMap())
val sources = _sources.asStateFlow() val sources = _sources.map { it.values.toList() }
@OptIn(ExperimentalCoroutinesApi::class) val bundles = sources.flatMapLatestAndCombine(
val bundles = sources.flatMapLatest { sources -> combiner = { it.toMap() }
combine( ) {
sources.map { (_, source) -> source.bundle } it.bundle.map { bundle -> it.uid to bundle }
) { bundles ->
sources.keys.zip(bundles).toMap()
}
} }
/** /**
@ -41,8 +37,8 @@ class SourceRepository(app: Application, private val persistenceRepo: SourcePers
private fun directoryOf(uid: Int) = sourcesDir.resolve(uid.toString()).also { it.mkdirs() } private fun directoryOf(uid: Int) = sourcesDir.resolve(uid.toString()).also { it.mkdirs() }
private fun SourceEntity.load(dir: File) = when (location) { private fun SourceEntity.load(dir: File) = when (location) {
is SourceLocation.Local -> LocalSource(uid, dir) is SourceLocation.Local -> LocalSource(name, uid, dir)
is SourceLocation.Remote -> RemoteSource(uid, dir) is SourceLocation.Remote -> RemoteSource(name, uid, dir)
} }
suspend fun loadSources() = withContext(Dispatchers.Default) { suspend fun loadSources() = withContext(Dispatchers.Default) {
@ -54,7 +50,7 @@ class SourceRepository(app: Application, private val persistenceRepo: SourcePers
val dir = directoryOf(it.uid) val dir = directoryOf(it.uid)
val source = it.load(dir) val source = it.load(dir)
it.name to source it.uid to source
} }
_sources.emit(sources) _sources.emit(sources)
@ -72,33 +68,33 @@ class SourceRepository(app: Application, private val persistenceRepo: SourcePers
} }
suspend fun remove(source: Source) = withContext(Dispatchers.Default) { suspend fun remove(source: Source) = withContext(Dispatchers.Default) {
persistenceRepo.delete(source.id) persistenceRepo.delete(source.uid)
directoryOf(source.id).deleteRecursively() directoryOf(source.uid).deleteRecursively()
_sources.update { _sources.update {
it.filterValues { value -> it.filterValues { value ->
value.id != source.id value.uid != source.uid
} }
} }
} }
private fun addSource(name: String, source: Source) = private fun addSource(source: Source) =
_sources.update { it.toMutableMap().apply { put(name, source) } } _sources.update { it.toMutableMap().apply { put(source.uid, source) } }
suspend fun createLocalSource(name: String, patches: InputStream, integrations: InputStream?) { suspend fun createLocalSource(name: String, patches: InputStream, integrations: InputStream?) {
val id = persistenceRepo.create(name, SourceLocation.Local) val id = persistenceRepo.create(name, SourceLocation.Local)
val source = LocalSource(id, directoryOf(id)) val source = LocalSource(name, id, directoryOf(id))
addSource(name, source) addSource(source)
source.replace(patches, integrations) source.replace(patches, integrations)
} }
suspend fun createRemoteSource(name: String, apiUrl: Url) { suspend fun createRemoteSource(name: String, apiUrl: Url) {
val id = persistenceRepo.create(name, SourceLocation.Remote(apiUrl)) val id = persistenceRepo.create(name, SourceLocation.Remote(apiUrl))
addSource(name, RemoteSource(id, directoryOf(id))) addSource(RemoteSource(name, id, directoryOf(id)))
} }
suspend fun redownloadRemoteSources() = suspend fun redownloadRemoteSources() =
sources.value.values.filterIsInstance<RemoteSource>().forEach { it.downloadLatest() } sources.first().filterIsInstance<RemoteSource>().forEach { it.downloadLatest() }
} }

View File

@ -7,7 +7,7 @@ import java.io.InputStream
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
class LocalSource(id: Int, directory: File) : Source(id, directory) { class LocalSource(name: String, id: Int, directory: File) : Source(name, id, directory) {
suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) { suspend fun replace(patches: InputStream? = null, integrations: InputStream? = null) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
patches?.let { patches?.let {

View File

@ -8,7 +8,7 @@ import org.koin.core.component.get
import java.io.File import java.io.File
@Stable @Stable
class RemoteSource(id: Int, directory: File) : Source(id, directory) { class RemoteSource(name: String, id: Int, directory: File) : Source(name, id, directory) {
private val api: ManagerAPI = get() private val api: ManagerAPI = get()
suspend fun downloadLatest() = withContext(Dispatchers.IO) { suspend fun downloadLatest() = withContext(Dispatchers.IO) {

View File

@ -15,7 +15,7 @@ import java.io.File
* A [PatchBundle] source. * A [PatchBundle] source.
*/ */
@Stable @Stable
sealed class Source(val id: Int, directory: File) : KoinComponent { sealed class Source(val name: String, val uid: Int, directory: File) : KoinComponent {
private val configRepository: SourcePersistenceRepository by inject() private val configRepository: SourcePersistenceRepository by inject()
protected companion object { protected companion object {
/** /**
@ -35,9 +35,9 @@ sealed class Source(val id: Int, directory: File) : KoinComponent {
*/ */
fun hasInstalled() = patchesJar.exists() fun hasInstalled() = patchesJar.exists()
protected suspend fun getVersion() = configRepository.getVersion(id) protected suspend fun getVersion() = configRepository.getVersion(uid)
protected suspend fun saveVersion(patches: String, integrations: String) = protected suspend fun saveVersion(patches: String, integrations: String) =
configRepository.updateVersion(id, patches, integrations) configRepository.updateVersion(uid, patches, integrations)
// TODO: Communicate failure states better. // TODO: Communicate failure states better.
protected fun loadBundle(onFail: (Throwable) -> Unit = ::logError) = if (!hasInstalled()) emptyPatchBundle protected fun loadBundle(onFail: (Throwable) -> Unit = ::logError) = if (!hasInstalled()) emptyPatchBundle

View File

@ -24,7 +24,7 @@ import java.io.InputStream
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SourceItem(name: String, source: Source, onDelete: () -> Unit) { fun SourceItem(source: Source, onDelete: () -> Unit) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var sheetActive by rememberSaveable { mutableStateOf(false) } var sheetActive by rememberSaveable { mutableStateOf(false) }
@ -48,7 +48,7 @@ fun SourceItem(name: String, source: Source, onDelete: () -> Unit) {
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Text( Text(
text = name, text = source.name,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
@ -81,7 +81,7 @@ fun SourceItem(name: String, source: Source, onDelete: () -> Unit) {
} }
) { ) {
Text( Text(
text = name, text = source.name,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(padding) modifier = Modifier.padding(padding)

View File

@ -0,0 +1,73 @@
package app.revanced.manager.ui.component.sources
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.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.revanced.manager.domain.sources.Source
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SourceSelector(sources: List<Source>, onFinish: (Source?) -> Unit) {
LaunchedEffect(sources) {
if (sources.size == 1) {
onFinish(sources[0])
}
}
if (sources.size < 2) {
return
}
ModalBottomSheet(
onDismissRequest = { onFinish(null) }
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
) {
Text(
text = "Select bundle",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
sources.forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.clickable {
onFinish(it)
}
) {
Text(
text = it.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}

View File

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
@ -62,7 +63,7 @@ fun PatchesSelectorScreen(
val pagerState = rememberPagerState() val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyList()) val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyArray())
if (vm.compatibleVersions.isNotEmpty()) if (vm.compatibleVersions.isNotEmpty())
UnsupportedDialog( UnsupportedDialog(
@ -89,9 +90,15 @@ fun PatchesSelectorScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
ExtendedFloatingActionButton(text = { Text(stringResource(R.string.patch)) }, ExtendedFloatingActionButton(
text = { Text(stringResource(R.string.patch)) },
icon = { Icon(Icons.Default.Build, null) }, icon = { Icon(Icons.Default.Build, null) },
onClick = { onPatchClick(vm.generateSelection()) }) onClick = {
coroutineScope.launch {
onPatchClick(vm.getAndSaveSelection())
}
}
)
} }
) { paddingValues -> ) { paddingValues ->
Column( Column(
@ -121,8 +128,7 @@ fun PatchesSelectorScreen(
state = pagerState, state = pagerState,
userScrollEnabled = true, userScrollEnabled = true,
pageContent = { index -> pageContent = { index ->
val bundle = bundles[index]
val (bundleName, supportedPatches, unsupportedPatches, universalPatches) = bundles[index]
Column { Column {
@ -140,7 +146,7 @@ fun PatchesSelectorScreen(
FilterChip( FilterChip(
selected = vm.filter and SHOW_UNIVERSAL != 0, selected = vm.filter and SHOW_UNIVERSAL != 0,
onClick = { vm.toggleFlag(SHOW_UNIVERSAL) }, onClick = { vm.toggleFlag(SHOW_UNIVERSAL) },
label = { Text(stringResource(R.string.universal)) } label = { Text(stringResource(R.string.universal)) }
) )
@ -154,62 +160,58 @@ fun PatchesSelectorScreen(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
if (supportedPatches.isNotEmpty() && (vm.filter and SHOW_SUPPORTED != 0 || vm.filter == 0)) { fun LazyListScope.patchList(
patches: List<PatchInfo>,
filterFlag: Int,
supported: Boolean,
header: (@Composable () -> Unit)? = null
) {
if (patches.isNotEmpty() && (vm.filter and filterFlag) != 0 || vm.filter == 0) {
header?.let {
item {
it()
}
}
items( items(
items = supportedPatches, items = patches,
key = { it.name } key = { it.name }
) { patch -> ) { patch ->
PatchItem( PatchItem(
patch = patch, patch = patch,
onOptionsDialog = vm::openOptionsDialog, onOptionsDialog = vm::openOptionsDialog,
onToggle = { vm.togglePatch(bundleName, patch) }, selected = supported && vm.isSelected(bundle.uid, patch),
selected = vm.isSelected(bundleName, patch) onToggle = { vm.togglePatch(bundle.uid, patch) },
) supported = supported
)
}
} }
} }
if (universalPatches.isNotEmpty() && (vm.filter and SHOW_UNIVERSAL != 0 || vm.filter == 0)) { patchList(
item { patches = bundle.supported,
ListHeader( filterFlag = SHOW_SUPPORTED,
title = stringResource(R.string.universal_patches), supported = true
onHelpClick = { } )
) patchList(
} patches = bundle.universal,
filterFlag = SHOW_UNIVERSAL,
items( supported = true
items = universalPatches, ) {
key = { it.name } ListHeader(
) { patch -> title = stringResource(R.string.universal_patches),
PatchItem( onHelpClick = { }
patch = patch, )
onOptionsDialog = vm::openOptionsDialog,
onToggle = { vm.togglePatch(bundleName, patch) },
selected = vm.isSelected(bundleName, patch)
)
}
} }
patchList(
if (unsupportedPatches.isNotEmpty() && (vm.filter and SHOW_UNSUPPORTED != 0 || vm.filter == 0)) { patches = bundle.unsupported,
item { filterFlag = SHOW_UNSUPPORTED,
ListHeader( supported = allowUnsupported
title = stringResource(R.string.unsupported_patches), ) {
onHelpClick = { vm.openUnsupportedDialog(unsupportedPatches) } ListHeader(
) title = stringResource(R.string.unsupported_patches),
} onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) }
)
items(
items = unsupportedPatches,
key = { it.name }
) { patch ->
PatchItem(
patch = patch,
onOptionsDialog = vm::openOptionsDialog,
onToggle = { vm.togglePatch(bundleName, patch) },
selected = vm.isSelected(bundleName, patch),
supported = allowUnsupported
)
}
} }
} }
} }
@ -262,14 +264,16 @@ fun ListHeader(
style = MaterialTheme.typography.labelLarge style = MaterialTheme.typography.labelLarge
) )
}, },
trailingContent = onHelpClick?.let { { trailingContent = onHelpClick?.let {
IconButton(onClick = onHelpClick) { {
Icon( IconButton(onClick = onHelpClick) {
Icons.Outlined.HelpOutline, Icon(
stringResource(R.string.help) Icons.Outlined.HelpOutline,
) stringResource(R.string.help)
)
}
} }
} } }
) )
} }
@ -286,7 +290,15 @@ fun UnsupportedDialog(
} }
}, },
title = { Text(stringResource(R.string.unsupported_app)) }, title = { Text(stringResource(R.string.unsupported_app)) },
text = { Text(stringResource(R.string.app_not_supported, appVersion, supportedVersions.joinToString(", "))) } text = {
Text(
stringResource(
R.string.app_not_supported,
appVersion,
supportedVersions.joinToString(", ")
)
)
}
) )
@Composable @Composable

View File

@ -19,7 +19,7 @@ fun SourcesScreen(vm: SourcesViewModel = getViewModel()) {
var showNewSourceDialog by rememberSaveable { mutableStateOf(false) } var showNewSourceDialog by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sources by vm.sources.collectAsStateWithLifecycle() val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
if (showNewSourceDialog) NewSourceDialog( if (showNewSourceDialog) NewSourceDialog(
onDismissRequest = { showNewSourceDialog = false }, onDismissRequest = { showNewSourceDialog = false },
@ -41,12 +41,11 @@ fun SourcesScreen(vm: SourcesViewModel = getViewModel()) {
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
) { ) {
sources.forEach { (name, source) -> sources.forEach {
SourceItem( SourceItem(
name = name, source = it,
source = source,
onDelete = { onDelete = {
vm.deleteSource(source) vm.delete(it)
} }
) )
} }

View File

@ -18,6 +18,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.ui.viewmodel.ImportExportViewModel import app.revanced.manager.ui.viewmodel.ImportExportViewModel
import app.revanced.manager.domain.manager.KeystoreManager.Companion.DEFAULT import app.revanced.manager.domain.manager.KeystoreManager.Companion.DEFAULT
@ -26,6 +27,7 @@ import app.revanced.manager.domain.manager.PreferencesManager
import app.revanced.manager.ui.component.AppTopBar import app.revanced.manager.ui.component.AppTopBar
import app.revanced.manager.ui.component.FileSelector import app.revanced.manager.ui.component.FileSelector
import app.revanced.manager.ui.component.GroupHeader import app.revanced.manager.ui.component.GroupHeader
import app.revanced.manager.ui.component.sources.SourceSelector
import org.koin.androidx.compose.getViewModel import org.koin.androidx.compose.getViewModel
import org.koin.compose.rememberKoinInject import org.koin.compose.rememberKoinInject
@ -38,16 +40,38 @@ fun ImportExportSettingsScreen(
var showImportKeystoreDialog by rememberSaveable { mutableStateOf(false) } var showImportKeystoreDialog by rememberSaveable { mutableStateOf(false) }
var showExportKeystoreDialog by rememberSaveable { mutableStateOf(false) } var showExportKeystoreDialog by rememberSaveable { mutableStateOf(false) }
vm.selectionAction?.let { action ->
val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList())
val launcher = rememberLauncherForActivityResult(action.activityContract) { uri ->
if (uri == null) {
vm.clearSelectionAction()
} else {
vm.executeSelectionAction(uri)
}
}
if (vm.selectedSource == null) {
SourceSelector(sources) {
if (it == null) {
vm.clearSelectionAction()
} else {
vm.selectSource(it)
launcher.launch(action.activityArg)
}
}
}
}
if (showImportKeystoreDialog) { if (showImportKeystoreDialog) {
ImportKeystoreDialog( ImportKeystoreDialog(
onDismissRequest = { showImportKeystoreDialog = false }, onDismissRequest = { showImportKeystoreDialog = false },
onImport = vm::import onImport = vm::importKeystore
) )
} }
if (showExportKeystoreDialog) { if (showExportKeystoreDialog) {
ExportKeystoreDialog( ExportKeystoreDialog(
onDismissRequest = { showExportKeystoreDialog = false }, onDismissRequest = { showExportKeystoreDialog = false },
onExport = vm::export onExport = vm::exportKeystore
) )
} }
@ -81,10 +105,27 @@ fun ImportExportSettingsScreen(
description = R.string.export_keystore_description description = R.string.export_keystore_description
) )
GroupItem( GroupItem(
onClick = vm::regenerate, onClick = vm::regenerateKeystore,
headline = R.string.regenerate_keystore, headline = R.string.regenerate_keystore,
description = R.string.regenerate_keystore_description description = R.string.regenerate_keystore_description
) )
GroupHeader(stringResource(R.string.patches_selection))
GroupItem(
onClick = vm::importSelection,
headline = R.string.restore_patches_selection,
description = R.string.restore_patches_selection_description
)
GroupItem(
onClick = vm::exportSelection,
headline = R.string.backup_patches_selection,
description = R.string.backup_patches_selection_description
)
GroupItem(
onClick = vm::resetSelection,
headline = R.string.clear_patches_selection,
description = R.string.clear_patches_selection_description
)
} }
} }
} }

View File

@ -1,22 +1,123 @@
package app.revanced.manager.ui.viewmodel package app.revanced.manager.ui.viewmodel
import android.app.Application import android.app.Application
import android.net.Uri import android.net.Uri
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SerializedSelection
import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.domain.sources.Source
import app.revanced.manager.util.JSON_MIMETYPE
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import app.revanced.manager.util.uiSafe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
class ImportExportViewModel(private val app: Application, private val keystoreManager: KeystoreManager) : ViewModel() { @OptIn(ExperimentalSerializationApi::class)
class ImportExportViewModel(
private val app: Application,
private val keystoreManager: KeystoreManager,
private val selectionRepository: PatchSelectionRepository,
sourceRepository: SourceRepository
) : ViewModel() {
private val contentResolver = app.contentResolver private val contentResolver = app.contentResolver
val sources = sourceRepository.sources
var selectedSource by mutableStateOf<Source?>(null)
private set
var selectionAction by mutableStateOf<SelectionAction?>(null)
private set
fun import(content: Uri, cn: String, pass: String) = fun importKeystore(content: Uri, cn: String, pass: String) =
keystoreManager.import(cn, pass, contentResolver.openInputStream(content)!!) keystoreManager.import(cn, pass, contentResolver.openInputStream(content)!!)
fun export(target: Uri) = keystoreManager.export(contentResolver.openOutputStream(target)!!) fun exportKeystore(target: Uri) =
keystoreManager.export(contentResolver.openOutputStream(target)!!)
fun regenerate() = keystoreManager.regenerate().also { fun regenerateKeystore() = keystoreManager.regenerate().also {
app.toast(app.getString(R.string.regenerate_keystore_success)) app.toast(app.getString(R.string.regenerate_keystore_success))
} }
fun resetSelection() = viewModelScope.launch(Dispatchers.Default) {
selectionRepository.reset()
}
fun executeSelectionAction(target: Uri) = viewModelScope.launch {
val source = selectedSource!!
val action = selectionAction!!
clearSelectionAction()
action.execute(source, target)
}
fun selectSource(source: Source) {
selectedSource = source
}
fun clearSelectionAction() {
selectionAction = null
selectedSource = null
}
fun importSelection() = clearSelectionAction().also {
selectionAction = Import()
}
fun exportSelection() = clearSelectionAction().also {
selectionAction = Export()
}
sealed interface SelectionAction {
suspend fun execute(source: Source, location: Uri)
val activityContract: ActivityResultContract<String, Uri?>
val activityArg: String
}
private inner class Import : SelectionAction {
override val activityContract = ActivityResultContracts.GetContent()
override val activityArg = JSON_MIMETYPE
override suspend fun execute(source: Source, location: Uri) = uiSafe(
app,
R.string.restore_patches_selection_fail,
"Failed to restore patches selection"
) {
val selection = withContext(Dispatchers.IO) {
contentResolver.openInputStream(location)!!.use {
Json.decodeFromStream<SerializedSelection>(it)
}
}
selectionRepository.import(source, selection)
}
}
private inner class Export : SelectionAction {
override val activityContract = ActivityResultContracts.CreateDocument(JSON_MIMETYPE)
override val activityArg = "selection.json"
override suspend fun execute(source: Source, location: Uri) = uiSafe(
app,
R.string.backup_patches_selection_fail,
"Failed to backup patches selection"
) {
val selection = selectionRepository.export(source)
withContext(Dispatchers.IO) {
contentResolver.openOutputStream(location, "wt")!!.use {
Json.Default.encodeToStream(selection, it)
}
}
}
}
} }

View File

@ -3,14 +3,26 @@ package app.revanced.manager.ui.viewmodel
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.revanced.manager.domain.repository.PatchSelectionRepository
import app.revanced.manager.domain.repository.SourceRepository import app.revanced.manager.domain.repository.SourceRepository
import app.revanced.manager.patcher.patch.PatchInfo import app.revanced.manager.patcher.patch.PatchInfo
import app.revanced.manager.ui.screen.allowUnsupported
import app.revanced.manager.util.AppInfo import app.revanced.manager.util.AppInfo
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.SnapshotStateSet
import app.revanced.manager.util.flatMapLatestAndCombine
import app.revanced.manager.util.mutableStateSetOf
import app.revanced.manager.util.toMutableStateSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
@ -18,48 +30,91 @@ import org.koin.core.component.get
class PatchesSelectorViewModel( class PatchesSelectorViewModel(
val appInfo: AppInfo val appInfo: AppInfo
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val selectionRepository: PatchSelectionRepository = get()
val bundlesFlow = get<SourceRepository>().bundles.map { bundles -> val bundlesFlow = get<SourceRepository>().sources.flatMapLatestAndCombine(
bundles.mapValues { (_, bundle) -> bundle.patches }.map { (name, patches) -> combiner = { it }
) { source ->
// Regenerate bundle information whenever this source updates.
source.bundle.map { bundle ->
val supported = mutableListOf<PatchInfo>() val supported = mutableListOf<PatchInfo>()
val unsupported = mutableListOf<PatchInfo>() val unsupported = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>() val universal = mutableListOf<PatchInfo>()
patches.filter { it.compatibleWith(appInfo.packageName) }.forEach { bundle.patches.filter { it.compatibleWith(appInfo.packageName) }.forEach {
val targetList = if (it.compatiblePackages == null) universal else if (it.supportsVersion(appInfo.packageInfo!!.versionName)) supported else unsupported val targetList =
if (it.compatiblePackages == null) universal else if (it.supportsVersion(
appInfo.packageInfo!!.versionName
)
) supported else unsupported
targetList.add(it) targetList.add(it)
} }
Bundle(name, supported, unsupported, universal) BundleInfo(source.name, source.uid, bundle.patches, supported, unsupported, universal)
} }
} }
private val selectedPatches = mutableStateListOf<Pair<String, String>>() private val selectedPatches = mutableStateMapOf<Int, SnapshotStateSet<String>>()
fun isSelected(bundle: String, patch: PatchInfo) = selectedPatches.contains(bundle to patch.name)
fun togglePatch(bundle: String, patch: PatchInfo) {
val pair = bundle to patch.name
if (isSelected(bundle, patch)) selectedPatches.remove(pair) else selectedPatches.add(pair)
}
fun generateSelection(): PatchesSelection = HashMap<String, MutableList<String>>().apply {
selectedPatches.forEach { (bundleName, patchName) ->
this.getOrPut(bundleName, ::mutableListOf).add(patchName)
}
}
data class Bundle(
val name: String,
val supported: List<PatchInfo>,
val unsupported: List<PatchInfo>,
val universal: List<PatchInfo>
)
var showOptionsDialog by mutableStateOf(false) var showOptionsDialog by mutableStateOf(false)
private set private set
val compatibleVersions = mutableStateListOf<String>() val compatibleVersions = mutableStateListOf<String>()
var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNSUPPORTED)
private set
private fun getOrCreateSelection(bundle: Int) =
selectedPatches.getOrPut(bundle, ::mutableStateSetOf)
fun isSelected(bundle: Int, patch: PatchInfo) =
selectedPatches[bundle]?.contains(patch.name) ?: false
fun togglePatch(bundle: Int, patch: PatchInfo) {
val name = patch.name
val patches = getOrCreateSelection(bundle)
if (patches.contains(name)) patches.remove(name) else patches.add(name)
}
suspend fun getAndSaveSelection(): PatchesSelection = withContext(Dispatchers.Default) {
selectedPatches.also {
selectionRepository.updateSelection(appInfo.packageName, it)
}.mapValues { it.value.toMutableList() }.apply {
if (allowUnsupported) {
return@apply
}
// Filter out unsupported patches that may have gotten selected through the database if the setting is not enabled.
bundlesFlow.first().forEach {
this[it.uid]?.removeAll(it.unsupported.map { patch -> patch.name })
}
}
}
init {
viewModelScope.launch(Dispatchers.Default) {
val bundles = bundlesFlow.first()
val filteredSelection =
selectionRepository.getSelection(appInfo.packageName).mapValues { (uid, patches) ->
// Filter out patches that don't exist.
val filteredPatches = bundles.singleOrNull { it.uid == uid }
?.let { bundle ->
val allPatches = bundle.all.map { it.name }
patches.filter { allPatches.contains(it) }
}
?: patches
filteredPatches.toMutableStateSet()
}
withContext(Dispatchers.Main) {
selectedPatches.putAll(filteredSelection)
}
}
}
fun dismissDialogs() { fun dismissDialogs() {
showOptionsDialog = false showOptionsDialog = false
compatibleVersions.clear() compatibleVersions.clear()
@ -73,17 +128,15 @@ class PatchesSelectorViewModel(
val set = HashSet<String>() val set = HashSet<String>()
unsupportedVersions.forEach { patch -> unsupportedVersions.forEach { patch ->
patch.compatiblePackages?.find { it.name == appInfo.packageName }?.let { compatiblePackage -> patch.compatiblePackages?.find { it.name == appInfo.packageName }
set.addAll(compatiblePackage.versions) ?.let { compatiblePackage ->
} set.addAll(compatiblePackage.versions)
}
} }
compatibleVersions.addAll(set) compatibleVersions.addAll(set)
} }
var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNSUPPORTED)
private set
fun toggleFlag(flag: Int) { fun toggleFlag(flag: Int) {
filter = filter xor flag filter = filter xor flag
} }
@ -93,4 +146,13 @@ class PatchesSelectorViewModel(
const val SHOW_UNIVERSAL = 2 // 2^1 const val SHOW_UNIVERSAL = 2 // 2^1
const val SHOW_UNSUPPORTED = 4 // 2^2 const val SHOW_UNSUPPORTED = 4 // 2^2
} }
data class BundleInfo(
val name: String,
val uid: Int,
val all: List<PatchInfo>,
val supported: List<PatchInfo>,
val unsupported: List<PatchInfo>,
val universal: List<PatchInfo>
)
} }

View File

@ -39,7 +39,7 @@ class SourcesViewModel(private val app: Application, private val sourceRepositor
suspend fun addRemote(name: String, apiUrl: Url) = sourceRepository.createRemoteSource(name, apiUrl) suspend fun addRemote(name: String, apiUrl: Url) = sourceRepository.createRemoteSource(name, apiUrl)
fun deleteSource(source: Source) = viewModelScope.launch { sourceRepository.remove(source) } fun delete(source: Source) = viewModelScope.launch { sourceRepository.remove(source) }
fun deleteAllSources() = viewModelScope.launch { fun deleteAllSources() = viewModelScope.launch {
sourceRepository.resetConfig() sourceRepository.resetConfig()

View File

@ -11,4 +11,5 @@ const val tag = "ReVanced Manager"
const val apiURL = "https://releases.revanced.app" const val apiURL = "https://releases.revanced.app"
const val JAR_MIMETYPE = "application/java-archive" const val JAR_MIMETYPE = "application/java-archive"
const val APK_MIMETYPE = "application/vnd.android.package-archive" const val APK_MIMETYPE = "application/vnd.android.package-archive"
const val JSON_MIMETYPE = "application/json"

View File

@ -0,0 +1,60 @@
package app.revanced.manager.util
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Source: https://gist.github.com/alexvanyo/a31826820ded6f654fb96291aff6b425
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.runtime.snapshots.StateObject
/**
* An implementation of [MutableSet] that can be observed and snapshot. This is the result type
* created by [mutableStateSetOf].
*
* This class closely implements the same semantics as [HashSet].
*
* This class is backed by a [SnapshotStateMap].
*
* @see mutableStateSetOf
*/
@Stable
class SnapshotStateSet<T> private constructor(
private val delegateSnapshotStateMap: SnapshotStateMap<T, Unit>,
) : MutableSet<T> by delegateSnapshotStateMap.keys, StateObject by delegateSnapshotStateMap {
constructor() : this(delegateSnapshotStateMap = mutableStateMapOf())
override fun add(element: T): Boolean =
delegateSnapshotStateMap.put(element, Unit) == null
override fun addAll(elements: Collection<T>): Boolean =
elements.map(::add).any()
override fun remove(element: T) = delegateSnapshotStateMap.remove(element) != null
}
/**
* Create a instance of [MutableSet]<T> that is observable and can be snapshot.
*/
fun <T> mutableStateSetOf() = SnapshotStateSet<T>()
/**
* Create an instance of [MutableSet]<T> from a collection that is observable and can be
* snapshot.
*/
fun <T> Collection<T>.toMutableStateSet() = SnapshotStateSet<T>().also { it.addAll(this) }

View File

@ -16,13 +16,17 @@ import androidx.work.Data
import androidx.work.workDataOf import androidx.work.workDataOf
import io.ktor.http.Url import io.ktor.http.Url
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.cbor.Cbor
import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray import kotlinx.serialization.encodeToByteArray
typealias PatchesSelection = Map<String, List<String>> typealias PatchesSelection = Map<Int, List<String>>
fun Context.openUrl(url: String) { fun Context.openUrl(url: String) {
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply { startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply {
@ -82,6 +86,20 @@ inline fun LifecycleOwner.launchAndRepeatWithViewLifecycle(
} }
} }
/**
* Run [transformer] on the [Iterable] and then [combine] the result using [combiner].
* This is used to transform collections that contain [Flow]s into something that is easier to work with.
*/
@OptIn(ExperimentalCoroutinesApi::class)
inline fun <T, reified R, C> Flow<Iterable<T>>.flatMapLatestAndCombine(
crossinline combiner: (Array<R>) -> C,
crossinline transformer: (T) -> Flow<R>,
): Flow<C> = flatMapLatest { iterable ->
combine(iterable.map(transformer)) {
combiner(it)
}
}
const val workDataKey = "payload" const val workDataKey = "payload"
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)

View File

@ -37,6 +37,15 @@
<string name="regenerate_keystore">Regenerate keystore</string> <string name="regenerate_keystore">Regenerate keystore</string>
<string name="regenerate_keystore_description">Generate a new keystore</string> <string name="regenerate_keystore_description">Generate a new keystore</string>
<string name="regenerate_keystore_success">The keystore has been successfully replaced</string> <string name="regenerate_keystore_success">The keystore has been successfully replaced</string>
<string name="patches_selection">Patches selection</string>
<string name="restore_patches_selection">Restore patches selections</string>
<string name="restore_patches_selection_description">Restore patches selection from a file</string>
<string name="restore_patches_selection_fail">Failed to restore patches selection: %s</string>
<string name="backup_patches_selection">Backup patches selections</string>
<string name="backup_patches_selection_description">Backup patches selection to a file</string>
<string name="backup_patches_selection_fail">Failed to backup patches selection: %s</string>
<string name="clear_patches_selection">Clear patches selection</string>
<string name="clear_patches_selection_description">Clear all patches selection</string>
<string name="search_apps">Search apps…</string> <string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string> <string name="loading_body">Loading…</string>