From 462c49765becfcb1c0f8904fb27f5a9bca6bd514 Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Mon, 21 Apr 2025 09:42:02 +0200 Subject: [PATCH] Update --- app/build.gradle.kts | 3 + .../pojavbackup/PojavBackupApplication.kt | 10 ++- .../java/eu/m724/pojavbackup/core/Backup.kt | 10 --- .../eu/m724/pojavbackup/core/backup/Backup.kt | 13 ++++ .../pojavbackup/core/backup/BackupModule.kt | 67 +++++++++++++++++ .../core/backup/BackupRepository.kt | 8 ++ .../core/{ => backup}/BackupStatus.kt | 2 +- .../pojavbackup/core/backup/BackupWorker.kt | 39 ++++++++++ .../m724/pojavbackup/core/backup/PathTools.kt | 73 +++++++++++++++++++ .../LauncherDataRepository.kt} | 34 +++++---- .../m724/pojavbackup/core/{ => data}/World.kt | 4 +- .../WorldScanner.kt} | 4 +- .../eu/m724/pojavbackup/home/HomeViewModel.kt | 8 +- .../home/screen/history/HistoryScreen.kt | 2 +- .../screen/content/ContentScreenViewModel.kt | 8 +- app/src/main/proto/settings.proto | 13 ++++ gradle/libs.versions.toml | 6 ++ 17 files changed, 263 insertions(+), 41 deletions(-) delete mode 100644 app/src/main/java/eu/m724/pojavbackup/core/Backup.kt create mode 100644 app/src/main/java/eu/m724/pojavbackup/core/backup/Backup.kt create mode 100644 app/src/main/java/eu/m724/pojavbackup/core/backup/BackupModule.kt create mode 100644 app/src/main/java/eu/m724/pojavbackup/core/backup/BackupRepository.kt rename app/src/main/java/eu/m724/pojavbackup/core/{ => backup}/BackupStatus.kt (66%) create mode 100644 app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt create mode 100644 app/src/main/java/eu/m724/pojavbackup/core/backup/PathTools.kt rename app/src/main/java/eu/m724/pojavbackup/core/{WorldRepository.kt => data/LauncherDataRepository.kt} (52%) rename app/src/main/java/eu/m724/pojavbackup/core/{ => data}/World.kt (78%) rename app/src/main/java/eu/m724/pojavbackup/core/{WorldDetector.kt => data/WorldScanner.kt} (98%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 354b030..02a0ef7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/eu/m724/pojavbackup/PojavBackupApplication.kt b/app/src/main/java/eu/m724/pojavbackup/PojavBackupApplication.kt index 96650c3..378e43a 100644 --- a/app/src/main/java/eu/m724/pojavbackup/PojavBackupApplication.kt +++ b/app/src/main/java/eu/m724/pojavbackup/PojavBackupApplication.kt @@ -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() } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/Backup.kt b/app/src/main/java/eu/m724/pojavbackup/core/Backup.kt deleted file mode 100644 index c7b91d9..0000000 --- a/app/src/main/java/eu/m724/pojavbackup/core/Backup.kt +++ /dev/null @@ -1,10 +0,0 @@ -package eu.m724.pojavbackup.core - -import java.time.Instant - -data class Backup( - val timestamp: Instant, - val status: BackupStatus -) { - -} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/backup/Backup.kt b/app/src/main/java/eu/m724/pojavbackup/core/backup/Backup.kt new file mode 100644 index 0000000..14e2d26 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/Backup.kt @@ -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? +) { + +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupModule.kt b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupModule.kt new file mode 100644 index 0000000..4f860ca --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupModule.kt @@ -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) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupRepository.kt b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupRepository.kt new file mode 100644 index 0000000..3df42d9 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupRepository.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/BackupStatus.kt b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupStatus.kt similarity index 66% rename from app/src/main/java/eu/m724/pojavbackup/core/BackupStatus.kt rename to app/src/main/java/eu/m724/pojavbackup/core/backup/BackupStatus.kt index 2f19b2c..252a88e 100644 --- a/app/src/main/java/eu/m724/pojavbackup/core/BackupStatus.kt +++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupStatus.kt @@ -1,4 +1,4 @@ -package eu.m724.pojavbackup.core +package eu.m724.pojavbackup.core.backup enum class BackupStatus { SUCCESS, diff --git a/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt new file mode 100644 index 0000000..ac2dc05 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/backup/PathTools.kt b/app/src/main/java/eu/m724/pojavbackup/core/backup/PathTools.kt new file mode 100644 index 0000000..aa9a649 --- /dev/null +++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/PathTools.kt @@ -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 compressInner(source: Path, outputStream: ArchiveOutputStream) { + // 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())) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/WorldRepository.kt b/app/src/main/java/eu/m724/pojavbackup/core/data/LauncherDataRepository.kt similarity index 52% rename from app/src/main/java/eu/m724/pojavbackup/core/WorldRepository.kt rename to app/src/main/java/eu/m724/pojavbackup/core/data/LauncherDataRepository.kt index 3b22f26..9bb8ed3 100644 --- a/app/src/main/java/eu/m724/pojavbackup/core/WorldRepository.kt +++ b/app/src/main/java/eu/m724/pojavbackup/core/data/LauncherDataRepository.kt @@ -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? = null - private val cacheMutex = Mutex() // To ensure thread-safe access to cache + private var worldCache: List? = 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 { - 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 } } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/pojavbackup/core/World.kt b/app/src/main/java/eu/m724/pojavbackup/core/data/World.kt similarity index 78% rename from app/src/main/java/eu/m724/pojavbackup/core/World.kt rename to app/src/main/java/eu/m724/pojavbackup/core/data/World.kt index 383af6f..b37ad65 100644 --- a/app/src/main/java/eu/m724/pojavbackup/core/World.kt +++ b/app/src/main/java/eu/m724/pojavbackup/core/data/World.kt @@ -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 { diff --git a/app/src/main/java/eu/m724/pojavbackup/core/WorldDetector.kt b/app/src/main/java/eu/m724/pojavbackup/core/data/WorldScanner.kt similarity index 98% rename from app/src/main/java/eu/m724/pojavbackup/core/WorldDetector.kt rename to app/src/main/java/eu/m724/pojavbackup/core/data/WorldScanner.kt index 3857c82..3755216 100644 --- a/app/src/main/java/eu/m724/pojavbackup/core/WorldDetector.kt +++ b/app/src/main/java/eu/m724/pojavbackup/core/data/WorldScanner.kt @@ -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 ) { diff --git a/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt index 769ce2d..acb7af6 100644 --- a/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt +++ b/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt @@ -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 } diff --git a/app/src/main/java/eu/m724/pojavbackup/home/screen/history/HistoryScreen.kt b/app/src/main/java/eu/m724/pojavbackup/home/screen/history/HistoryScreen.kt index a0e46d7..5cfa7bf 100644 --- a/app/src/main/java/eu/m724/pojavbackup/home/screen/history/HistoryScreen.kt +++ b/app/src/main/java/eu/m724/pojavbackup/home/screen/history/HistoryScreen.kt @@ -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 diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenViewModel.kt index 232a1a7..8880874 100644 --- a/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenViewModel.kt +++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenViewModel.kt @@ -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>(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) diff --git a/app/src/main/proto/settings.proto b/app/src/main/proto/settings.proto index da938c5..b1f5089 100644 --- a/app/src/main/proto/settings.proto +++ b/app/src/main/proto/settings.proto @@ -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; } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b5ee18..5797abc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }