Update
This commit is contained in:
parent
462c49765b
commit
697b9e0bec
30 changed files with 882 additions and 443 deletions
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package eu.m724.pojavbackup.core.backup
|
||||
|
||||
enum class BackupStatus {
|
||||
SUCCESS,
|
||||
FAILURE,
|
||||
ONGOING,
|
||||
ABORTED
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
349
app/src/main/java/eu/m724/pojavbackup/core/backup/McaUtils.kt
Normal file
349
app/src/main/java/eu/m724/pojavbackup/core/backup/McaUtils.kt
Normal 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.
|
||||
}
|
|
@ -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 + "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
)
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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" }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue