This commit is contained in:
Minecon724 2025-04-21 09:42:02 +02:00
commit 462c49765b
Signed by: Minecon724
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.datastore)
implementation(libs.protobuf.javalite)
implementation(libs.commons.compress)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.hilt.work)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View file

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

View file

@ -1,4 +1,4 @@
package eu.m724.pojavbackup.core
package eu.m724.pojavbackup.core.data
import android.graphics.Bitmap
import java.time.Instant
@ -8,7 +8,7 @@ import java.time.ZonedDateTime
data class World(
val id: String,
val displayName: String,
val lastPlayed: ZonedDateTime, // TODO or Instant?
val lastPlayed: ZonedDateTime,
val icon: Bitmap?
) {
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.graphics.BitmapFactory
@ -17,7 +17,7 @@ import net.benwoodworth.knbt.nbtString
import java.time.Instant
import java.time.ZoneOffset
class WorldDetector(
class WorldScanner(
private val contentResolver: ContentResolver,
private val savesDirectory: DocumentFile
) {

View file

@ -9,7 +9,7 @@ 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.WorldRepository
import eu.m724.pojavbackup.core.data.LauncherDataRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -22,7 +22,7 @@ import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val worldRepository: WorldRepository
private val launcherDataRepository: LauncherDataRepository
) : ViewModel() {
private val TAG = javaClass.name
@ -46,8 +46,8 @@ class HomeViewModel @Inject constructor(
.findFile("saves")
if (documentFile != null) {
worldRepository.setSavesDirectory(documentFile)
worldRepository.listWorlds()
launcherDataRepository.setSavesDirectory(documentFile)
launcherDataRepository.listWorlds()
} else {
// 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.unit.dp
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 java.time.ZonedDateTime
import java.time.format.DateTimeFormatter

View file

@ -6,8 +6,8 @@ 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.World
import eu.m724.pojavbackup.core.WorldRepository
import eu.m724.pojavbackup.core.data.World
import eu.m724.pojavbackup.core.data.LauncherDataRepository
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.proto.WorldOrder
import kotlinx.coroutines.flow.MutableStateFlow
@ -22,7 +22,7 @@ import javax.inject.Inject
@HiltViewModel
class ContentScreenViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val worldRepository: WorldRepository,
private val launcherDataRepository: LauncherDataRepository,
private val settingsRepository: SettingsRepository
) : ViewModel() {
private val _worlds = MutableStateFlow<List<World>>(emptyList())
@ -33,7 +33,7 @@ class ContentScreenViewModel @Inject constructor(
settingsRepository.getSettingsFlow().collect { settings ->
val worlds = settings.worldOrder.worldIdsList.map {
// 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()
worlds.add(settings.worldOrder.separatorIndex, World.SEPARATOR)

View file

@ -18,4 +18,17 @@ message Settings {
repeated string extraPaths = 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;
}

View file

@ -20,6 +20,9 @@ hiltNavigationCompose = "1.2.0"
datastore = "1.1.4"
protobufJavalite = "4.30.2"
protobuf = "0.9.5"
commonsCompress = "1.27.1"
work = "2.10.0"
hiltWork = "1.2.0"
[libraries]
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-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
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]
android-application = { id = "com.android.application", version.ref = "agp" }