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" }