diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 02a0ef7..ddc9040 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -65,6 +65,12 @@ dependencies {
implementation(libs.commons.compress)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.hilt.work)
+ implementation(libs.zstd.jni) {
+ artifact {
+ type = "aar"
+ }
+ }
+ implementation(libs.lz4.java)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@@ -73,6 +79,7 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
ksp(libs.hilt.compiler)
+ ksp(libs.androidx.hilt.compiler)
}
protobuf {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a211ec4..b773e42 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -39,6 +39,17 @@
+
+
+
+
\ 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
index 14e2d26..82329c2 100644
--- a/app/src/main/java/eu/m724/pojavbackup/core/backup/Backup.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/Backup.kt
@@ -1,13 +1,17 @@
package eu.m724.pojavbackup.core.backup
-import java.nio.file.Path
import java.time.Instant
+import java.util.UUID
data class Backup(
- val id: String,
+ val id: UUID,
val timestamp: Instant,
- val status: BackupStatus,
- val tempDirectory: Path?
+ val status: BackupStatus
) {
-
+ enum class BackupStatus {
+ SUCCESS,
+ FAILURE,
+ ONGOING,
+ ABORTED
+ }
}
\ 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
index 4f860ca..4ae04b6 100644
--- a/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupModule.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupModule.kt
@@ -1,65 +1,40 @@
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 eu.m724.pojavbackup.core.backup.Backup.BackupStatus
import java.time.Instant
-import java.util.HexFormat
+import java.util.UUID
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
+ @ApplicationContext context: Context
): 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,
+ UUID.randomUUID(),
Instant.now(),
- BackupStatus.ONGOING,
- path
+ BackupStatus.ONGOING
)
}
@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)
+ status = status
)
}
}
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
index 3df42d9..764176a 100644
--- a/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupRepository.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupRepository.kt
@@ -1,8 +1,8 @@
package eu.m724.pojavbackup.core.backup
+import eu.m724.pojavbackup.core.backup.Backup.BackupStatus
+
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/backup/BackupStatus.kt b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupStatus.kt
deleted file mode 100644
index 252a88e..0000000
--- a/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupStatus.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package eu.m724.pojavbackup.core.backup
-
-enum class BackupStatus {
- SUCCESS,
- FAILURE,
- ONGOING,
- ABORTED
-}
\ No newline at end of file
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
index ac2dc05..4dee52c 100644
--- a/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/BackupWorker.kt
@@ -2,38 +2,88 @@ package eu.m724.pojavbackup.core.backup
import android.content.Context
import android.util.Log
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
+import androidx.work.Data
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
+import eu.m724.pojavbackup.core.backup.Backup.BackupStatus
+import eu.m724.pojavbackup.core.data.GameDataRepository
+import eu.m724.pojavbackup.core.datastore.SettingsRepository
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
@HiltWorker
class BackupWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
- private val backupRepository: BackupRepository
-
+ private val settingsRepository: SettingsRepository,
+ private val backupRepository: BackupRepository,
+ private val gameDataRepository: GameDataRepository
) : CoroutineWorker(appContext, workerParams) {
companion object {
const val TAG = "BackupWorker"
}
override suspend fun doWork(): Result {
+ statusUpdate("Creating")
+
val backup = backupRepository.createBackup()
+ // TODO multiple destinations support and remove those !!
+
+ val backupUri = settingsRepository.getDestinations().first().uri.toUri()
+ val backupDirectory = DocumentFile.fromTreeUri(applicationContext, backupUri)!!
+ .createDirectory(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()) + " (" + backup.id + ")")!!
+
return try {
- Log.d(TAG, "Created backup: ${backup.id}")
+ statusUpdate("#${backup.id} Initialized")
+ Log.d(TAG, "Initialized backup: ${backup.id}")
+
+ settingsRepository.getIncludedWorldIds().forEach {
+ statusUpdate("#${backup.id} Backing up world $it")
+ Log.d(TAG, "Backing up world $it")
+
+ val world = runCatching {
+ gameDataRepository.getWorld(it)
+ }
+
+ if (world.isFailure) {
+ Log.e(TAG, "Cannot backup world $it:" + world.exceptionOrNull())
+ return@forEach
+ }
+
+ val documentFile = backupDirectory.createFile("application/zstd", "$it.tar.zst")!!
+
+ applicationContext.contentResolver.openOutputStream(documentFile.uri)!!.use {
+ PathTools.compressDocumentFileDirectory(
+ contentResolver = applicationContext.contentResolver,
+ source = world.getOrThrow().documentFile!!,
+ target = it
+ )
+ }
+ }
backupRepository.completeBackup(backup, BackupStatus.SUCCESS)
-
+ statusUpdate("#${backup.id} Done")
Result.success()
} catch (e: Exception) {
+ e.printStackTrace()
+ statusUpdate("#${backup.id} Error: $e")
+
backupRepository.completeBackup(backup, BackupStatus.FAILURE)
Result.failure()
}
}
+
+ // TODO only for test
+ suspend fun statusUpdate(status: String) {
+ setProgress(Data.Builder().putString("status", status).build())
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/core/backup/McaUtils.kt b/app/src/main/java/eu/m724/pojavbackup/core/backup/McaUtils.kt
new file mode 100644
index 0000000..4348962
--- /dev/null
+++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/McaUtils.kt
@@ -0,0 +1,349 @@
+package eu.m724.pojavbackup.core.backup
+
+import java.io.BufferedInputStream
+import java.io.BufferedOutputStream
+import java.io.ByteArrayInputStream
+import java.io.DataOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.nio.ByteBuffer
+import java.util.zip.InflaterInputStream
+import java.util.zip.ZipException
+import kotlin.math.ceil
+
+// --- Constants ---
+const val SECTOR_BYTES = 4096
+const val CHUNK_HEADER_SIZE = 5 // 4 bytes length, 1 byte compression type
+const val REGION_HEADER_TABLES_SIZE = 2 * SECTOR_BYTES // Location table + Timestamp table
+const val CHUNKS_PER_REGION_SIDE = 32
+const val CHUNKS_PER_REGION = CHUNKS_PER_REGION_SIDE * CHUNKS_PER_REGION_SIDE // 1024
+const val MAX_CHUNK_SECTORS = 255 // Max value for the 1-byte size field
+
+// --- Data Classes ---
+
+/** Represents an entry in the MCA location table. */
+data class LocationEntry(val offsetSectors: Int, val sizeSectors: Int) {
+ val byteOffset: Long = offsetSectors.toLong() * SECTOR_BYTES
+ val paddedByteSize: Int = sizeSectors * SECTOR_BYTES
+ val exists: Boolean = offsetSectors > 0 && sizeSectors > 0
+
+ companion object {
+ /** Parses the 4-byte integer from the location table. */
+ fun fromInt(value: Int): LocationEntry {
+ val offset = value ushr 8
+ val size = value and 0xFF
+ // Basic validation: offset shouldn't point inside header unless 0
+ if (offset > 0 && offset < (REGION_HEADER_TABLES_SIZE / SECTOR_BYTES)) {
+ println("Warning: LocationEntry points inside header (offset sectors: $offset). Treating as non-existent.")
+ return LocationEntry(0, 0)
+ }
+ return LocationEntry(offset, size)
+ }
+ }
+
+ /** Converts back to the 4-byte integer format for writing. */
+ fun toInt(): Int {
+ if (offsetSectors >= (1 shl 24) || offsetSectors < 0) {
+ println("Warning: Attempting to create LocationEntry integer with invalid offset: $offsetSectors sectors. Clamping to 0.")
+ return 0 // Cannot represent this offset
+ }
+ if (sizeSectors >= (1 shl 8) || sizeSectors < 0) {
+ println("Warning: Attempting to create LocationEntry integer with invalid size: $sizeSectors sectors. Clamping to 0.")
+ return 0 // Cannot represent this size
+ }
+ return (offsetSectors shl 8) or (sizeSectors and 0xFF)
+ }
+}
+
+/** Stores calculated size info after the first pass. */
+private data class ProcessedChunkSizeInfo(
+ val originalIndex: Int,
+ val newPaddedSize: Int,
+ val newSizeSectors: Int
+)
+
+/**
+ * Converts an MCA region file stream, decompressing ZLIB chunks to uncompressed.
+ * Reads from InputStream, writes to OutputStream.
+ *
+ * WARNING: This implementation buffers the entire chunk data region of the
+ * input stream into memory to simulate random access. It WILL cause
+ * OutOfMemoryError if the input stream represents a file too large to fit
+ * the chunk data portion in available RAM. This is an inherent limitation
+ * when working with non-seekable streams and the MCA format.
+ *
+ * @param inputStream The input stream containing the original MCA file data.
+ * @param outputStream The output stream where the modified MCA file data will be written.
+ * @throws IOException If an I/O error occurs during reading or writing.
+ * @throws OutOfMemoryError If the input stream's chunk data region is too large for memory.
+ * @throws ZipException If ZLIB decompression fails for a chunk.
+ */
+fun convertMcaToUncompressedStream(inputStream: InputStream, outputStream: OutputStream) {
+
+ // Use buffered streams for efficiency
+ val bis = inputStream as? BufferedInputStream ?: BufferedInputStream(inputStream)
+ val bos = outputStream as? BufferedOutputStream ?: BufferedOutputStream(outputStream, 65536) // Buffer output
+ val dos = DataOutputStream(bos)
+
+ // --- 1. Read Header Tables ---
+ println("Reading header tables...")
+ val headerBytes = ByteArray(REGION_HEADER_TABLES_SIZE)
+ val headerBytesRead = bis.readNBytes(headerBytes, 0, REGION_HEADER_TABLES_SIZE)
+ if (headerBytesRead < REGION_HEADER_TABLES_SIZE) {
+ throw IOException("Input stream too short to contain MCA header ($headerBytesRead bytes read)")
+ }
+ val headerBuffer = ByteBuffer.wrap(headerBytes)
+
+ val originalLocationEntries = Array(CHUNKS_PER_REGION) { LocationEntry(0, 0) }
+ val originalTimestamps = IntArray(CHUNKS_PER_REGION)
+
+ for (i in 0 until CHUNKS_PER_REGION) {
+ originalLocationEntries[i] = LocationEntry.fromInt(headerBuffer.getInt())
+ }
+ for (i in 0 until CHUNKS_PER_REGION) {
+ originalTimestamps[i] = headerBuffer.getInt()
+ }
+ println("Header tables read.")
+
+ // --- 2. Buffer Remaining Input Stream Data (Chunk Data Region) ---
+ // !!! THIS IS THE MEMORY-INTENSIVE STEP !!!
+ println("Buffering input stream chunk data... (Potential OOM)")
+ val inputChunkDataBytes: ByteArray = try {
+ bis.readAllBytes() // Reads everything *after* the header
+ } catch (oom: OutOfMemoryError) {
+ println("FATAL: OutOfMemoryError while buffering input stream data.")
+ println("The input MCA data is too large to fit in memory using this stream-based method.")
+ println("Consider using a file-based approach if possible.")
+ throw oom // Re-throw the error
+ }
+ val inputChunkDataSize = inputChunkDataBytes.size
+ println("Input chunk data buffered (${inputChunkDataSize} bytes).")
+
+ // --- 3. First Pass: Calculate New Sizes ---
+ println("Pass 1: Calculating final chunk sizes...")
+ val processedChunkInfos = mutableListOf()
+ var processedCount = 0
+
+ for (i in 0 until CHUNKS_PER_REGION) {
+ val entry = originalLocationEntries[i]
+ if (!entry.exists) continue
+
+ // Calculate offset relative to the buffered chunk data
+ val chunkOffsetInDataRegion = entry.byteOffset - REGION_HEADER_TABLES_SIZE
+ if (chunkOffsetInDataRegion < 0 || chunkOffsetInDataRegion + CHUNK_HEADER_SIZE > inputChunkDataSize) {
+ println("Warning [Pass 1]: Chunk $i location invalid or header out of buffered bounds. Offset: ${entry.byteOffset}. Skipping.")
+ originalLocationEntries[i] = LocationEntry(0, 0) // Mark as non-existent
+ continue
+ }
+
+ var actualLength: Int
+ var compressionType: Byte
+ var payload: ByteArray
+
+ try {
+ // Read header directly from the byte array buffer
+ val chunkHeaderBuffer = ByteBuffer.wrap(inputChunkDataBytes, chunkOffsetInDataRegion.toInt(), CHUNK_HEADER_SIZE)
+ actualLength = chunkHeaderBuffer.getInt()
+ compressionType = chunkHeaderBuffer.get()
+
+ // Validate header data
+ if (actualLength <= 0 || actualLength > entry.paddedByteSize - 4) {
+ throw IOException("Invalid actual length: $actualLength (padded: ${entry.paddedByteSize})")
+ }
+ val payloadSize = actualLength - 1
+ val payloadOffsetInDataRegion = chunkOffsetInDataRegion + CHUNK_HEADER_SIZE
+ if (payloadOffsetInDataRegion + payloadSize > inputChunkDataSize) {
+ throw IOException("Chunk payload extends beyond buffered data bounds (Offset: ${entry.byteOffset}, Declared Size: $actualLength)")
+ }
+
+ // Extract payload reference (no copy needed yet for size calculation if compressed)
+ // We only need the actual payload bytes if we decompress
+ payload = if (compressionType == 2.toByte()) {
+ // Need to read the payload to decompress it for size calculation
+ inputChunkDataBytes.copyOfRange(payloadOffsetInDataRegion.toInt(), (payloadOffsetInDataRegion + payloadSize).toInt())
+ } else {
+ // For non-ZLIB, we only need the size, not the data itself yet
+ ByteArray(payloadSize) // Dummy array of correct size
+ }
+
+ } catch (e: Exception) { // Catch IndexOutOfBounds too
+ println("Warning [Pass 1]: Error reading header/payload for chunk $i at offset ${entry.byteOffset}. Skipping. Error: ${e.message}")
+ originalLocationEntries[i] = LocationEntry(0, 0) // Mark as non-existent
+ continue
+ }
+
+ // --- Determine New Payload Size ---
+ var newPayloadSize = payload.size // Start with original size
+
+ if (compressionType == 2.toByte()) { // ZLIB compressed
+ try {
+ // Decompress just to get the size
+ val bais = ByteArrayInputStream(payload) // Use the actual payload read earlier
+ val iis = InflaterInputStream(bais)
+ // Read fully to determine decompressed size, discard the data
+ val decompressedBytes = iis.readAllBytes()
+ newPayloadSize = decompressedBytes.size
+ // newCompressionType will be 3 in the second pass
+ } catch (e: ZipException) {
+ println("Warning [Pass 1]: Failed to decompress ZLIB chunk $i to calculate size. Using original size. Error: ${e.message}")
+ // Keep original size, type will remain 2 in second pass
+ newPayloadSize = payload.size
+ } catch (e: IOException) {
+ println("Warning [Pass 1]: IO error during ZLIB decompression for size calculation chunk $i. Using original size. Error: ${e.message}")
+ newPayloadSize = payload.size
+ }
+ }
+ // else: newPayloadSize remains payload.size
+
+ // --- Calculate Final Sector Size ---
+ val newActualDataSize = CHUNK_HEADER_SIZE + newPayloadSize
+ val newSizeSectors = ceil(newActualDataSize.toDouble() / SECTOR_BYTES).toInt()
+
+ if (newSizeSectors > MAX_CHUNK_SECTORS) {
+ println("Warning [Pass 1]: Processed chunk $i would be too large ($newSizeSectors sectors > $MAX_CHUNK_SECTORS). Skipping chunk.")
+ originalLocationEntries[i] = LocationEntry(0, 0) // Mark as non-existent
+ continue
+ }
+
+ val newPaddedSize = newSizeSectors * SECTOR_BYTES
+
+ processedChunkInfos.add(
+ ProcessedChunkSizeInfo(
+ originalIndex = i,
+ newPaddedSize = newPaddedSize,
+ newSizeSectors = newSizeSectors
+ )
+ )
+ processedCount++
+ if (processedCount % 100 == 0) print(".")
+
+ } // End Pass 1 loop
+ println("\nPass 1 complete. Calculated sizes for $processedCount chunks.")
+
+
+ // --- 4. Calculate New Location Table ---
+ val newLocationInts = IntArray(CHUNKS_PER_REGION) { 0 }
+ var currentOffsetBytes = REGION_HEADER_TABLES_SIZE.toLong()
+ // Sort infos by original index to ensure correct offset calculation if needed,
+ // though iterating 0..1023 and looking up is safer.
+ // processedChunkInfos.sortBy { it.originalIndex } // Not strictly necessary if we iterate below
+
+ // Create a map for quick lookup during offset calculation
+ val infoMap = processedChunkInfos.associateBy { it.originalIndex }
+
+ for (i in 0 until CHUNKS_PER_REGION) {
+ val info = infoMap[i]
+ if (info != null) {
+ val offsetSectors = (currentOffsetBytes / SECTOR_BYTES).toInt()
+ val newEntry = LocationEntry(offsetSectors, info.newSizeSectors)
+ newLocationInts[i] = newEntry.toInt()
+ currentOffsetBytes += info.newPaddedSize
+ } else {
+ // Ensure non-existent chunks (or skipped ones) have a 0 entry
+ newLocationInts[i] = 0
+ }
+ }
+
+
+ // --- 5. Write New Header Tables to Output ---
+ println("Writing new header tables...")
+ for (locationValue in newLocationInts) {
+ dos.writeInt(locationValue)
+ }
+ for (timestampValue in originalTimestamps) {
+ dos.writeInt(timestampValue)
+ }
+ dos.flush() // Ensure headers are written before chunk data
+
+ // --- 6. Second Pass: Process Again and Write Chunk Data ---
+ println("Pass 2: Processing and writing chunk data...")
+ processedCount = 0
+ for (i in 0 until CHUNKS_PER_REGION) {
+ // Use the potentially modified originalLocationEntries (some might be marked non-existent now)
+ val entry = originalLocationEntries[i]
+ // Also double-check if we calculated info for it (it might have been skipped in pass 1)
+ val sizeInfo = infoMap[i]
+
+ if (!entry.exists || sizeInfo == null) continue // Skip non-existent or skipped chunks
+
+ // Offsets are the same as in Pass 1
+ val chunkOffsetInDataRegion = entry.byteOffset - REGION_HEADER_TABLES_SIZE
+ // No need to re-validate bounds, done in Pass 1
+
+ var actualLength: Int
+ var compressionType: Byte
+ var payload: ByteArray
+
+ try {
+ // Read header again
+ val chunkHeaderBuffer = ByteBuffer.wrap(inputChunkDataBytes, chunkOffsetInDataRegion.toInt(), CHUNK_HEADER_SIZE)
+ actualLength = chunkHeaderBuffer.getInt()
+ compressionType = chunkHeaderBuffer.get()
+ val payloadSize = actualLength - 1
+ val payloadOffsetInDataRegion = chunkOffsetInDataRegion + CHUNK_HEADER_SIZE
+
+ // Read payload again - this time we need the actual data
+ payload = inputChunkDataBytes.copyOfRange(payloadOffsetInDataRegion.toInt(), (payloadOffsetInDataRegion + payloadSize).toInt())
+
+ } catch (e: Exception) {
+ // Should not happen if Pass 1 succeeded, but good practice
+ println("Error [Pass 2]: Unexpected error reading chunk $i data at offset ${entry.byteOffset}. Skipping write. Error: ${e.message}")
+ // This will lead to a gap/corruption in the output file!
+ continue
+ }
+
+ // --- Decompress if necessary (again) ---
+ var newPayload = payload
+ var newCompressionType = compressionType
+
+ if (compressionType == 2.toByte()) { // ZLIB compressed
+ try {
+ val bais = ByteArrayInputStream(payload)
+ val iis = InflaterInputStream(bais)
+ newPayload = iis.readAllBytes() // Decompress fully
+ newCompressionType = 3.toByte() // Mark as uncompressed
+ } catch (e: Exception) { // Catch ZipException or IOException
+ println("Warning [Pass 2]: Failed to decompress ZLIB chunk $i. Writing as-is. Error: ${e.message}")
+ newPayload = payload // Keep original
+ newCompressionType = compressionType
+ }
+ }
+ // else: Keep original payload and type
+
+ // --- Prepare Data for Writing ---
+ val newActualDataSize = CHUNK_HEADER_SIZE + newPayload.size
+ // Use calculated sizes from Pass 1
+ val newPaddedSize = sizeInfo.newPaddedSize
+ val paddingSize = newPaddedSize - newActualDataSize
+
+ // --- Write Processed Chunk to Output Stream ---
+ try {
+ dos.writeInt(newPayload.size + 1) // New actual length
+ dos.writeByte(newCompressionType.toInt()) // New compression type
+ dos.flush() // Flush DataOutputStream buffer before writing byte array directly
+
+ bos.write(newPayload) // Write payload bytes directly to underlying BufferedOutputStream
+ if (paddingSize > 0) {
+ bos.write(ByteArray(paddingSize)) // Write padding zeros
+ }
+ } catch (e: IOException) {
+ println("Error [Pass 2]: Failed to write processed chunk $i to output stream. Aborting? Error: ${e.message}")
+ // Depending on requirements, might want to re-throw or try to continue
+ throw e // Rethrow for now
+ }
+
+ processedCount++
+ if (processedCount % 100 == 0) print(".")
+
+ } // End Pass 2 loop
+ println("\nPass 2 complete. Wrote $processedCount chunks.")
+
+ // --- 7. Final Flush and Cleanup ---
+ println("Flushing output stream...")
+ bos.flush() // Ensure all buffered data is written to the underlying outputStream
+ println("Conversion complete.")
+
+ // Note: We don't close the input or output streams here.
+ // The caller who provided the streams is responsible for closing them.
+}
\ 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
index aa9a649..3b9bf53 100644
--- a/app/src/main/java/eu/m724/pojavbackup/core/backup/PathTools.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/core/backup/PathTools.kt
@@ -1,71 +1,72 @@
package eu.m724.pojavbackup.core.backup
-import android.content.Context
+import android.content.ContentResolver
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.TarArchiveEntry
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
+import java.io.ByteArrayOutputStream
+import java.io.OutputStream
class PathTools {
companion object {
- fun compressDocumentFileDirectory(source: Path, target: Path) {
- target.outputStream().use {
+ fun compressDocumentFileDirectory(
+ contentResolver: ContentResolver,
+ source: DocumentFile,
+ target: OutputStream
+ ) {
+ target.use {
ZstdCompressorOutputStream(it).use {
TarArchiveOutputStream(it).use { outputStream ->
- compressInner(source, outputStream)
+ compressInner(contentResolver, source, outputStream)
+ outputStream.finish()
}
}
}
}
- 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
+ private fun compressInner(
+ contentResolver: ContentResolver,
+ source: DocumentFile,
+ archiveOutputStream: TarArchiveOutputStream,
+ prefix: String = ""
) {
- if (!sourceDir.isDirectory) {
- // TODO copy file
- return
- }
+ source.listFiles().forEach {
+ if (!it.isDirectory) {
+ val entry = TarArchiveEntry(prefix + it.name)
+ entry.setModTime(it.lastModified())
- sourceDir.listFiles().forEach { child ->
- val targetPath = targetDir.resolve(child.name)
+ val inflate = it.name!!.endsWith(".mca")
- 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()))
+ if (inflate) {
+ contentResolver.openInputStream(it.uri)!!.use { inputStream ->
+ ByteArrayOutputStream().use { outputStream ->
+ convertMcaToUncompressedStream(inputStream, outputStream)
+
+ entry.size = outputStream.size().toLong()
+
+ archiveOutputStream.putArchiveEntry(entry)
+ outputStream.writeTo(archiveOutputStream)
+ }
+ }
+ } else {
+ entry.size = it.length()
+
+ archiveOutputStream.putArchiveEntry(entry)
+
+ contentResolver.openInputStream(it.uri)!!.use { inputStream ->
+ inputStream.copyTo(archiveOutputStream)
+ }
}
+
+ archiveOutputStream.closeArchiveEntry()
+ } else {
+ val entry = TarArchiveEntry(prefix + it.name + "/")
+ entry.setModTime(it.lastModified())
+ archiveOutputStream.putArchiveEntry(entry)
+ archiveOutputStream.closeArchiveEntry() // Close directory entry immediately (no content)
+
+ compressInner(contentResolver, it, archiveOutputStream, prefix + it.name + "/")
}
}
}
diff --git a/app/src/main/java/eu/m724/pojavbackup/core/data/GameDataModule.kt b/app/src/main/java/eu/m724/pojavbackup/core/data/GameDataModule.kt
new file mode 100644
index 0000000..6bba03b
--- /dev/null
+++ b/app/src/main/java/eu/m724/pojavbackup/core/data/GameDataModule.kt
@@ -0,0 +1,56 @@
+package eu.m724.pojavbackup.core.data
+
+import android.content.Context
+import android.util.Log
+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.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.flow
+import java.io.File
+import javax.inject.Singleton
+
+
+@Module
+@InstallIn(SingletonComponent::class)
+object GameDataModule {
+ @Provides
+ @Singleton
+ fun provideGameDataRepository(
+ @ApplicationContext context: Context,
+ settingsRepository: SettingsRepository
+ ): GameDataRepository {
+ return object : GameDataRepository {
+ override suspend fun listAllWorlds(): Flow {
+ val sourceUri = settingsRepository.getSource()!!
+ val documentFile = DocumentFile.fromTreeUri(context, sourceUri)!!.findFile(".minecraft")!!.findFile("saves")!!
+
+ return flow {
+ documentFile.listFiles().mapNotNull {
+ try {
+ emit(getWorldFromDirectory(context.contentResolver, it))
+ } catch (e: InvalidWorldException) {
+ Log.i("GameDataRepository", "${it.name} is invalid: ${e.message}")
+ }
+ }
+ }
+ }
+
+ override suspend fun getWorld(id: String): World {
+ val sourceUri = settingsRepository.getSource()!!
+ 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))
+
+ return getWorldFromDirectory(context.contentResolver, worldFile)
+ }
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/core/data/GameDataRepository.kt b/app/src/main/java/eu/m724/pojavbackup/core/data/GameDataRepository.kt
new file mode 100644
index 0000000..54a1cb0
--- /dev/null
+++ b/app/src/main/java/eu/m724/pojavbackup/core/data/GameDataRepository.kt
@@ -0,0 +1,11 @@
+package eu.m724.pojavbackup.core.data
+
+import eu.m724.pojavbackup.core.data.World.InvalidWorldException
+import kotlinx.coroutines.flow.Flow
+
+interface GameDataRepository {
+ suspend fun listAllWorlds(): Flow
+
+ @Throws(InvalidWorldException::class, NoSuchFileException::class)
+ suspend fun getWorld(id: String): World
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/core/data/LauncherDataRepository.kt b/app/src/main/java/eu/m724/pojavbackup/core/data/LauncherDataRepository.kt
deleted file mode 100644
index 9bb8ed3..0000000
--- a/app/src/main/java/eu/m724/pojavbackup/core/data/LauncherDataRepository.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-package eu.m724.pojavbackup.core.data
-
-import android.content.Context
-import androidx.documentfile.provider.DocumentFile
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class LauncherDataRepository @Inject constructor(
- @ApplicationContext private val appContext: Context
-) {
- private lateinit var dataDirectory: DocumentFile
- private lateinit var worldScanner: WorldScanner
-
- private var worldCache: List? = null
- private val worldCacheMutex = Mutex() // To ensure thread-safe access to cache
-
- fun setSavesDirectory(documentFile: DocumentFile) {
- this.dataDirectory = documentFile
-
- val savesDirectory = dataDirectory.findFile(".minecraft")!!.findFile("saves")!!
- this.worldScanner = WorldScanner(appContext.contentResolver, savesDirectory)
- }
-
- suspend fun listWorlds(): List {
- 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) {
- worldScanner.listWorlds().toList() // TODO
- }
-
- // Store in cache (thread-safe)
- worldCacheMutex.withLock {
- worldCache = freshData
- }
-
- return freshData
- }
-
- suspend fun getWorld(id: String): World? {
- return listWorlds().firstOrNull { it.id == id }
- }
-
- suspend fun clearCache() {
- worldCacheMutex.withLock {
- worldCache = null
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/core/data/World.kt b/app/src/main/java/eu/m724/pojavbackup/core/data/World.kt
index b37ad65..f7b6146 100644
--- a/app/src/main/java/eu/m724/pojavbackup/core/data/World.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/core/data/World.kt
@@ -1,6 +1,17 @@
package eu.m724.pojavbackup.core.data
+import android.content.ContentResolver
import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import androidx.documentfile.provider.DocumentFile
+import net.benwoodworth.knbt.Nbt
+import net.benwoodworth.knbt.NbtCompound
+import net.benwoodworth.knbt.NbtCompression
+import net.benwoodworth.knbt.NbtVariant
+import net.benwoodworth.knbt.decodeFromStream
+import net.benwoodworth.knbt.nbtCompound
+import net.benwoodworth.knbt.nbtLong
+import net.benwoodworth.knbt.nbtString
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
@@ -9,9 +20,66 @@ data class World(
val id: String,
val displayName: String,
val lastPlayed: ZonedDateTime,
- val icon: Bitmap?
+ val icon: Bitmap?,
+
+ val documentFile: DocumentFile?
) {
- companion object {
- val SEPARATOR = World("", "", Instant.EPOCH.atZone(ZoneOffset.UTC), null)
+ 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 {
+ variant = NbtVariant.Java
+ compression = NbtCompression.Gzip
+ }
+
+ @Throws(InvalidWorldException::class)
+ fun getWorldFromDirectory(contentResolver: ContentResolver, directory: DocumentFile): World {
+ val id = directory.name!!
+
+ if (!directory.isDirectory) {
+ throw InvalidWorldException("Not a directory")
+ }
+
+ val levelDat = directory.findFile("level.dat") ?: throw InvalidWorldException("No level.dat")
+
+ val nbtData: NbtCompound = contentResolver.openInputStream(levelDat.uri)?.use {
+ NBT.decodeFromStream(it)[""]?.nbtCompound?.get("Data")?.nbtCompound
+ } ?: throw InvalidWorldException("No NBT data")
+
+ val displayName: String = nbtData["LevelName"]?.nbtString?.value ?: throw InvalidWorldException("No display name")
+ val lastPlayed = Instant.ofEpochMilli(nbtData["LastPlayed"]?.nbtLong?.value ?: throw InvalidWorldException("No display name")).atZone(ZoneOffset.UTC)
+ // TODO maybe load on demand or something
+ val icon = directory.findFile("icon.png")?.let { file ->
+ contentResolver.openInputStream(file.uri)?.use { inputStream ->
+ BitmapFactory.decodeStream(inputStream)
+ }
+ }
+
+
+ return World(
+ id = id,
+ displayName = displayName,
+ lastPlayed = lastPlayed,
+ icon = icon,
+ documentFile = directory
+ )
+ }
+ }
+
+ class InvalidWorldException(
+ override val message: String?
+ ) : Exception()
}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/core/data/WorldScanner.kt b/app/src/main/java/eu/m724/pojavbackup/core/data/WorldScanner.kt
deleted file mode 100644
index 3755216..0000000
--- a/app/src/main/java/eu/m724/pojavbackup/core/data/WorldScanner.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-package eu.m724.pojavbackup.core.data
-
-import android.content.ContentResolver
-import android.graphics.BitmapFactory
-import android.util.Log
-import androidx.documentfile.provider.DocumentFile
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flow
-import net.benwoodworth.knbt.Nbt
-import net.benwoodworth.knbt.NbtCompound
-import net.benwoodworth.knbt.NbtCompression
-import net.benwoodworth.knbt.NbtVariant
-import net.benwoodworth.knbt.decodeFromStream
-import net.benwoodworth.knbt.nbtCompound
-import net.benwoodworth.knbt.nbtLong
-import net.benwoodworth.knbt.nbtString
-import java.time.Instant
-import java.time.ZoneOffset
-
-class WorldScanner(
- private val contentResolver: ContentResolver,
- private val savesDirectory: DocumentFile
-) {
- private val TAG = javaClass.name
-
- private val NBT = Nbt {
- variant = NbtVariant.Java
- compression = NbtCompression.Gzip
- }
-
- fun listWorlds(): Flow {
- return flow {
- savesDirectory.listFiles().mapNotNull {
- try {
- emit(getWorldFromDirectory(it))
- } catch (e: InvalidWorldException) {
- Log.i(TAG, "${it.name} is invalid: ${e.message}")
- }
- }
- }
- }
-
- fun listWorlds(consumer: (World) -> Unit) {
- savesDirectory.listFiles().forEach {
- try {
- val world = getWorldFromDirectory(it)
- consumer(world)
- } catch (e: InvalidWorldException) {
- Log.i(TAG, "${it.name} is invalid: ${e.message}")
- }
- }
- }
-
- fun getWorld(id: String): World? {
- return savesDirectory.findFile(id)?.let {
- try {
- getWorldFromDirectory(it)
- } catch (e: InvalidWorldException) {
- Log.i(TAG, "${it.name} is invalid: ${e.message}")
- null
- }
- }
- }
-
- /**
- * Get a world from a DirectoryFile
- *
- * @param directory The world directory (where level.dat is)
- *
- * @return a World
- *
- * @throws InvalidWorldException If world is invalid
- * @see World
- */
- private fun getWorldFromDirectory(directory: DocumentFile): World {
- val id = directory.name!!
-
- if (!directory.isDirectory) {
- throw InvalidWorldException("Not a directory")
- }
-
- val levelDat = directory.findFile("level.dat") ?: throw InvalidWorldException("No level.dat")
-
- val nbtData: NbtCompound = contentResolver.openInputStream(levelDat.uri)?.use {
- NBT.decodeFromStream(it)[""]?.nbtCompound?.get("Data")?.nbtCompound
- } ?: throw InvalidWorldException("No NBT data")
-
- val displayName: String = nbtData["LevelName"]?.nbtString?.value ?: throw InvalidWorldException("No display name")
- val lastPlayed = Instant.ofEpochMilli(nbtData["LastPlayed"]?.nbtLong?.value ?: throw InvalidWorldException("No display name")).atZone(ZoneOffset.UTC)
- // TODO maybe load on demand or something
- val icon = directory.findFile("icon.png")?.let { file ->
- contentResolver.openInputStream(file.uri)?.use { inputStream ->
- BitmapFactory.decodeStream(inputStream)
- }
- }
-
-
- return World(
- id = id,
- displayName = displayName,
- lastPlayed = lastPlayed,
- icon = icon
- )
- }
-
- class InvalidWorldException(
- override val message: String?
- ) : Exception()
-}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsRepository.kt b/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsRepository.kt
index c586581..c59b5d4 100644
--- a/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsRepository.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/core/datastore/SettingsRepository.kt
@@ -1,5 +1,7 @@
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.Settings
@@ -21,6 +23,13 @@ class SettingsRepository @Inject constructor(
return dataStore.data.first()
}
+ /* Worlds */
+
+ suspend fun getIncludedWorldIds(): List {
+ val worldOrder = getSettings().worldOrder
+ return worldOrder.worldIdsList.subList(0, worldOrder.separatorIndex)
+ }
+
suspend fun updateWorldOrder(worldOrder: WorldOrder) {
dataStore.updateData {
it.toBuilder()
@@ -51,4 +60,21 @@ class SettingsRepository @Inject constructor(
.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())
+ .build()
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/home/HomeActivity.kt b/app/src/main/java/eu/m724/pojavbackup/home/HomeActivity.kt
index 01a7e25..4e69b51 100644
--- a/app/src/main/java/eu/m724/pojavbackup/home/HomeActivity.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/home/HomeActivity.kt
@@ -18,11 +18,13 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -92,6 +94,7 @@ class HomeActivity : ComponentActivity() {
}
}
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScaffold(
onSettingsOpen: (String) -> Unit
@@ -100,6 +103,13 @@ fun HomeScaffold(
Scaffold(
modifier = Modifier.fillMaxSize(),
+ topBar = {
+ TopAppBar(
+ title = {
+ Text("PojavBackup")
+ }
+ )
+ },
bottomBar = {
NavigationBar {
ScreenNavigationBarItem(
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 acb7af6..99e0b76 100644
--- a/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/home/HomeViewModel.kt
@@ -3,26 +3,25 @@ package eu.m724.pojavbackup.home
import android.content.Context
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.LauncherDataRepository
-import kotlinx.coroutines.Dispatchers
+import eu.m724.pojavbackup.core.data.GameDataRepository
+import eu.m724.pojavbackup.core.datastore.SettingsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
- private val launcherDataRepository: LauncherDataRepository
+ private val settingsRepository: SettingsRepository,
+ private val gameDataRepository: GameDataRepository
) : ViewModel() {
private val TAG = javaClass.name
@@ -32,31 +31,14 @@ class HomeViewModel @Inject constructor(
fun load(
onSetupNeeded: () -> Unit
) {
- // TODO this should be dynamic (selected) or a list (debug/normal version)
- val uri = "content://net.kdt.pojavlaunch.scoped.gamefolder.debug/tree/%2Fstorage%2Femulated%2F0%2FAndroid%2Fdata%2Fnet.kdt.pojavlaunch.debug%2Ffiles".toUri()
-
viewModelScope.launch {
- withContext(Dispatchers.IO) {
- val hasPermission = checkForStoragePermission(uri)
+ val uri = settingsRepository.getSource()
- if (hasPermission) {
- // some paths were checked earlier
- val documentFile = DocumentFile.fromTreeUri(appContext, uri)!!
- .findFile(".minecraft")!!
- .findFile("saves")
-
- if (documentFile != null) {
- launcherDataRepository.setSavesDirectory(documentFile)
- launcherDataRepository.listWorlds()
- } else {
- // TODO handle if "saves" doesn't exist
- }
-
- _uiState.update { it.copy(loading = false) }
- } else {
- // TODO there could be that only one or two permissions are missing
- onSetupNeeded()
- }
+ if (uri == null || !checkForStoragePermission(uri)) {
+ // TODO there could be that only one or two permissions are missing
+ onSetupNeeded()
+ } else {
+ _uiState.update { it.copy(loading = false) }
}
}
}
diff --git a/app/src/main/java/eu/m724/pojavbackup/home/screen/HomeScreen.kt b/app/src/main/java/eu/m724/pojavbackup/home/screen/HomeScreen.kt
index b8492a8..b58b051 100644
--- a/app/src/main/java/eu/m724/pojavbackup/home/screen/HomeScreen.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/home/screen/HomeScreen.kt
@@ -1,28 +1,8 @@
package eu.m724.pojavbackup.home.screen
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
import kotlinx.serialization.Serializable
@Serializable sealed interface HomeScreen {
@Serializable data object Dashboard : HomeScreen
@Serializable data object History : HomeScreen
-}
-
-@Composable
-fun ScreenColumn(
- modifier: Modifier = Modifier,
- content: @Composable (ColumnScope.() -> Unit)
-) {
- Column(
- modifier = modifier.fillMaxSize().padding(top = 50.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- content = content
- )
}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreen.kt b/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreen.kt
index 8bea8fd..660cc65 100644
--- a/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreen.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/home/screen/dashboard/DashboardScreen.kt
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -26,7 +27,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.home.screen.HomeScreen
-import eu.m724.pojavbackup.home.screen.ScreenColumn
@OptIn(ExperimentalLayoutApi::class)
@Composable
@@ -37,7 +37,10 @@ fun DashboardScreen(
val viewModel: DashboardScreenViewModel = hiltViewModel()
val settings by viewModel.settings.collectAsStateWithLifecycle()
- ScreenColumn {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
FlowRow(
maxItemsInEachRow = 3
) {
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 5cfa7bf..e8fbbd0 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,34 +22,33 @@ 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.backup.BackupStatus
-import eu.m724.pojavbackup.home.screen.ScreenColumn
+import eu.m724.pojavbackup.core.backup.Backup.BackupStatus
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun HistoryScreen() {
- ScreenColumn {
- Column {
- BackupCard(
- status = BackupStatus.ONGOING,
- dateTime = ZonedDateTime.now().minusDays(28)
- )
- BackupCard(
- status = BackupStatus.SUCCESS,
- dateTime = ZonedDateTime.now().minusDays(7)
- )
- BackupCard(
- status = BackupStatus.FAILURE,
- dateTime = ZonedDateTime.now().minusDays(14)
- )
- BackupCard(
- status = BackupStatus.ABORTED,
- dateTime = ZonedDateTime.now().minusDays(21)
- )
-
- }
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ BackupCard(
+ status = BackupStatus.ONGOING,
+ dateTime = ZonedDateTime.now().minusDays(28)
+ )
+ BackupCard(
+ status = BackupStatus.SUCCESS,
+ dateTime = ZonedDateTime.now().minusDays(7)
+ )
+ BackupCard(
+ status = BackupStatus.FAILURE,
+ dateTime = ZonedDateTime.now().minusDays(14)
+ )
+ BackupCard(
+ status = BackupStatus.ABORTED,
+ dateTime = ZonedDateTime.now().minusDays(21)
+ )
}
}
diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/SettingsScreen.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/SettingsScreen.kt
index 6d3b45f..21f9c1b 100644
--- a/app/src/main/java/eu/m724/pojavbackup/settings/screen/SettingsScreen.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/SettingsScreen.kt
@@ -1,29 +1,9 @@
package eu.m724.pojavbackup.settings.screen
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
import kotlinx.serialization.Serializable
@Serializable sealed interface SettingsScreen {
@Serializable data object Options : SettingsScreen
@Serializable data object Content : SettingsScreen
@Serializable data object Destination : SettingsScreen
-}
-
-@Composable
-fun ScreenColumn(
- modifier: Modifier = Modifier,
- content: @Composable (ColumnScope.() -> Unit)
-) {
- Column(
- modifier = modifier.fillMaxSize().padding(top = 50.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- content = content
- )
}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreen.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreen.kt
index 32b2453..1d84839 100644
--- a/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreen.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreen.kt
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -35,7 +36,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import eu.m724.pojavbackup.R
-import eu.m724.pojavbackup.home.screen.ScreenColumn
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
@@ -51,43 +51,51 @@ fun ContentScreen(
) {
val viewModel: ContentScreenViewModel = hiltViewModel()
- val worlds by viewModel.worlds.collectAsStateWithLifecycle()
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
- ScreenColumn {
- if (worlds.size <= 1) { // separator
- Text(
- text = "No worlds available!"
- )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (uiState.loading) {
+ CircularProgressIndicator()
} else {
- val state = rememberReorderableLazyListState(onMove = { from, to ->
- viewModel.moveWorld(from.index, to.index)
- })
+ if (uiState.worlds.size <= 1) { // separator
+ Text(
+ text = "No worlds available!",
+ modifier = Modifier.padding(top = 50.dp)
+ )
+ } else {
+ val state = rememberReorderableLazyListState(onMove = { from, to ->
+ viewModel.moveWorld(from.index, to.index)
+ })
- LazyColumn(
- modifier = Modifier
- .reorderable(state)
- .detectReorderAfterLongPress(state),
- state = state.listState,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- items(
- items = worlds,
- key = { it.id }
- ) { world ->
- ReorderableItem(state, key = world.id) { isDragging ->
- if (!world.id.isEmpty()) {
- WorldInfoCard(
- bitmap = world.icon,
- id = world.id,
- displayName = world.displayName,
- lastPlayed = world.lastPlayed
- )
- } else {
- Text(
- text = "↑ Worlds above this line will be backed up ↑",
- modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
- textAlign = TextAlign.Center
- )
+ LazyColumn(
+ modifier = Modifier
+ .reorderable(state)
+ .detectReorderAfterLongPress(state),
+ state = state.listState,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ items(
+ items = uiState.worlds,
+ key = { it.id }
+ ) { world ->
+ ReorderableItem(state, key = world.id) { isDragging ->
+ if (!world.id.isEmpty()) {
+ WorldInfoCard(
+ bitmap = world.icon,
+ id = world.id,
+ displayName = world.displayName,
+ lastPlayed = world.lastPlayed
+ )
+ } else {
+ Text(
+ text = "↑ Worlds above this line will be backed up ↑",
+ modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
+ textAlign = TextAlign.Center
+ )
+ }
}
}
}
diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenUiState.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenUiState.kt
new file mode 100644
index 0000000..7e0638f
--- /dev/null
+++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/content/ContentScreenUiState.kt
@@ -0,0 +1,8 @@
+package eu.m724.pojavbackup.settings.screen.content
+
+import eu.m724.pojavbackup.core.data.World
+
+data class ContentScreenUiState(
+ val loading: Boolean = true,
+ val worlds: List = emptyList()
+)
\ No newline at end of file
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 8880874..de44fa2 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.data.GameDataRepository
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
@@ -15,56 +15,68 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import java.time.Instant
-import java.time.ZoneOffset
import javax.inject.Inject
@HiltViewModel
class ContentScreenViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
- private val launcherDataRepository: LauncherDataRepository,
+ private val gameDataRepository: GameDataRepository,
private val settingsRepository: SettingsRepository
) : ViewModel() {
- private val _worlds = MutableStateFlow>(emptyList())
- val worlds: StateFlow> = _worlds.asStateFlow()
+ private val _uiState = MutableStateFlow(ContentScreenUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
init {
viewModelScope.launch {
settingsRepository.getSettingsFlow().collect { settings ->
val worlds = settings.worldOrder.worldIdsList.map {
- // TODO mark deleted worlds better
- launcherDataRepository.getWorld(it) ?: World(it, "Deleted world", Instant.EPOCH.atZone(ZoneOffset.UTC), null)
+ 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)
- // TODO same for extras
+ gameDataRepository.listAllWorlds().collect {
+ if (!worlds.contains(it)) {
+ worlds.add(it)
+ }
+ }
- _worlds.update { worlds }
+ _uiState.update {
+ it.copy(loading = false, worlds = worlds)
+ }
}
}
}
fun moveWorld(fromIndex: Int, toIndex: Int) {
// Similar to mutableStateOf, create a NEW list
- val currentList = _worlds.value.toMutableList()
+ val currentList = _uiState.value.worlds.toMutableList()
// Check bounds for safety
if (fromIndex in currentList.indices && toIndex >= 0 && toIndex <= currentList.size) {
val item = currentList.removeAt(fromIndex)
currentList.add(toIndex, item)
- _worlds.value = currentList // Assign the new list to the flow
- updateWorldOrder()
+ updateWorldOrder(currentList)
} else {
Log.e("Reorder", "Invalid indices: from $fromIndex, to $toIndex, size ${currentList.size}")
}
}
- private fun updateWorldOrder() {
+ private fun updateWorldOrder(newList: List) {
+ _uiState.update {
+ it.copy(worlds = newList)
+ }
+
viewModelScope.launch {
settingsRepository.updateWorldOrder(
WorldOrder.newBuilder()
- .addAllWorldIds(_worlds.value.filter { it != World.SEPARATOR }.map { it.id })
- .setSeparatorIndex(_worlds.value.indexOf(World.SEPARATOR))
+ .addAllWorldIds(newList.filter { it != World.SEPARATOR }.map { it.id })
+ .setSeparatorIndex(newList.indexOf(World.SEPARATOR))
.build()
)
}
diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/destination/DestinationScreen.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/destination/DestinationScreen.kt
index 59747e4..d459ce8 100644
--- a/app/src/main/java/eu/m724/pojavbackup/settings/screen/destination/DestinationScreen.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/destination/DestinationScreen.kt
@@ -23,7 +23,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import eu.m724.pojavbackup.R
-import eu.m724.pojavbackup.settings.screen.ScreenColumn
@Composable
fun DestinationScreen(
@@ -33,7 +32,7 @@ fun DestinationScreen(
val viewModel: DestinationScreenViewModel = hiltViewModel()
val destinations by viewModel.destinations.collectAsStateWithLifecycle()
- ScreenColumn {
+ Column {
Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(0.5f), // TODO make room for more destinations
horizontalAlignment = Alignment.CenterHorizontally
diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreen.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreen.kt
index f81da2c..d3287f4 100644
--- a/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreen.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreen.kt
@@ -3,22 +3,36 @@ package eu.m724.pojavbackup.settings.screen.options
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
@Composable
fun OptionsScreen(
navController: NavController,
) {
+ val viewModel: OptionsScreenViewModel = hiltViewModel()
+
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
- Text("This is the options screen")
- Text("It's empty for now")
+ Button(
+ onClick = {
+ viewModel.backupNow()
+ }
+ ) {
+ Text("Backup now")
+ }
+
+ val status by viewModel.backupStatus.collectAsStateWithLifecycle()
+ Text(text = status)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreenViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreenViewModel.kt
new file mode 100644
index 0000000..e4e8a0e
--- /dev/null
+++ b/app/src/main/java/eu/m724/pojavbackup/settings/screen/options/OptionsScreenViewModel.kt
@@ -0,0 +1,51 @@
+package eu.m724.pojavbackup.settings.screen.options
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkRequest
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import eu.m724.pojavbackup.core.backup.BackupWorker
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.util.UUID
+import javax.inject.Inject
+
+@HiltViewModel
+class OptionsScreenViewModel @Inject constructor(
+ @ApplicationContext private val appContext: Context
+) : ViewModel() {
+ private val _backupStatus = MutableStateFlow("Not running")
+ val backupStatus: StateFlow = _backupStatus.asStateFlow()
+
+ private val workManager = WorkManager
+ .getInstance(appContext)
+
+ fun backupNow() {
+ _backupStatus.value = "Starting"
+
+ val uuid = UUID.randomUUID()
+
+ val workRequest: WorkRequest =
+ OneTimeWorkRequestBuilder()
+ .setId(uuid)
+ .build()
+
+ workManager.enqueue(workRequest)
+
+ viewModelScope.launch {
+ workManager.getWorkInfoByIdFlow(uuid).collect { workInfo ->
+ _backupStatus.value = workInfo?.state.toString()
+
+ workInfo?.progress?.getString("status")?.let {
+ _backupStatus.value = it
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt b/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt
index 2f42e6e..f50b8d8 100644
--- a/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/setup/SetupActivity.kt
@@ -26,25 +26,29 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import eu.m724.pojavbackup.ui.theme.PojavBackupTheme
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import eu.m724.pojavbackup.home.HomeActivity
+import eu.m724.pojavbackup.ui.theme.PojavBackupTheme
@AndroidEntryPoint
class SetupActivity : ComponentActivity() {
private val viewModel: SetupViewModel by viewModels()
private val openDocumentTree = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
- viewModel.onOpenDocumentTree(applicationContext, it, { success ->
+ viewModel.onOpenDocumentTree(applicationContext, it) { success ->
if (success) {
onComplete()
} else {
// TODO instead red text?
- Toast.makeText(applicationContext, "This is not a PojavLauncher directory.", Toast.LENGTH_SHORT).show()
+ Toast.makeText(
+ applicationContext,
+ "This is not a PojavLauncher directory.",
+ Toast.LENGTH_SHORT
+ ).show()
}
- })
+ }
}
fun onComplete() {
@@ -59,16 +63,7 @@ class SetupActivity : ComponentActivity() {
// println("Found pojav launchers: ${packages.joinToString(", ")}")
- val uri = "content://net.kdt.pojavlaunch.scoped.gamefolder.debug/tree/%2Fstorage%2Femulated%2F0%2FAndroid%2Fdata%2Fnet.kdt.pojavlaunch.debug%2Ffiles".toUri()
-
- val hasPermission = viewModel.checkForStoragePermission(
- applicationContext,
- uri
- )
-
- if (hasPermission) {
- onComplete()
- }
+ val defaultUri = "content://net.kdt.pojavlaunch.scoped.gamefolder.debug/tree/%2Fstorage%2Femulated%2F0%2FAndroid%2Fdata%2Fnet.kdt.pojavlaunch.debug%2Ffiles".toUri()
setContent {
PojavBackupTheme {
@@ -77,7 +72,7 @@ class SetupActivity : ComponentActivity() {
modifier = Modifier.padding(innerPadding),
viewModel = viewModel,
onGrantClick = {
- openDocumentTree.launch(uri)
+ openDocumentTree.launch(defaultUri)
}
)
}
diff --git a/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt b/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt
index d5d54fc..56fd903 100644
--- a/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt
+++ b/app/src/main/java/eu/m724/pojavbackup/setup/SetupViewModel.kt
@@ -8,12 +8,20 @@ import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import eu.m724.pojavbackup.core.datastore.SettingsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
-class SetupViewModel : ViewModel() {
+@HiltViewModel
+class SetupViewModel @Inject constructor(
+ private val settingsRepository: SettingsRepository
+) : ViewModel() {
private val TAG: String = javaClass.name
private val POJAV_PACKAGES = arrayOf(
@@ -35,18 +43,18 @@ class SetupViewModel : ViewModel() {
)
val hasPermission = checkForStoragePermission(context, uri)
+
+ viewModelScope.launch {
+ if (hasPermission) {
+ settingsRepository.setSource(uri)
+ }
+ }
+
result(hasPermission)
}
}
fun checkForStoragePermission(context: Context, uri: Uri): Boolean {
- val hasPermission = _checkForStoragePermission(context, uri)
- _uiState.update { it.copy(storagePermissionGranted = hasPermission) }
-
- return hasPermission
- }
-
- private fun _checkForStoragePermission(context: Context, uri: Uri): Boolean {
Log.i(TAG, "Checking for storage permission...")
// TODO Is this the right way? This isn't in https://developer.android.com/training/data-storage/shared/documents-files
@@ -67,6 +75,10 @@ class SetupViewModel : ViewModel() {
Log.i(TAG, "Yes we have permission")
+ _uiState.update {
+ it.copy(storagePermissionGranted = true)
+ }
+
return true
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 5797abc..ceaccdc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -22,7 +22,9 @@ protobufJavalite = "4.30.2"
protobuf = "0.9.5"
commonsCompress = "1.27.1"
work = "2.10.0"
-hiltWork = "1.2.0"
+androidxHilt = "1.2.0"
+zstdJni = "1.5.7-2"
+lz4Java = "1.8.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -52,7 +54,10 @@ androidx-datastore = { group = "androidx.datastore", name = "datastore", version
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"}
+androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "androidxHilt" }
+hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidxHilt"}
+zstd-jni = { group = "com.github.luben", name = "zstd-jni", version.ref = "zstdJni" }
+lz4-java = { group = "org.lz4", name = "lz4-java", version.ref = "lz4Java" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }