Reorder settings

This commit is contained in:
Minecon724 2025-05-08 09:56:15 +02:00
commit 115c3e2b6e
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
17 changed files with 136 additions and 163 deletions

View file

@ -1,11 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Required to check which pojavlauncher is installed -->
<queries>
<package android:name="net.kdt.pojavlaunch" />
<package android:name="net.kdt.pojavlaunch.debug" />
</queries>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>

View file

@ -21,6 +21,8 @@ import eu.m724.pojavbackup.core.data.GameDataRepository
import eu.m724.pojavbackup.core.data.World
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.notification.NotificationChannels
import eu.m724.pojavbackup.proto.BackupSource
import kotlinx.coroutines.flow.first
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.UUID
@ -55,8 +57,13 @@ class BackupWorker @AssistedInject constructor(
}
private suspend fun doBackup(backup: Backup) {
val settings = settingsRepository.getSettings()
val worldIds = settingsRepository.getIncludedWorldIds(settings.worldOrder)
val settings = settingsRepository.settingsFlow.first()
val sources = settings.sourcesList
// TODO other source types support
val worldIds = sources
.filter { it.type == BackupSource.SourceType.WORLD }
.map { it.id }
// TODO multiple destinations support
val destinationUri = settings.destinationsList.first().uri.toUri()

View file

@ -2,6 +2,7 @@ package eu.m724.pojavbackup.core.data
import android.content.Context
import android.util.Log
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import dagger.Module
import dagger.Provides
@ -12,6 +13,7 @@ import eu.m724.pojavbackup.core.data.World.Companion.getWorldFromDirectory
import eu.m724.pojavbackup.core.data.World.InvalidWorldException
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import java.io.File
import javax.inject.Singleton
@ -27,8 +29,9 @@ object GameDataModule {
settingsRepository: SettingsRepository
): GameDataRepository {
return object : GameDataRepository {
override suspend fun listAllWorlds(): Flow<World> {
val sourceUri = settingsRepository.getSource()!!
val sourceUri = settingsRepository.launcherDocumentUriFlow.first().toUri()
val documentFile = DocumentFile.fromTreeUri(context, sourceUri)!!.findFile(".minecraft")!!.findFile("saves")!!
return flow {
@ -43,7 +46,7 @@ object GameDataModule {
}
override suspend fun getWorld(id: String): World {
val sourceUri = settingsRepository.getSource()!!
val sourceUri = settingsRepository.launcherDocumentUriFlow.first().toUri()
val documentFile = DocumentFile.fromTreeUri(context, sourceUri)!!.findFile(".minecraft")!!.findFile("saves")!! // TODO maybe we could cache this
val worldFile = documentFile.findFile(id) ?: throw NoSuchFileException(File(id))

View file

@ -24,20 +24,12 @@ data class World(
val documentFile: DocumentFile?
) {
override fun equals(other: Any?): Boolean {
if (other !is World) return false
// TODO compare only id or other stuff too?
return this.id == other.id
}
companion object {
val SEPARATOR = dummy("")
fun dummy(id: String, label: String = ""): World {
return World(id, label, Instant.EPOCH.atZone(ZoneOffset.UTC), null, null)
}
// TODO do below belong here?
private val NBT = Nbt {
@ -82,4 +74,20 @@ data class World(
class InvalidWorldException(
override val message: String?
) : Exception()
override fun equals(other: Any?): Boolean {
if (other !is World) return false
// TODO compare only id or other stuff too?
return this.id == other.id
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + displayName.hashCode()
result = 31 * result + lastPlayed.hashCode()
result = 31 * result + (icon?.hashCode() ?: 0)
result = 31 * result + (documentFile?.hashCode() ?: 0)
return result
}
}

View file

@ -1,13 +1,11 @@
package eu.m724.pojavbackup.core.datastore
import android.net.Uri
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import eu.m724.pojavbackup.proto.BackupDestination
import eu.m724.pojavbackup.proto.BackupSource
import eu.m724.pojavbackup.proto.Settings
import eu.m724.pojavbackup.proto.WorldOrder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@ -15,68 +13,43 @@ import javax.inject.Singleton
class SettingsRepository @Inject constructor(
private val dataStore: DataStore<Settings>
) {
fun getSettingsFlow(): Flow<Settings> {
return dataStore.data
}
val settingsFlow = dataStore.data
suspend fun getSettings(): Settings {
return dataStore.data.first()
}
/* Getters */
/* Worlds */
val backupSourcesFlow: Flow<List<BackupSource>> =
settingsFlow.map { it.sourcesList }
suspend fun getIncludedWorldIds(): List<String> {
return getIncludedWorldIds(getSettings().worldOrder)
}
val backupDestinationsFlow: Flow<List<BackupDestination>> =
settingsFlow.map { it.destinationsList }
fun getIncludedWorldIds(worldOrder: WorldOrder): List<String> {
return worldOrder.worldIdsList.subList(0, worldOrder.separatorIndex)
}
val launcherDocumentUriFlow: Flow<String> =
settingsFlow.map { it.launcherDocumentUri }
suspend fun updateWorldOrder(worldOrder: WorldOrder) {
/* Setters */
suspend fun updateBackupDestinations(backupDestinations: (List<BackupDestination>) -> List<BackupDestination>) {
dataStore.updateData {
it.toBuilder()
.clearWorldOrder()
.setWorldOrder(worldOrder)
.clearDestinations()
.addAllDestinations(backupDestinations(it.destinationsList))
.build()
}
}
/* Destination */
suspend fun getDestinations(): List<BackupDestination> {
return getSettings().destinationsList
}
suspend fun addDestination(destination: BackupDestination) {
suspend fun updateBackupSources(backupSources: (List<BackupSource>) -> List<BackupSource>) {
dataStore.updateData {
it.toBuilder()
.addDestinations(destination)
.clearSources()
.addAllSources(backupSources(it.sourcesList))
.build()
}
}
suspend fun removeDestination(index: Int) {
suspend fun updateLauncherDocumentUri(launcherDocumentUri: (String) -> String) {
dataStore.updateData {
it.toBuilder()
.removeDestinations(index)
.build()
}
}
/* Source / launcher */
suspend fun getSource(): Uri? {
return getSettings().sourceUri.let {
if (it.isEmpty()) null
else it.toUri()
}
}
suspend fun setSource(source: Uri) {
dataStore.updateData {
it.toBuilder()
.setSourceUri(source.toString())
.setLauncherDocumentUri(launcherDocumentUri(it.launcherDocumentUri))
.build()
}
}

View file

@ -5,18 +5,19 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.m724.pojavbackup.core.data.GameDataRepository
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.notification.NotificationChannels
import eu.m724.pojavbackup.setup.SetupActivity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -24,8 +25,7 @@ import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val settingsRepository: SettingsRepository,
private val gameDataRepository: GameDataRepository
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val TAG = javaClass.name
@ -45,9 +45,11 @@ class HomeViewModel @Inject constructor(
onSetupRequired: (Intent) -> Unit
) {
viewModelScope.launch {
val uri = settingsRepository.getSource()
val uri = settingsRepository.launcherDocumentUriFlow.first()
val storagePermission = uri?.let { checkForStoragePermission(uri) } == true
val storagePermission = if (uri.isNotEmpty()) {
checkForStoragePermission(uri.toUri())
} else false
if (!storagePermission || (!notificationPermissionGranted && !notificationPermissionRejected)) {
val intent = Intent(appContext, SetupActivity::class.java)

View file

@ -47,7 +47,7 @@ fun DashboardScreen(
) {
DashboardCard(
title = stringResource(R.string.dashboard_card_worlds),
value = settings.worldOrder.separatorIndex,
value = settings.sourcesList.size,
iconResourceId = R.drawable.baseline_mosque_24,
onClick = onWorldsIncludedClick
)

View file

@ -21,7 +21,7 @@ class DashboardScreenViewModel @Inject constructor(
init {
viewModelScope.launch {
settingsRepository.getSettingsFlow().collect { newSettings ->
settingsRepository.settingsFlow.collect { newSettings ->
_settings.update { newSettings }
}
}

View file

@ -35,7 +35,7 @@ class SettingsViewModel @Inject constructor(
.build()
viewModelScope.launch {
settingsRepository.addDestination(destination)
settingsRepository.updateBackupDestinations { it + destination }
}
}
}

View file

@ -33,7 +33,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import eu.m724.pojavbackup.R
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
@ -45,9 +44,7 @@ import java.time.format.FormatStyle
@Composable
fun ContentScreen(
navController: NavController,
) {
fun ContentScreen() {
val viewModel: ContentScreenViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -59,7 +56,7 @@ fun ContentScreen(
if (uiState.loading) {
CircularProgressIndicator()
} else {
if (uiState.worlds.size <= 1) { // separator
if (uiState.worldList.size <= 1) { // separator
Text(
text = stringResource(R.string.worlds_none),
modifier = Modifier.padding(top = 50.dp)
@ -77,11 +74,11 @@ fun ContentScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
items(
items = uiState.worlds,
key = { it.id }
items = uiState.worldList,
key = { it?.id ?: "" }
) { world ->
ReorderableItem(state, key = world.id) { isDragging ->
if (!world.id.isEmpty()) {
ReorderableItem(state, key = world?.id ?: "" ) { isDragging ->
if (world != null) {
WorldInfoCard(
bitmap = world.icon,
id = world.id,

View file

@ -4,5 +4,5 @@ import eu.m724.pojavbackup.core.data.World
data class ContentScreenUiState(
val loading: Boolean = true,
val worlds: List<World> = emptyList()
val worldList: List<World?> = emptyList() // null is separator
)

View file

@ -1,25 +1,23 @@
package eu.m724.pojavbackup.settings.screen.content
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.m724.pojavbackup.core.data.GameDataRepository
import eu.m724.pojavbackup.core.data.World
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.proto.WorldOrder
import eu.m724.pojavbackup.proto.BackupSource
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ContentScreenViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val gameDataRepository: GameDataRepository,
private val settingsRepository: SettingsRepository
) : ViewModel() {
@ -28,26 +26,32 @@ class ContentScreenViewModel @Inject constructor(
init {
viewModelScope.launch {
settingsRepository.getSettingsFlow().collect { settings ->
val worlds = settings.worldOrder.worldIdsList.map {
try {
gameDataRepository.getWorld(it)
} catch (e: NoSuchFileException) {
World.dummy(it, "Deleted world") // TODO more clues that it's deleted
} catch (e: World.InvalidWorldException) {
World.dummy(it, "Corrupted world") // TODO do we want that
}
}.toMutableList()
worlds.add(settings.worldOrder.separatorIndex, World.SEPARATOR)
val savedWorlds = gameDataRepository.listAllWorlds().toList().associate { it.id to it }
gameDataRepository.listAllWorlds().collect {
if (!worlds.contains(it)) {
worlds.add(it)
settingsRepository.backupSourcesFlow.collect { sources ->
val worldStack = savedWorlds.toMutableMap()
val backedUpWorlds = mutableListOf<World>()
// TODO support non-world sources
sources.forEach { source ->
if (source.type != BackupSource.SourceType.WORLD) return@forEach
val world = worldStack.remove(source.id)
if (world == null) {
backedUpWorlds.add(World.dummy(source.id, "Deleted world"))
return@forEach
}
backedUpWorlds.add(world)
}
_uiState.update {
it.copy(loading = false, worlds = worlds)
it.copy(
loading = false,
worldList = backedUpWorlds + null + worldStack.values
)
}
}
}
@ -55,7 +59,7 @@ class ContentScreenViewModel @Inject constructor(
fun moveWorld(fromIndex: Int, toIndex: Int) {
// Similar to mutableStateOf, create a NEW list
val currentList = _uiState.value.worlds.toMutableList()
val currentList = _uiState.value.worldList.toMutableList()
// Check bounds for safety
if (fromIndex in currentList.indices && toIndex >= 0 && toIndex <= currentList.size) {
val item = currentList.removeAt(fromIndex)
@ -67,18 +71,31 @@ class ContentScreenViewModel @Inject constructor(
}
}
private fun updateWorldOrder(newList: List<World>) {
private fun updateWorldOrder(newList: List<World?>) {
_uiState.update {
it.copy(worlds = newList)
it.copy(
worldList = newList
)
}
val backupSources = mutableListOf<BackupSource>()
val iterator = newList.iterator()
while (iterator.hasNext()) {
val world = iterator.next() ?: break // remember the separator is null
val backupSource = BackupSource.newBuilder()
.setId(world.id)
.setType(BackupSource.SourceType.WORLD)
.build()
backupSources.add(backupSource)
}
viewModelScope.launch {
settingsRepository.updateWorldOrder(
WorldOrder.newBuilder()
.addAllWorldIds(newList.filter { it != World.SEPARATOR }.map { it.id })
.setSeparatorIndex(newList.indexOf(World.SEPARATOR))
.build()
settingsRepository.updateBackupSources(
backupSources = { backupSources }
)
}
}
}
}

View file

@ -22,16 +22,14 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import eu.m724.pojavbackup.R
@Composable
fun DestinationScreen(
navController: NavController,
onAddDestination: () -> Unit
) {
val viewModel: DestinationScreenViewModel = hiltViewModel()
val destinations by viewModel.destinations.collectAsStateWithLifecycle()
val destinations by viewModel.destinationsFlow.collectAsStateWithLifecycle()
Column {
Column(

View file

@ -1,10 +1,8 @@
package eu.m724.pojavbackup.settings.screen.destination
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.proto.BackupDestination
import kotlinx.coroutines.flow.MutableStateFlow
@ -16,18 +14,15 @@ import javax.inject.Inject
@HiltViewModel
class DestinationScreenViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val _destinations = MutableStateFlow<List<BackupDestination>>(emptyList())
val destinations: StateFlow<List<BackupDestination>> = _destinations.asStateFlow()
private val _destinationsFlow = MutableStateFlow<List<BackupDestination>>(emptyList())
val destinationsFlow: StateFlow<List<BackupDestination>> = _destinationsFlow.asStateFlow()
init {
viewModelScope.launch {
settingsRepository.getSettingsFlow().collect { settings ->
_destinations.update {
settings.destinationsList
}
settingsRepository.backupDestinationsFlow.collect { destinations ->
_destinationsFlow.update { destinations }
}
}
}

View file

@ -64,7 +64,7 @@ class SetupActivity : ComponentActivity() {
if (!viewModel.checkIfCanProceed()) return
startActivity(Intent(applicationContext, HomeActivity::class.java))
finishActivity(0)
finish()
}

View file

@ -2,7 +2,6 @@ package eu.m724.pojavbackup.setup
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager.NameNotFoundException
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
@ -25,11 +24,6 @@ class SetupViewModel @Inject constructor(
) : ViewModel() {
private val TAG: String = javaClass.name
private val POJAV_PACKAGES = arrayOf(
"net.kdt.pojavlaunch",
"net.kdt.pojavlaunch.debug"
)
private val _uiState = MutableStateFlow(SetupUiState())
val uiState: StateFlow<SetupUiState> = _uiState.asStateFlow()
@ -67,7 +61,7 @@ class SetupViewModel @Inject constructor(
viewModelScope.launch {
if (hasPermission) {
settingsRepository.setSource(uri)
settingsRepository.updateLauncherDocumentUri { uri.toString() }
}
}
@ -116,17 +110,4 @@ class SetupViewModel @Inject constructor(
return true
}
fun detectInstalledLauncherPackage(): List<String> {
val packageManager = appContext.packageManager
return POJAV_PACKAGES.filter {
try {
packageManager.getPackageInfo(it, 0)
true
} catch (e: NameNotFoundException) {
false
}
}
}
}

View file

@ -3,32 +3,29 @@ syntax = "proto3";
option java_package = "eu.m724.pojavbackup.proto";
option java_multiple_files = true;
message WorldOrder {
repeated string worldIds = 1;
int32 separatorIndex = 2;
message BackupSource {
enum SourceType {
WORLD = 0;
}
SourceType type = 1;
string id = 2;
}
message BackupDestination {
enum DestinationType {
EXTERNAL = 0; // currently the only one because we use DocumentFile
}
string label = 1;
string uri = 2; // TODO
DestinationType type = 2;
string uri = 3; // this should be changed after adding more types
}
message Settings {
WorldOrder worldOrder = 1;
repeated string extraPaths = 2;
string launcherDocumentUri = 1;
repeated BackupSource sources = 2;
repeated BackupDestination destinations = 3;
string sourceUri = 4;
}
message BackupMeta {
string id = 1;
int64 timestamp = 2;
int32 status = 3;
}
message BackupsMeta {
repeated BackupMeta backups = 1;
}