diff --git a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json index bddce40..f47f93a 100644 --- a/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json +++ b/app/schemas/app.revanced.manager.data.room.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "b40f3b048880f3f3c9361f6d1c4aaea5", + "identityHash": "dadad726e82673e2a4c266bf7a7c8af1", "entities": [ { "tableName": "sources", @@ -57,12 +57,106 @@ } ], "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": [], "setupQueries": [ "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')" ] } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt index 14748c3..4515bbe 100644 --- a/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt +++ b/app/src/main/java/app/revanced/manager/data/room/AppDatabase.kt @@ -3,11 +3,20 @@ package app.revanced.manager.data.room import androidx.room.Database import androidx.room.RoomDatabase 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.SourceDao +import kotlin.random.Random -@Database(entities = [SourceEntity::class], version = 1) +@Database(entities = [SourceEntity::class, PatchSelection::class, SelectedPatch::class], version = 1) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { abstract fun sourceDao(): SourceDao + abstract fun selectionDao(): SelectionDao + + companion object { + fun generateUid() = Random.Default.nextInt() + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/PatchSelection.kt b/app/src/main/java/app/revanced/manager/data/room/selection/PatchSelection.kt new file mode 100644 index 0000000..16ed490 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/selection/PatchSelection.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/SelectedPatch.kt b/app/src/main/java/app/revanced/manager/data/room/selection/SelectedPatch.kt new file mode 100644 index 0000000..c190364 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/selection/SelectedPatch.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt new file mode 100644 index 0000000..c85d1e8 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/data/room/selection/SelectionDao.kt @@ -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> + + @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> + + @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) + + @Query("DELETE FROM selected_patches WHERE selection = :selectionId") + protected abstract suspend fun clearSelection(selectionId: Int) + + @Transaction + open suspend fun updateSelections(selections: Map>) = + selections.map { (selectionUid, patches) -> + clearSelection(selectionUid) + selectPatches(patches.map { SelectedPatch(selectionUid, it) }) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt index eb7b5b9..c7f458a 100644 --- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt @@ -1,5 +1,6 @@ package app.revanced.manager.di +import app.revanced.manager.domain.repository.PatchSelectionRepository import app.revanced.manager.domain.repository.ReVancedRepository import app.revanced.manager.network.api.ManagerAPI import app.revanced.manager.domain.repository.SourcePersistenceRepository @@ -11,5 +12,6 @@ val repositoryModule = module { singleOf(::ReVancedRepository) singleOf(::ManagerAPI) singleOf(::SourcePersistenceRepository) + singleOf(::PatchSelectionRepository) singleOf(::SourceRepository) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt new file mode 100644 index 0000000..0aca845 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/repository/PatchSelectionRepository.kt @@ -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> = + dao.getSelectedPatches(packageName).mapValues { it.value.toSet() } + + suspend fun updateSelection(packageName: String, selection: Map>) = + 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> \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/repository/SourcePersistenceRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/SourcePersistenceRepository.kt index 0122a5d..fd380e4 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/SourcePersistenceRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/SourcePersistenceRepository.kt @@ -1,19 +1,17 @@ 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.sources.SourceEntity import app.revanced.manager.data.room.sources.SourceLocation import app.revanced.manager.data.room.sources.VersionInfo import app.revanced.manager.util.apiURL -import kotlin.random.Random import io.ktor.http.* class SourcePersistenceRepository(db: AppDatabase) { private val dao = db.sourceDao() private companion object { - fun generateUid() = Random.Default.nextInt() - val defaultSource = SourceEntity( uid = generateUid(), name = "Official", diff --git a/app/src/main/java/app/revanced/manager/domain/repository/SourceRepository.kt b/app/src/main/java/app/revanced/manager/domain/repository/SourceRepository.kt index 7e4c10c..ac183bf 100644 --- a/app/src/main/java/app/revanced/manager/domain/repository/SourceRepository.kt +++ b/app/src/main/java/app/revanced/manager/domain/repository/SourceRepository.kt @@ -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.RemoteSource import app.revanced.manager.domain.sources.Source +import app.revanced.manager.util.flatMapLatestAndCombine import app.revanced.manager.util.tag import io.ktor.http.* import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import java.io.File @@ -23,16 +22,13 @@ import java.io.InputStream class SourceRepository(app: Application, private val persistenceRepo: SourcePersistenceRepository) { private val sourcesDir = app.dataDir.resolve("sources").also { it.mkdirs() } - private val _sources: MutableStateFlow> = MutableStateFlow(emptyMap()) - val sources = _sources.asStateFlow() + private val _sources: MutableStateFlow> = MutableStateFlow(emptyMap()) + val sources = _sources.map { it.values.toList() } - @OptIn(ExperimentalCoroutinesApi::class) - val bundles = sources.flatMapLatest { sources -> - combine( - sources.map { (_, source) -> source.bundle } - ) { bundles -> - sources.keys.zip(bundles).toMap() - } + val bundles = sources.flatMapLatestAndCombine( + combiner = { it.toMap() } + ) { + it.bundle.map { bundle -> it.uid to bundle } } /** @@ -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 SourceEntity.load(dir: File) = when (location) { - is SourceLocation.Local -> LocalSource(uid, dir) - is SourceLocation.Remote -> RemoteSource(uid, dir) + is SourceLocation.Local -> LocalSource(name, uid, dir) + is SourceLocation.Remote -> RemoteSource(name, uid, dir) } suspend fun loadSources() = withContext(Dispatchers.Default) { @@ -54,7 +50,7 @@ class SourceRepository(app: Application, private val persistenceRepo: SourcePers val dir = directoryOf(it.uid) val source = it.load(dir) - it.name to source + it.uid to source } _sources.emit(sources) @@ -72,33 +68,33 @@ class SourceRepository(app: Application, private val persistenceRepo: SourcePers } suspend fun remove(source: Source) = withContext(Dispatchers.Default) { - persistenceRepo.delete(source.id) - directoryOf(source.id).deleteRecursively() + persistenceRepo.delete(source.uid) + directoryOf(source.uid).deleteRecursively() _sources.update { it.filterValues { value -> - value.id != source.id + value.uid != source.uid } } } - private fun addSource(name: String, source: Source) = - _sources.update { it.toMutableMap().apply { put(name, source) } } + private fun addSource(source: Source) = + _sources.update { it.toMutableMap().apply { put(source.uid, source) } } suspend fun createLocalSource(name: String, patches: InputStream, integrations: InputStream?) { 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) } suspend fun createRemoteSource(name: String, apiUrl: Url) { val id = persistenceRepo.create(name, SourceLocation.Remote(apiUrl)) - addSource(name, RemoteSource(id, directoryOf(id))) + addSource(RemoteSource(name, id, directoryOf(id))) } suspend fun redownloadRemoteSources() = - sources.value.values.filterIsInstance().forEach { it.downloadLatest() } + sources.first().filterIsInstance().forEach { it.downloadLatest() } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt index e119342..cb410a4 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/LocalSource.kt @@ -7,7 +7,7 @@ import java.io.InputStream import java.nio.file.Files 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) { withContext(Dispatchers.IO) { patches?.let { diff --git a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt index 472e26f..7002886 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/RemoteSource.kt @@ -8,7 +8,7 @@ import org.koin.core.component.get import java.io.File @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() suspend fun downloadLatest() = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt index 20f8031..3486956 100644 --- a/app/src/main/java/app/revanced/manager/domain/sources/Source.kt +++ b/app/src/main/java/app/revanced/manager/domain/sources/Source.kt @@ -15,7 +15,7 @@ import java.io.File * A [PatchBundle] source. */ @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() protected companion object { /** @@ -35,9 +35,9 @@ sealed class Source(val id: Int, directory: File) : KoinComponent { */ 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) = - configRepository.updateVersion(id, patches, integrations) + configRepository.updateVersion(uid, patches, integrations) // TODO: Communicate failure states better. protected fun loadBundle(onFail: (Throwable) -> Unit = ::logError) = if (!hasInstalled()) emptyPatchBundle diff --git a/app/src/main/java/app/revanced/manager/ui/component/sources/SourceItem.kt b/app/src/main/java/app/revanced/manager/ui/component/sources/SourceItem.kt index c1542cd..624ff4d 100644 --- a/app/src/main/java/app/revanced/manager/ui/component/sources/SourceItem.kt +++ b/app/src/main/java/app/revanced/manager/ui/component/sources/SourceItem.kt @@ -24,7 +24,7 @@ import java.io.InputStream @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SourceItem(name: String, source: Source, onDelete: () -> Unit) { +fun SourceItem(source: Source, onDelete: () -> Unit) { val coroutineScope = rememberCoroutineScope() var sheetActive by rememberSaveable { mutableStateOf(false) } @@ -48,7 +48,7 @@ fun SourceItem(name: String, source: Source, onDelete: () -> Unit) { verticalArrangement = Arrangement.Center, ) { Text( - text = name, + text = source.name, style = MaterialTheme.typography.titleLarge ) @@ -81,7 +81,7 @@ fun SourceItem(name: String, source: Source, onDelete: () -> Unit) { } ) { Text( - text = name, + text = source.name, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(padding) diff --git a/app/src/main/java/app/revanced/manager/ui/component/sources/SourceSelector.kt b/app/src/main/java/app/revanced/manager/ui/component/sources/SourceSelector.kt new file mode 100644 index 0000000..2fd0e52 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/component/sources/SourceSelector.kt @@ -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, 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 + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt index bfe71e9..e6698d5 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/PatchesSelectorScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -62,7 +63,7 @@ fun PatchesSelectorScreen( val pagerState = rememberPagerState() val coroutineScope = rememberCoroutineScope() - val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + val bundles by vm.bundlesFlow.collectAsStateWithLifecycle(initialValue = emptyArray()) if (vm.compatibleVersions.isNotEmpty()) UnsupportedDialog( @@ -89,9 +90,15 @@ fun PatchesSelectorScreen( ) }, floatingActionButton = { - ExtendedFloatingActionButton(text = { Text(stringResource(R.string.patch)) }, + ExtendedFloatingActionButton( + text = { Text(stringResource(R.string.patch)) }, icon = { Icon(Icons.Default.Build, null) }, - onClick = { onPatchClick(vm.generateSelection()) }) + onClick = { + coroutineScope.launch { + onPatchClick(vm.getAndSaveSelection()) + } + } + ) } ) { paddingValues -> Column( @@ -121,8 +128,7 @@ fun PatchesSelectorScreen( state = pagerState, userScrollEnabled = true, pageContent = { index -> - - val (bundleName, supportedPatches, unsupportedPatches, universalPatches) = bundles[index] + val bundle = bundles[index] Column { @@ -140,7 +146,7 @@ fun PatchesSelectorScreen( FilterChip( selected = vm.filter and SHOW_UNIVERSAL != 0, - onClick = { vm.toggleFlag(SHOW_UNIVERSAL) }, + onClick = { vm.toggleFlag(SHOW_UNIVERSAL) }, label = { Text(stringResource(R.string.universal)) } ) @@ -154,62 +160,58 @@ fun PatchesSelectorScreen( LazyColumn( modifier = Modifier.fillMaxSize() ) { - if (supportedPatches.isNotEmpty() && (vm.filter and SHOW_SUPPORTED != 0 || vm.filter == 0)) { + fun LazyListScope.patchList( + patches: List, + 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 = supportedPatches, - key = { it.name } - ) { patch -> - PatchItem( - patch = patch, - onOptionsDialog = vm::openOptionsDialog, - onToggle = { vm.togglePatch(bundleName, patch) }, - selected = vm.isSelected(bundleName, patch) - ) + items( + items = patches, + key = { it.name } + ) { patch -> + PatchItem( + patch = patch, + onOptionsDialog = vm::openOptionsDialog, + selected = supported && vm.isSelected(bundle.uid, patch), + onToggle = { vm.togglePatch(bundle.uid, patch) }, + supported = supported + ) + } } } - if (universalPatches.isNotEmpty() && (vm.filter and SHOW_UNIVERSAL != 0 || vm.filter == 0)) { - item { - ListHeader( - title = stringResource(R.string.universal_patches), - onHelpClick = { } - ) - } - - items( - items = universalPatches, - key = { it.name } - ) { patch -> - PatchItem( - patch = patch, - onOptionsDialog = vm::openOptionsDialog, - onToggle = { vm.togglePatch(bundleName, patch) }, - selected = vm.isSelected(bundleName, patch) - ) - } + patchList( + patches = bundle.supported, + filterFlag = SHOW_SUPPORTED, + supported = true + ) + patchList( + patches = bundle.universal, + filterFlag = SHOW_UNIVERSAL, + supported = true + ) { + ListHeader( + title = stringResource(R.string.universal_patches), + onHelpClick = { } + ) } - - if (unsupportedPatches.isNotEmpty() && (vm.filter and SHOW_UNSUPPORTED != 0 || vm.filter == 0)) { - item { - ListHeader( - title = stringResource(R.string.unsupported_patches), - onHelpClick = { vm.openUnsupportedDialog(unsupportedPatches) } - ) - } - - 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 - ) - } + patchList( + patches = bundle.unsupported, + filterFlag = SHOW_UNSUPPORTED, + supported = allowUnsupported + ) { + ListHeader( + title = stringResource(R.string.unsupported_patches), + onHelpClick = { vm.openUnsupportedDialog(bundle.unsupported) } + ) } } } @@ -262,14 +264,16 @@ fun ListHeader( style = MaterialTheme.typography.labelLarge ) }, - trailingContent = onHelpClick?.let { { - IconButton(onClick = onHelpClick) { - Icon( - Icons.Outlined.HelpOutline, - stringResource(R.string.help) - ) + trailingContent = onHelpClick?.let { + { + IconButton(onClick = onHelpClick) { + Icon( + Icons.Outlined.HelpOutline, + stringResource(R.string.help) + ) + } } - } } + } ) } @@ -286,7 +290,15 @@ fun UnsupportedDialog( } }, 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 diff --git a/app/src/main/java/app/revanced/manager/ui/screen/SourcesScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/SourcesScreen.kt index aabac67..f42702a 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/SourcesScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/SourcesScreen.kt @@ -19,7 +19,7 @@ fun SourcesScreen(vm: SourcesViewModel = getViewModel()) { var showNewSourceDialog by rememberSaveable { mutableStateOf(false) } val scope = rememberCoroutineScope() - val sources by vm.sources.collectAsStateWithLifecycle() + val sources by vm.sources.collectAsStateWithLifecycle(initialValue = emptyList()) if (showNewSourceDialog) NewSourceDialog( onDismissRequest = { showNewSourceDialog = false }, @@ -41,12 +41,11 @@ fun SourcesScreen(vm: SourcesViewModel = getViewModel()) { modifier = Modifier .fillMaxSize(), ) { - sources.forEach { (name, source) -> + sources.forEach { SourceItem( - name = name, - source = source, + source = it, onDelete = { - vm.deleteSource(source) + vm.delete(it) } ) } diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt index cbdd975..4cd2bdd 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.revanced.manager.R import app.revanced.manager.ui.viewmodel.ImportExportViewModel 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.FileSelector import app.revanced.manager.ui.component.GroupHeader +import app.revanced.manager.ui.component.sources.SourceSelector import org.koin.androidx.compose.getViewModel import org.koin.compose.rememberKoinInject @@ -38,16 +40,38 @@ fun ImportExportSettingsScreen( var showImportKeystoreDialog 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) { ImportKeystoreDialog( onDismissRequest = { showImportKeystoreDialog = false }, - onImport = vm::import + onImport = vm::importKeystore ) } if (showExportKeystoreDialog) { ExportKeystoreDialog( onDismissRequest = { showExportKeystoreDialog = false }, - onExport = vm::export + onExport = vm::exportKeystore ) } @@ -81,10 +105,27 @@ fun ImportExportSettingsScreen( description = R.string.export_keystore_description ) GroupItem( - onClick = vm::regenerate, + onClick = vm::regenerateKeystore, headline = R.string.regenerate_keystore, 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 + ) } } } diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt index c7029d4..4304071 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt @@ -1,22 +1,123 @@ package app.revanced.manager.ui.viewmodel - import android.app.Application 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.viewModelScope import app.revanced.manager.R 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.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 + val sources = sourceRepository.sources + var selectedSource by mutableStateOf(null) + private set + var selectionAction by mutableStateOf(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)!!) - 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)) } + + 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 + 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(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) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt index cbda928..ca37310 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/PatchesSelectorViewModel.kt @@ -3,14 +3,26 @@ package app.revanced.manager.ui.viewmodel import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue 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.patcher.patch.PatchInfo +import app.revanced.manager.ui.screen.allowUnsupported import app.revanced.manager.util.AppInfo 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.launch +import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.get @@ -18,48 +30,91 @@ import org.koin.core.component.get class PatchesSelectorViewModel( val appInfo: AppInfo ) : ViewModel(), KoinComponent { + private val selectionRepository: PatchSelectionRepository = get() - val bundlesFlow = get().bundles.map { bundles -> - bundles.mapValues { (_, bundle) -> bundle.patches }.map { (name, patches) -> + val bundlesFlow = get().sources.flatMapLatestAndCombine( + combiner = { it } + ) { source -> + // Regenerate bundle information whenever this source updates. + source.bundle.map { bundle -> val supported = mutableListOf() val unsupported = mutableListOf() val universal = mutableListOf() - patches.filter { it.compatibleWith(appInfo.packageName) }.forEach { - val targetList = if (it.compatiblePackages == null) universal else if (it.supportsVersion(appInfo.packageInfo!!.versionName)) supported else unsupported + 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 targetList.add(it) } - Bundle(name, supported, unsupported, universal) + BundleInfo(source.name, source.uid, bundle.patches, supported, unsupported, universal) } } - private val selectedPatches = mutableStateListOf>() - 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>().apply { - selectedPatches.forEach { (bundleName, patchName) -> - this.getOrPut(bundleName, ::mutableListOf).add(patchName) - } - } - - data class Bundle( - val name: String, - val supported: List, - val unsupported: List, - val universal: List - ) + private val selectedPatches = mutableStateMapOf>() var showOptionsDialog by mutableStateOf(false) private set val compatibleVersions = mutableStateListOf() + 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() { showOptionsDialog = false compatibleVersions.clear() @@ -73,17 +128,15 @@ class PatchesSelectorViewModel( val set = HashSet() unsupportedVersions.forEach { patch -> - patch.compatiblePackages?.find { it.name == appInfo.packageName }?.let { compatiblePackage -> - set.addAll(compatiblePackage.versions) - } + patch.compatiblePackages?.find { it.name == appInfo.packageName } + ?.let { compatiblePackage -> + set.addAll(compatiblePackage.versions) + } } compatibleVersions.addAll(set) } - var filter by mutableStateOf(SHOW_SUPPORTED or SHOW_UNSUPPORTED) - private set - fun toggleFlag(flag: Int) { filter = filter xor flag } @@ -93,4 +146,13 @@ class PatchesSelectorViewModel( const val SHOW_UNIVERSAL = 2 // 2^1 const val SHOW_UNSUPPORTED = 4 // 2^2 } + + data class BundleInfo( + val name: String, + val uid: Int, + val all: List, + val supported: List, + val unsupported: List, + val universal: List + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/SourcesViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/SourcesViewModel.kt index cd13362..7a81611 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/SourcesViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/SourcesViewModel.kt @@ -39,7 +39,7 @@ class SourcesViewModel(private val app: Application, private val sourceRepositor 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 { sourceRepository.resetConfig() diff --git a/app/src/main/java/app/revanced/manager/util/Constants.kt b/app/src/main/java/app/revanced/manager/util/Constants.kt index c0c07d1..faaca7b 100644 --- a/app/src/main/java/app/revanced/manager/util/Constants.kt +++ b/app/src/main/java/app/revanced/manager/util/Constants.kt @@ -11,4 +11,5 @@ const val tag = "ReVanced Manager" const val apiURL = "https://releases.revanced.app" const val JAR_MIMETYPE = "application/java-archive" -const val APK_MIMETYPE = "application/vnd.android.package-archive" \ No newline at end of file +const val APK_MIMETYPE = "application/vnd.android.package-archive" +const val JSON_MIMETYPE = "application/json" \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/SnapshotStateSet.kt b/app/src/main/java/app/revanced/manager/util/SnapshotStateSet.kt new file mode 100644 index 0000000..bada267 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/util/SnapshotStateSet.kt @@ -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 private constructor( + private val delegateSnapshotStateMap: SnapshotStateMap, +) : MutableSet 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): Boolean = + elements.map(::add).any() + + override fun remove(element: T) = delegateSnapshotStateMap.remove(element) != null +} + +/** + * Create a instance of [MutableSet] that is observable and can be snapshot. + */ +fun mutableStateSetOf() = SnapshotStateSet() + +/** + * Create an instance of [MutableSet] from a collection that is observable and can be + * snapshot. + */ +fun Collection.toMutableStateSet() = SnapshotStateSet().also { it.addAll(this) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/util/Util.kt b/app/src/main/java/app/revanced/manager/util/Util.kt index c5aec63..7d233b0 100644 --- a/app/src/main/java/app/revanced/manager/util/Util.kt +++ b/app/src/main/java/app/revanced/manager/util/Util.kt @@ -16,13 +16,17 @@ import androidx.work.Data import androidx.work.workDataOf import io.ktor.http.Url 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.serialization.ExperimentalSerializationApi import kotlinx.serialization.cbor.Cbor import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.encodeToByteArray -typealias PatchesSelection = Map> +typealias PatchesSelection = Map> fun Context.openUrl(url: String) { 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 Flow>.flatMapLatestAndCombine( + crossinline combiner: (Array) -> C, + crossinline transformer: (T) -> Flow, +): Flow = flatMapLatest { iterable -> + combine(iterable.map(transformer)) { + combiner(it) + } +} + const val workDataKey = "payload" @OptIn(ExperimentalSerializationApi::class) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1e5c7a7..607ea9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,15 @@ Regenerate keystore Generate a new keystore The keystore has been successfully replaced + Patches selection + Restore patches selections + Restore patches selection from a file + Failed to restore patches selection: %s + Backup patches selections + Backup patches selection to a file + Failed to backup patches selection: %s + Clear patches selection + Clear all patches selection Search apps… Loading…