This commit is contained in:
Minecon724 2025-04-21 09:42:02 +02:00
commit 462c49765b
Signed by untrusted user who does not match committer: m724
GPG key ID: A02E6E67AB961189
17 changed files with 263 additions and 41 deletions

View file

@ -62,6 +62,9 @@ dependencies {
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.protobuf.javalite) implementation(libs.protobuf.javalite)
implementation(libs.commons.compress)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.hilt.work)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View file

@ -1,9 +1,17 @@
package eu.m724.pojavbackup package eu.m724.pojavbackup
import android.app.Application import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class PojavBackupApplication : Application() { class PojavBackupApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
} }

View file

@ -1,10 +0,0 @@
package eu.m724.pojavbackup.core
import java.time.Instant
data class Backup(
val timestamp: Instant,
val status: BackupStatus
) {
}

View file

@ -0,0 +1,13 @@
package eu.m724.pojavbackup.core.backup
import java.nio.file.Path
import java.time.Instant
data class Backup(
val id: String,
val timestamp: Instant,
val status: BackupStatus,
val tempDirectory: Path?
) {
}

View file

@ -0,0 +1,67 @@
package eu.m724.pojavbackup.core.backup
import android.content.Context
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import java.io.File
import java.time.Instant
import java.util.HexFormat
import java.util.concurrent.ThreadLocalRandom
import javax.inject.Singleton
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.deleteRecursively
@Module
@InstallIn(SingletonComponent::class)
object BackupModule {
@Provides
@Singleton
fun provideBackupRepository(
@ApplicationContext context: Context,
settingsRepository: SettingsRepository
): BackupRepository {
return object : BackupRepository {
override suspend fun createBackup(): Backup {
val bytes = ByteArray(16)
ThreadLocalRandom.current().nextBytes(bytes)
val id = HexFormat.of().formatHex(bytes)
val path = File.createTempFile("bp-$id", null, context.cacheDir).toPath()
return Backup(
id,
Instant.now(),
BackupStatus.ONGOING,
path
)
}
@OptIn(ExperimentalPathApi::class)
override suspend fun completeBackup(backup: Backup, status: BackupStatus): Backup {
backup.tempDirectory!!.deleteRecursively()
return backup.copy(
status = status,
tempDirectory = null
)
}
override suspend fun backupDirectory(backup: Backup, directory: String) {
val documentFile = DocumentFile.fromTreeUri(context, settingsRepository.getSettings().sourceUri.toUri())
PathTools.copyFromDocumentFileToPath(
context,
documentFile!!.findFile(directory)!!,
backup.tempDirectory!!.resolve(directory)
)
}
}
}
}

View file

@ -0,0 +1,8 @@
package eu.m724.pojavbackup.core.backup
interface BackupRepository {
suspend fun createBackup(): Backup
suspend fun completeBackup(backup: Backup, status: BackupStatus = BackupStatus.SUCCESS): Backup
suspend fun backupDirectory(backup: Backup, directory: String)
}

View file

@ -1,4 +1,4 @@
package eu.m724.pojavbackup.core package eu.m724.pojavbackup.core.backup
enum class BackupStatus { enum class BackupStatus {
SUCCESS, SUCCESS,

View file

@ -0,0 +1,39 @@
package eu.m724.pojavbackup.core.backup
import android.content.Context
import android.util.Log
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class BackupWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val backupRepository: BackupRepository
) : CoroutineWorker(appContext, workerParams) {
companion object {
const val TAG = "BackupWorker"
}
override suspend fun doWork(): Result {
val backup = backupRepository.createBackup()
return try {
Log.d(TAG, "Created backup: ${backup.id}")
backupRepository.completeBackup(backup, BackupStatus.SUCCESS)
Result.success()
} catch (e: Exception) {
backupRepository.completeBackup(backup, BackupStatus.FAILURE)
Result.failure()
}
}
}

View file

@ -0,0 +1,73 @@
package eu.m724.pojavbackup.core.backup
import android.content.Context
import androidx.documentfile.provider.DocumentFile
import org.apache.commons.compress.archivers.ArchiveEntry
import org.apache.commons.compress.archivers.ArchiveOutputStream
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.FileTime
import kotlin.io.path.createDirectory
import kotlin.io.path.name
import kotlin.io.path.outputStream
import kotlin.io.path.walk
class PathTools {
companion object {
fun compressDocumentFileDirectory(source: Path, target: Path) {
target.outputStream().use {
ZstdCompressorOutputStream(it).use {
TarArchiveOutputStream(it).use { outputStream ->
compressInner(source, outputStream)
}
}
}
}
private fun <T : ArchiveEntry> compressInner(source: Path, outputStream: ArchiveOutputStream<T>) {
// TODO we could compress DirectoryFile I think https://commons.apache.org/proper/commons-compress/examples.html https://aistudio.google.com/prompts/1AFCTIE9FdxT3AvDOQ0puTxoSv6xMYYH1
source.walk().forEach {
val entry = outputStream.createArchiveEntry(it, it.name)
outputStream.putArchiveEntry(entry)
Files.copy(it, outputStream)
outputStream.closeArchiveEntry()
}
outputStream.finish()
}
/**
* Recursively copies the contents of a DocumentFile directory to a local Path.
*
* @param context Context needed for ContentResolver.
* @param sourceDir The source DocumentFile directory. Must be a directory.
* @param targetDir The destination Path directory. Will be created if it doesn't exist.
*/
fun copyFromDocumentFileToPath(
context: Context,
sourceDir: DocumentFile,
targetDir: Path
) {
if (!sourceDir.isDirectory) {
// TODO copy file
return
}
sourceDir.listFiles().forEach { child ->
val targetPath = targetDir.resolve(child.name)
if (child.isDirectory) {
targetPath.createDirectory()
copyFromDocumentFileToPath(context, child, targetPath)
} else {
context.contentResolver.openInputStream(child.uri)!!.use { inputStream ->
Files.copy(inputStream, targetPath)
Files.setLastModifiedTime(targetPath, FileTime.fromMillis(child.lastModified()))
}
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package eu.m724.pojavbackup.core package eu.m724.pojavbackup.core.data
import android.content.Context import android.content.Context
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@ -12,35 +12,37 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class WorldRepository @Inject constructor( class LauncherDataRepository @Inject constructor(
@ApplicationContext private val appContext: Context @ApplicationContext private val appContext: Context
) { ) {
private lateinit var savesDirectory: DocumentFile private lateinit var dataDirectory: DocumentFile
private lateinit var worldDetector: WorldDetector private lateinit var worldScanner: WorldScanner
private var worldsCache: List<World>? = null private var worldCache: List<World>? = null
private val cacheMutex = Mutex() // To ensure thread-safe access to cache private val worldCacheMutex = Mutex() // To ensure thread-safe access to cache
fun setSavesDirectory(documentFile: DocumentFile) { fun setSavesDirectory(documentFile: DocumentFile) {
this.savesDirectory = documentFile this.dataDirectory = documentFile
this.worldDetector = WorldDetector(appContext.contentResolver, savesDirectory)
val savesDirectory = dataDirectory.findFile(".minecraft")!!.findFile("saves")!!
this.worldScanner = WorldScanner(appContext.contentResolver, savesDirectory)
} }
suspend fun listWorlds(): List<World> { suspend fun listWorlds(): List<World> {
cacheMutex.withLock { worldCacheMutex.withLock {
if (worldsCache != null) { if (worldCache != null) {
return worldsCache!! // Return copy or immutable list if needed return worldCache!! // Return copy or immutable list if needed
} }
} }
// If cache is empty, fetch data on IO dispatcher // If cache is empty, fetch data on IO dispatcher
val freshData = withContext(Dispatchers.IO) { val freshData = withContext(Dispatchers.IO) {
worldDetector.listWorlds().toList() // TODO worldScanner.listWorlds().toList() // TODO
} }
// Store in cache (thread-safe) // Store in cache (thread-safe)
cacheMutex.withLock { worldCacheMutex.withLock {
worldsCache = freshData worldCache = freshData
} }
return freshData return freshData
@ -51,8 +53,8 @@ class WorldRepository @Inject constructor(
} }
suspend fun clearCache() { suspend fun clearCache() {
cacheMutex.withLock { worldCacheMutex.withLock {
worldsCache = null worldCache = null
} }
} }
} }

View file

@ -1,4 +1,4 @@
package eu.m724.pojavbackup.core package eu.m724.pojavbackup.core.data
import android.graphics.Bitmap import android.graphics.Bitmap
import java.time.Instant import java.time.Instant
@ -8,7 +8,7 @@ import java.time.ZonedDateTime
data class World( data class World(
val id: String, val id: String,
val displayName: String, val displayName: String,
val lastPlayed: ZonedDateTime, // TODO or Instant? val lastPlayed: ZonedDateTime,
val icon: Bitmap? val icon: Bitmap?
) { ) {
companion object { companion object {

View file

@ -1,4 +1,4 @@
package eu.m724.pojavbackup.core package eu.m724.pojavbackup.core.data
import android.content.ContentResolver import android.content.ContentResolver
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@ -17,7 +17,7 @@ import net.benwoodworth.knbt.nbtString
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
class WorldDetector( class WorldScanner(
private val contentResolver: ContentResolver, private val contentResolver: ContentResolver,
private val savesDirectory: DocumentFile private val savesDirectory: DocumentFile
) { ) {

View file

@ -9,7 +9,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.m724.pojavbackup.core.WorldRepository import eu.m724.pojavbackup.core.data.LauncherDataRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -22,7 +22,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class HomeViewModel @Inject constructor( class HomeViewModel @Inject constructor(
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
private val worldRepository: WorldRepository private val launcherDataRepository: LauncherDataRepository
) : ViewModel() { ) : ViewModel() {
private val TAG = javaClass.name private val TAG = javaClass.name
@ -46,8 +46,8 @@ class HomeViewModel @Inject constructor(
.findFile("saves") .findFile("saves")
if (documentFile != null) { if (documentFile != null) {
worldRepository.setSavesDirectory(documentFile) launcherDataRepository.setSavesDirectory(documentFile)
worldRepository.listWorlds() launcherDataRepository.listWorlds()
} else { } else {
// TODO handle if "saves" doesn't exist // TODO handle if "saves" doesn't exist
} }

View file

@ -22,7 +22,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.m724.pojavbackup.R import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.core.BackupStatus import eu.m724.pojavbackup.core.backup.BackupStatus
import eu.m724.pojavbackup.home.screen.ScreenColumn import eu.m724.pojavbackup.home.screen.ScreenColumn
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter

View file

@ -6,8 +6,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.m724.pojavbackup.core.World import eu.m724.pojavbackup.core.data.World
import eu.m724.pojavbackup.core.WorldRepository import eu.m724.pojavbackup.core.data.LauncherDataRepository
import eu.m724.pojavbackup.core.datastore.SettingsRepository import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.proto.WorldOrder import eu.m724.pojavbackup.proto.WorldOrder
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -22,7 +22,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ContentScreenViewModel @Inject constructor( class ContentScreenViewModel @Inject constructor(
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
private val worldRepository: WorldRepository, private val launcherDataRepository: LauncherDataRepository,
private val settingsRepository: SettingsRepository private val settingsRepository: SettingsRepository
) : ViewModel() { ) : ViewModel() {
private val _worlds = MutableStateFlow<List<World>>(emptyList()) private val _worlds = MutableStateFlow<List<World>>(emptyList())
@ -33,7 +33,7 @@ class ContentScreenViewModel @Inject constructor(
settingsRepository.getSettingsFlow().collect { settings -> settingsRepository.getSettingsFlow().collect { settings ->
val worlds = settings.worldOrder.worldIdsList.map { val worlds = settings.worldOrder.worldIdsList.map {
// TODO mark deleted worlds better // TODO mark deleted worlds better
worldRepository.getWorld(it) ?: World(it, "Deleted world", Instant.EPOCH.atZone(ZoneOffset.UTC), null) launcherDataRepository.getWorld(it) ?: World(it, "Deleted world", Instant.EPOCH.atZone(ZoneOffset.UTC), null)
}.toMutableList() }.toMutableList()
worlds.add(settings.worldOrder.separatorIndex, World.SEPARATOR) worlds.add(settings.worldOrder.separatorIndex, World.SEPARATOR)

View file

@ -18,4 +18,17 @@ message Settings {
repeated string extraPaths = 2; repeated string extraPaths = 2;
repeated BackupDestination destinations = 3; repeated BackupDestination destinations = 3;
string sourceUri = 4;
}
message BackupMeta {
string id = 1;
int64 timestamp = 2;
int32 status = 3;
}
message BackupsMeta {
repeated BackupMeta backups = 1;
} }

View file

@ -20,6 +20,9 @@ hiltNavigationCompose = "1.2.0"
datastore = "1.1.4" datastore = "1.1.4"
protobufJavalite = "4.30.2" protobufJavalite = "4.30.2"
protobuf = "0.9.5" protobuf = "0.9.5"
commonsCompress = "1.27.1"
work = "2.10.0"
hiltWork = "1.2.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -47,6 +50,9 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose"} androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose"}
androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobufJavalite"} protobuf-javalite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobufJavalite"}
commons-compress = { group = "org.apache.commons", name = "commons-compress", version.ref = "commonsCompress"}
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltWork"}
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }