Update
This commit is contained in:
parent
34af1f025f
commit
462c49765b
17 changed files with 263 additions and 41 deletions
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
package eu.m724.pojavbackup.core
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class Backup(
|
||||
val timestamp: Instant,
|
||||
val status: BackupStatus
|
||||
) {
|
||||
|
||||
}
|
13
app/src/main/java/eu/m724/pojavbackup/core/backup/Backup.kt
Normal file
13
app/src/main/java/eu/m724/pojavbackup/core/backup/Backup.kt
Normal 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?
|
||||
) {
|
||||
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package eu.m724.pojavbackup.core
|
||||
package eu.m724.pojavbackup.core.backup
|
||||
|
||||
enum class BackupStatus {
|
||||
SUCCESS,
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
|
@ -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
|
||||
) {
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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" }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue