This commit is contained in:
Minecon724 2025-04-22 20:17:47 +02:00
commit 697b9e0bec
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
30 changed files with 882 additions and 443 deletions

View file

@ -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 {

View file

@ -39,6 +39,17 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- If you are using androidx.startup to initialize other components -->
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View file

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

View file

@ -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
)
}
}

View file

@ -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)
}

View file

@ -1,8 +0,0 @@
package eu.m724.pojavbackup.core.backup
enum class BackupStatus {
SUCCESS,
FAILURE,
ONGOING,
ABORTED
}

View file

@ -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())
}
}

View file

@ -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<ProcessedChunkSizeInfo>()
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.
}

View file

@ -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 <T : ArchiveEntry> compressInner(source: Path, outputStream: ArchiveOutputStream<T>) {
// TODO we could compress DirectoryFile I think https://commons.apache.org/proper/commons-compress/examples.html https://aistudio.google.com/prompts/1AFCTIE9FdxT3AvDOQ0puTxoSv6xMYYH1
source.walk().forEach {
val entry = outputStream.createArchiveEntry(it, it.name)
outputStream.putArchiveEntry(entry)
Files.copy(it, outputStream)
outputStream.closeArchiveEntry()
}
outputStream.finish()
}
/**
* Recursively copies the contents of a DocumentFile directory to a local Path.
*
* @param context Context needed for ContentResolver.
* @param sourceDir The source DocumentFile directory. Must be a directory.
* @param targetDir The destination Path directory. Will be created if it doesn't exist.
*/
fun copyFromDocumentFileToPath(
context: Context,
sourceDir: DocumentFile,
targetDir: Path
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 + "/")
}
}
}

View file

@ -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<World> {
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)
}
}
}
}

View file

@ -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<World>
@Throws(InvalidWorldException::class, NoSuchFileException::class)
suspend fun getWorld(id: String): World
}

View file

@ -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<World>? = 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<World> {
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
}
}
}

View file

@ -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<NbtCompound>(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()
}

View file

@ -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<World> {
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<NbtCompound>(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()
}

View file

@ -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<String> {
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()
}
}
}

View file

@ -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(

View file

@ -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) }
}
}
}

View file

@ -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
)
}

View file

@ -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
) {

View file

@ -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)
)
}
}

View file

@ -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
)
}

View file

@ -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
)
}
}
}
}

View file

@ -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<World> = emptyList()
)

View file

@ -6,8 +6,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.m724.pojavbackup.core.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<List<World>>(emptyList())
val worlds: StateFlow<List<World>> = _worlds.asStateFlow()
private val _uiState = MutableStateFlow(ContentScreenUiState())
val uiState: StateFlow<ContentScreenUiState> = _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<World>) {
_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()
)
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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<String> = _backupStatus.asStateFlow()
private val workManager = WorkManager
.getInstance(appContext)
fun backupNow() {
_backupStatus.value = "Starting"
val uuid = UUID.randomUUID()
val workRequest: WorkRequest =
OneTimeWorkRequestBuilder<BackupWorker>()
.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
}
}
}
}
}

View file

@ -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)
}
)
}

View file

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

View file

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