mirror of
https://github.com/revanced/revanced-manager-compose
synced 2025-02-19 01:26:48 +01:00
feat: save patch selection using room db (#38)
This commit is contained in:
parent
1ffaf43b82
commit
f0edf35206
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
@ -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
|
||||
)
|
@ -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) })
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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>>
|
@ -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",
|
||||
|
@ -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() }
|
||||
}
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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()
|
||||
|
@ -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"
|
@ -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) }
|
@ -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)
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user