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,
"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')"
]
}
}

View File

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

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

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
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",

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.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<Map<String, Source>> = MutableStateFlow(emptyMap())
val sources = _sources.asStateFlow()
private val _sources: MutableStateFlow<Map<Int, Source>> = 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<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.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 {

View File

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

View File

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

View File

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

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.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<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 = 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

View File

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

View File

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

View File

@ -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<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)!!)
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<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.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<SourceRepository>().bundles.map { bundles ->
bundles.mapValues { (_, bundle) -> bundle.patches }.map { (name, patches) ->
val bundlesFlow = get<SourceRepository>().sources.flatMapLatestAndCombine(
combiner = { it }
) { source ->
// Regenerate bundle information whenever this source updates.
source.bundle.map { bundle ->
val supported = mutableListOf<PatchInfo>()
val unsupported = mutableListOf<PatchInfo>()
val universal = mutableListOf<PatchInfo>()
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<Pair<String, 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>
)
private val selectedPatches = mutableStateMapOf<Int, SnapshotStateSet<String>>()
var showOptionsDialog by mutableStateOf(false)
private set
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() {
showOptionsDialog = false
compatibleVersions.clear()
@ -73,17 +128,15 @@ class PatchesSelectorViewModel(
val set = HashSet<String>()
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<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)
fun deleteSource(source: Source) = viewModelScope.launch { sourceRepository.remove(source) }
fun delete(source: Source) = viewModelScope.launch { sourceRepository.remove(source) }
fun deleteAllSources() = viewModelScope.launch {
sourceRepository.resetConfig()

View File

@ -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"
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 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<String, List<String>>
typealias PatchesSelection = Map<Int, List<String>>
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 <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"
@OptIn(ExperimentalSerializationApi::class)

View File

@ -37,6 +37,15 @@
<string name="regenerate_keystore">Regenerate 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="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="loading_body">Loading…</string>