This commit is contained in:
Minecon724 2025-04-28 20:13:34 +02:00
commit 392c0efc83
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
23 changed files with 240 additions and 645 deletions

View file

@ -25,17 +25,14 @@
<activity
android:name=".settings.SettingsActivity"
android:exported="false"
android:label="@string/title_activity_settings"
android:theme="@style/Theme.PojavBackup" />
<activity
android:name=".setup.SetupActivity"
android:exported="false"
android:label="@string/title_activity_setup"
android:theme="@style/Theme.PojavBackup" />
<activity
android:name=".home.HomeActivity"
android:exported="true"
android:label="@string/title_activity_home"
android:theme="@style/Theme.PojavBackup">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

@ -1,26 +1,21 @@
package eu.m724.pojavbackup.core.backup
import android.content.Context
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.backup.Backup.BackupStatus
import java.time.Instant
import java.util.UUID
import java.util.concurrent.ThreadLocalRandom
import kotlin.io.path.ExperimentalPathApi
@Module
@InstallIn(SingletonComponent::class)
object BackupModule {
@Provides
fun provideBackupRepository(
@ApplicationContext context: Context
): BackupRepository {
fun provideBackupRepository(): BackupRepository {
return object : BackupRepository {
override suspend fun createBackup(): Backup {
override fun createBackup(): Backup {
val bytes = ByteArray(16)
ThreadLocalRandom.current().nextBytes(bytes)
@ -31,8 +26,7 @@ object BackupModule {
)
}
@OptIn(ExperimentalPathApi::class)
override suspend fun completeBackup(backup: Backup, status: BackupStatus): Backup {
override fun completeBackup(backup: Backup, status: BackupStatus): Backup {
return backup.copy(
status = status
)

View file

@ -3,6 +3,6 @@ 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
fun createBackup(): Backup
fun completeBackup(backup: Backup, status: BackupStatus = BackupStatus.SUCCESS): Backup
}

View file

@ -1,8 +1,8 @@
package eu.m724.pojavbackup.core.backup
import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.net.Uri
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
@ -15,10 +15,12 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.core.backup.Backup.BackupStatus
import eu.m724.pojavbackup.core.backup.exception.BackupFailedException
import eu.m724.pojavbackup.core.backup.exception.BackupOfWorldException
import eu.m724.pojavbackup.core.data.GameDataRepository
import eu.m724.pojavbackup.core.data.World
import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.notification.NotificationChannels
import org.apache.commons.compress.compressors.CompressorStreamFactory
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.UUID
@ -32,106 +34,113 @@ class BackupWorker @AssistedInject constructor(
private val backupRepository: BackupRepository,
private val gameDataRepository: GameDataRepository
) : CoroutineWorker(appContext, workerParams) {
private val notificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as
NotificationManager
private val notificationId: Int =
Random.nextInt()
private val errors = mutableListOf<Throwable>()
private lateinit var backup: Backup
override suspend fun doWork(): Result {
val backup = backupRepository.createBackup()
try {
backup = backupRepository.createBackup()
doBackup(backup)
updateStatus("Initializing")
val settings = settingsRepository.getSettings()
val worldIds = settingsRepository.getIncludedWorldIds(settings.worldOrder)
// TODO multiple destinations support and remove those !!
val backupUri = settings.destinationsList.first().uri.toUri()
val backupDirectory = DocumentFile.fromTreeUri(applicationContext, backupUri)!!
.createDirectory(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()) + " (" + backup.id + ")")!!
worldIds.forEach {
updateStatus("Backing up world $it")
val world = runCatching {
gameDataRepository.getWorld(it)
}
if (world.isFailure) {
val e = Exception("Error backing up world $it", world.exceptionOrNull())
errors.add(e)
e.printStackTrace()
return@forEach
}
val compression = if (settings.improvement) CompressorStreamFactory.ZSTANDARD else CompressorStreamFactory.GZIP
val documentFile = backupDirectory.createFile("application/zstd", "$it.tar.$compression")!!
applicationContext.contentResolver.openOutputStream(documentFile.uri)!!.use {
PathTools.compressDocumentFileDirectory(
contentResolver = applicationContext.contentResolver,
source = world.getOrThrow().documentFile!!,
target = it,
compression = compression,
inflate = settings.improvement
)
}
}
} catch (e: Exception) {
val e = Exception("Error backing up", e)
errors.add(e)
e.printStackTrace()
}
if (errors.isEmpty()) {
backupRepository.completeBackup(backup, BackupStatus.SUCCESS)
return Result.success(statusData(backup.id, "Backup completed successfully"))
} catch (cause: Throwable) {
val e = throw BackupFailedException(backup, cause)
// TODO raise this error
return Result.success(statusData("Backup completed successfully"))
} else {
backupRepository.completeBackup(backup, BackupStatus.FAILURE)
// TODO notify and tell error
return Result.failure(statusData("Backup failed"))
return Result.failure(statusData(backup.id, "Backup failed"))
}
}
fun statusData(status: String): Data {
private suspend fun doBackup(backup: Backup) {
val settings = settingsRepository.getSettings()
val worldIds = settingsRepository.getIncludedWorldIds(settings.worldOrder)
// TODO multiple destinations support
val destinationUri = settings.destinationsList.first().uri.toUri()
val backupDirectory = createBackupDirectory(backup.id, destinationUri)
val documentFileCompressor = DocumentFileCompressor(
openInputStream = {
applicationContext.contentResolver.openInputStream(it.uri)!!
}
)
worldIds.forEach {
updateStatus(backup.id, it)
try {
val world = gameDataRepository.getWorld(it)
doBackupWorld(world, documentFileCompressor, backupDirectory)
} catch (e: Throwable) {
// TODO maybe continue with other worlds
throw BackupOfWorldException(backup, e)
}
}
}
private fun doBackupWorld(
world: World,
documentFileCompressor: DocumentFileCompressor,
backupDirectory: DocumentFile
) {
val documentFile = backupDirectory.createFile("application/zstd", "${world.id}.tar.gz")
if (documentFile == null) {
// TODO generic Exception?
throw Exception("Failed to create file: \"${world.id}.tar.gz\" in: ${backupDirectory.uri}")
}
val outputStream = applicationContext.contentResolver.openOutputStream(documentFile.uri)
if (outputStream == null) {
// TODO generic Exception?
throw Exception("Failed to open for writing: ${documentFile.uri}")
}
outputStream.use {
documentFileCompressor.compressToOutputStream(
source = world.documentFile!!,
target = it
)
}
}
private fun createBackupDirectory(
backupId: UUID,
destinationUri: Uri
): DocumentFile {
val destinationRoot = DocumentFile.fromTreeUri(applicationContext, destinationUri)!! // would return null only <=A5
val destination = destinationRoot.createDirectory(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()) + " (" + backupId + ")")
if (destination == null) {
// TODO generic Exception?
throw Exception("Failed to create backup destination directory")
}
return destination
}
private fun statusData(uuid: UUID, status: String): Data {
return Data.Builder()
.putUuid("uuid", backup.id)
.putUuid("uuid", uuid)
.putString("status", status)
.build()
}
suspend fun updateStatus(status: String) {
private suspend fun updateStatus(uuid: UUID, status: String) {
setForeground(createForegroundInfo(status))
val data = statusData(status)
val data = statusData(uuid, status)
setProgress(data)
}
fun finish(status: String, success: Boolean) {
val data = statusData(status)
if (success) {
Result.success(data)
} else {
Result.failure(data)
}
}
fun Data.Builder.putUuid(key: String, value: UUID): Data.Builder {
private fun Data.Builder.putUuid(key: String, value: UUID): Data.Builder {
return this.putLongArray(key, longArrayOf(value.mostSignificantBits, value.leastSignificantBits))
}

View file

@ -0,0 +1,54 @@
package eu.m724.pojavbackup.core.backup
import androidx.documentfile.provider.DocumentFile
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.apache.commons.compress.compressors.CompressorStreamFactory
import java.io.InputStream
import java.io.OutputStream
class DocumentFileCompressor(
private val openInputStream: (DocumentFile) -> InputStream,
) {
fun compressToOutputStream(
source: DocumentFile,
target: OutputStream,
compressionAlgorithm: String = CompressorStreamFactory.GZIP
) {
CompressorStreamFactory().createCompressorOutputStream(compressionAlgorithm, target).use { compressorOutputStream ->
TarArchiveOutputStream(compressorOutputStream).use { tarArchiveOutputStream ->
writeTarEntry(tarArchiveOutputStream, source)
tarArchiveOutputStream.finish()
}
}
}
private fun writeTarEntry(
tarArchiveOutputStream: TarArchiveOutputStream,
file: DocumentFile,
prefix: String = "",
) {
val entryName = prefix + file.name + if (file.isDirectory) "/" else ""
val entry = TarArchiveEntry(entryName)
entry.setModTime(file.lastModified())
entry.size = file.length() // for directory this doesn't matter
tarArchiveOutputStream.putArchiveEntry(entry)
if (!file.isDirectory) {
openInputStream(file).use { inputStream ->
inputStream.copyTo(tarArchiveOutputStream)
}
}
tarArchiveOutputStream.closeArchiveEntry()
if (file.isDirectory) {
file.listFiles().forEach { child ->
writeTarEntry(tarArchiveOutputStream, child, entryName)
}
}
}
}

View file

@ -1,349 +0,0 @@
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,77 +0,0 @@
package eu.m724.pojavbackup.core.backup
import android.content.ContentResolver
import androidx.documentfile.provider.DocumentFile
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.apache.commons.compress.compressors.CompressorStreamFactory
import java.io.ByteArrayOutputStream
import java.io.OutputStream
class PathTools {
companion object {
fun compressDocumentFileDirectory(
contentResolver: ContentResolver,
source: DocumentFile,
target: OutputStream,
compression: String,
inflate: Boolean = false,
) {
target.use {
CompressorStreamFactory().createCompressorOutputStream(compression, it).use {
TarArchiveOutputStream(it).use { outputStream ->
compressInner(contentResolver, source, outputStream, "", inflate)
outputStream.finish()
}
}
}
}
private fun compressInner(
contentResolver: ContentResolver,
source: DocumentFile,
archiveOutputStream: TarArchiveOutputStream,
prefix: String = "",
inflate: Boolean = false,
) {
source.listFiles().forEach {
if (!it.isDirectory) {
val entry = TarArchiveEntry(prefix + it.name)
entry.setModTime(it.lastModified())
val inflate = inflate and it.name!!.endsWith(".mca")
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 + "/", inflate)
}
}
}
}
}

View file

@ -0,0 +1,8 @@
package eu.m724.pojavbackup.core.backup.exception
import eu.m724.pojavbackup.core.backup.Backup
open class BackupException(
backup: Backup,
cause: Throwable
) : Exception("Exception backing up (backup ID: ${backup.id})", cause)

View file

@ -0,0 +1,9 @@
package eu.m724.pojavbackup.core.backup.exception
import eu.m724.pojavbackup.core.backup.Backup
class BackupFailedException(
backup: Backup,
cause: Throwable
) : BackupException(backup, cause) {
}

View file

@ -0,0 +1,8 @@
package eu.m724.pojavbackup.core.backup.exception
import eu.m724.pojavbackup.core.backup.Backup
class BackupOfWorldException(
backup: Backup,
cause: Throwable
) : BackupException(backup, cause)

View file

@ -23,22 +23,6 @@ class SettingsRepository @Inject constructor(
return dataStore.data.first()
}
suspend fun updateSettings(
improvement: Boolean? = null
) {
dataStore.updateData {
val builder = it.toBuilder()
if (improvement != null) {
builder.setImprovement(improvement)
}
// add here
builder.build()
}
}
/* Worlds */
suspend fun getIncludedWorldIds(): List<String> {

View file

@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -119,7 +120,7 @@ fun HomeScaffold(
topBar = {
TopAppBar(
title = {
Text("PojavBackup")
Text(stringResource(R.string.app_name))
}
)
},
@ -127,13 +128,13 @@ fun HomeScaffold(
NavigationBar {
ScreenNavigationBarItem(
navController = navController,
label = "Dashboard",
label = stringResource(R.string.header_dashboard),
route = HomeScreen.Dashboard,
iconResourceId = R.drawable.baseline_home_24
)
ScreenNavigationBarItem(
navController = navController,
label = "History",
label = stringResource(R.string.header_history),
route = HomeScreen.History,
iconResourceId = R.drawable.baseline_history_24
)

View file

@ -19,6 +19,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@ -45,21 +46,21 @@ fun DashboardScreen(
maxItemsInEachRow = 3
) {
DashboardCard(
title = "Worlds included",
title = stringResource(R.string.dashboard_card_worlds),
value = settings.worldOrder.separatorIndex,
iconResourceId = R.drawable.baseline_mosque_24,
onClick = onWorldsIncludedClick
)
DashboardCard(
title = "Health",
value = "Good",
title = stringResource(R.string.dashboard_card_health),
value = "Good", // TODO
iconResourceId = R.drawable.baseline_heart_broken_24
)
DashboardCard(
title = "Last backup",
value = "1d ago",
title = stringResource(R.string.dashboard_card_last_backup),
value = "1d ago", // TODO
iconResourceId = R.drawable.baseline_access_time_filled_24,
onClick = {
navController.navigate(HomeScreen.History)
@ -119,7 +120,7 @@ fun DashboardCard(
if (onClick != null) {
Icon(
painter = painterResource(R.drawable.baseline_arrow_forward_ios_24),
contentDescription = "Go to $title",
contentDescription = stringResource(R.string.dashboard_card_click, title),
modifier = Modifier.padding(end = 2.dp).size(12.dp),
tint = MaterialTheme.colorScheme.secondary
)

View file

@ -123,7 +123,7 @@ fun BackupCard(
modifier = Modifier.width(5.dp)
)
Text(
text = "$formattedTimestamp", // Use formatted timestamp
text = formattedTimestamp,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.NavHost
@ -69,7 +70,7 @@ class SettingsActivity : ComponentActivity() {
topBar = {
TopAppBar(
title = {
Text("Settings")
Text(stringResource(R.string.header_settings))
},
navigationIcon = {
IconButton(
@ -79,7 +80,7 @@ class SettingsActivity : ComponentActivity() {
) {
Icon(
painter = painterResource(R.drawable.baseline_arrow_back_24),
contentDescription = "Exit Settings"
contentDescription = stringResource(R.string.settings_exit)
)
}
}
@ -89,19 +90,19 @@ class SettingsActivity : ComponentActivity() {
NavigationBar {
ScreenNavigationBarItem(
navController = navController,
label = "Options",
label = stringResource(R.string.settings_options),
route = SettingsScreen.Options,
iconResourceId = R.drawable.baseline_settings_24
)
ScreenNavigationBarItem(
navController = navController,
label = "Content",
label = stringResource(R.string.settings_content),
route = SettingsScreen.Content,
iconResourceId = R.drawable.baseline_folder_copy_24
)
ScreenNavigationBarItem(
navController = navController,
label = "Destination",
label = stringResource(R.string.settings_destination),
route = SettingsScreen.Destination,
iconResourceId = R.drawable.baseline_cloud_24
)

View file

@ -28,9 +28,8 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -62,7 +61,7 @@ fun ContentScreen(
} else {
if (uiState.worlds.size <= 1) { // separator
Text(
text = "No worlds available!",
text = stringResource(R.string.worlds_none),
modifier = Modifier.padding(top = 50.dp)
)
} else {
@ -91,7 +90,7 @@ fun ContentScreen(
)
} else {
Text(
text = "Worlds above this line will be backed up",
text = "" + stringResource(R.string.worlds_separator) + "",
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
textAlign = TextAlign.Center
)
@ -110,19 +109,14 @@ fun ContentScreen(
*
* @param modifier Optional Modifier for the Card.
* @param bitmap The Bitmap for the icon to display. If null, uses a pack.png-like default icon.
* @param iconSize The size for the square icon (width and height).
* @param id The ID text to display.
* @param displayName The display name text.
* @param lastPlayed The ZonedDateTime timestamp to display, formatted by locale.
* @param elevation The elevation of the card.
* @param internalPadding Padding inside the card, around the content.
* @param spacingBetweenIconAndText Space between the icon and the text column.
*/
@Composable
fun WorldInfoCard(
modifier: Modifier = Modifier,
bitmap: Bitmap?,
iconSize: Dp = 64.dp, // Control icon size here
id: String,
displayName: String,
lastPlayed: ZonedDateTime
@ -160,7 +154,7 @@ fun WorldInfoCard(
painter = painter, // Use the determined painter
contentDescription = "world icon", // Hardcoded content description
modifier = Modifier
.size(iconSize)
.size(64.dp)
.align(Alignment.CenterVertically)
.clip(CardDefaults.shape), // TODO match corner radius
contentScale = ContentScale.Crop // Crop is usually best for fixed aspect ratio
@ -178,26 +172,11 @@ fun WorldInfoCard(
)
Text(
text = "Last played $formattedTimestamp", // Use formatted timestamp
text = stringResource(R.string.worlds_last_played, formattedTimestamp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Preview(showBackground = true, name = "Card with Default Icon")
@Composable
fun WorldInfoCardPreviewDefaultIcon() {
MaterialTheme {
Column(modifier = Modifier.padding(16.dp)) {
WorldInfoCard(
bitmap = null, // Test the default icon case
id = "world-001",
displayName = "Earth",
lastPlayed = ZonedDateTime.now().minusDays(1)
)
}
}
}

View file

@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -38,7 +39,9 @@ fun DestinationScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
if (destinations.isEmpty()) {
Text("There are no destinations.")
Text(
text = stringResource(R.string.destination_none)
)
} else {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally
@ -74,7 +77,7 @@ fun DestinationScreen(
)
Text(
text = "Add destination"
text = stringResource(R.string.destination_add)
)
}
}

View file

@ -2,18 +2,18 @@ package eu.m724.pojavbackup.settings.screen.options
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
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.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import eu.m724.pojavbackup.R
@Composable
fun OptionsScreen(
@ -22,17 +22,6 @@ fun OptionsScreen(
val viewModel: OptionsScreenViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Toggle(
title = "Optimal mode",
description = "Uses less space, but requires special treatment to restore",
checked = uiState.improvement,
onCheckedChange = {
viewModel.setOptions(improvement = it)
}
)
}
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
@ -43,30 +32,12 @@ fun OptionsScreen(
viewModel.backupNow()
}
) {
Text("Backup now")
Text(
text = stringResource(R.string.button_backup_now)
)
}
Text(uiState.backupStatusHeader)
Text(uiState.backupStatusDetails)
}
}
@Composable
fun Toggle(
title: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row {
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange
)
Column {
Text(title)
Text(description)
}
}
}

View file

@ -1,7 +1,6 @@
package eu.m724.pojavbackup.settings.screen.options
data class OptionsScreenUiState(
val improvement: Boolean = false,
val backupStatusHeader: String = "",
val backupStatusDetails: String = ""
)

View file

@ -29,18 +29,6 @@ class OptionsScreenViewModel @Inject constructor(
private val workManager = WorkManager
.getInstance(appContext)
init {
viewModelScope.launch {
settingsRepository.getSettingsFlow().collect { settings ->
_uiState.update {
it.copy(
improvement = settings.improvement
)
}
}
}
}
fun backupNow() {
_uiState.update {
it.copy(
@ -77,12 +65,4 @@ class OptionsScreenViewModel @Inject constructor(
}
}
}
fun setOptions(
improvement: Boolean? = true
) {
viewModelScope.launch {
settingsRepository.updateSettings(improvement = improvement)
}
}
}

View file

@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -30,6 +31,7 @@ import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.home.HomeActivity
import eu.m724.pojavbackup.ui.theme.PojavBackupTheme
@ -113,25 +115,25 @@ fun SetupScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Setup",
text = stringResource(R.string.header_setup),
modifier = Modifier.padding(vertical = 30.dp),
fontSize = 24.sp
)
GrantCard(
title = "Storage permission",
description = "It's needed to access your saves etc.",
title = stringResource(R.string.permission_storage),
description = stringResource(R.string.permission_storage_description),
granted = uiState.storagePermissionGranted,
onClick = onStoragePermissionGrantClick
)
GrantCard(
title = "Notification permission",
description = "It's needed to notify you about backup status.",
title = stringResource(R.string.permission_notification),
description = stringResource(R.string.permission_notification_description),
granted = uiState.notificationPermissionGranted || uiState.notificationPermissionRejected,
onClick = onNotificationPermissionGrantClick,
customButtonText = if (uiState.notificationPermissionRejected) "Rejected" else null
customButtonText = if (uiState.notificationPermissionRejected) stringResource(R.string.permission_rejected) else null
)
}
}
@ -178,9 +180,9 @@ fun GrantCard(
) {
if (customButtonText == null) {
if (granted) {
Text("Already granted")
Text(stringResource(R.string.permission_granted))
} else {
Text("Grant")
Text(stringResource(R.string.permission_grant))
}
} else {
Text(customButtonText)

View file

@ -20,7 +20,6 @@ message Settings {
repeated BackupDestination destinations = 3;
string sourceUri = 4;
bool improvement = 5;
}

View file

@ -1,6 +1,28 @@
<resources>
<string name="app_name">PojavBackup</string>
<string name="title_activity_setup">SetupActivity</string>
<string name="title_activity_home">HomeActivity</string>
<string name="title_activity_settings">SettingsActivity</string>
<string name="permission_notification">Notifications permission</string>
<string name="permission_notification_description">It\'s needed to notify you about backup status.</string>
<string name="permission_rejected">Rejected</string>
<string name="permission_storage_description">It\'s needed to access your saves etc.</string>
<string name="permission_storage">Storage permission</string>
<string name="header_setup">Setup</string>
<string name="permission_granted">Granted</string>
<string name="permission_grant">Grant</string>
<string name="settings_exit">Exit settings</string>
<string name="header_settings">Settings</string>
<string name="settings_options">Options</string>
<string name="settings_content">Content</string>
<string name="settings_destination">Destination</string>
<string name="button_backup_now">Backup now</string>
<string name="destination_none">No destinations</string>
<string name="destination_add">Add destination</string>
<string name="worlds_none">No worlds available</string>
<string name="worlds_separator">Worlds above this line will be backed up</string>
<string name="worlds_last_played">Last played: %1$s</string>
<string name="header_dashboard">Dashboard</string>
<string name="header_history">History</string>
<string name="dashboard_card_last_backup">Last backup</string>
<string name="dashboard_card_health">Health</string>
<string name="dashboard_card_worlds">Worlds</string>
<string name="dashboard_card_click">Go to %1$s</string>
</resources>