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

View file

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

View file

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

View file

@ -3,6 +3,6 @@ package eu.m724.pojavbackup.core.backup
import eu.m724.pojavbackup.core.backup.Backup.BackupStatus import eu.m724.pojavbackup.core.backup.Backup.BackupStatus
interface BackupRepository { interface BackupRepository {
suspend fun createBackup(): Backup fun createBackup(): Backup
suspend fun completeBackup(backup: Backup, status: BackupStatus = BackupStatus.SUCCESS): Backup fun completeBackup(backup: Backup, status: BackupStatus = BackupStatus.SUCCESS): Backup
} }

View file

@ -1,8 +1,8 @@
package eu.m724.pojavbackup.core.backup package eu.m724.pojavbackup.core.backup
import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.net.Uri
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
@ -15,10 +15,12 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import eu.m724.pojavbackup.R import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.core.backup.Backup.BackupStatus 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.GameDataRepository
import eu.m724.pojavbackup.core.data.World
import eu.m724.pojavbackup.core.datastore.SettingsRepository import eu.m724.pojavbackup.core.datastore.SettingsRepository
import eu.m724.pojavbackup.notification.NotificationChannels import eu.m724.pojavbackup.notification.NotificationChannels
import org.apache.commons.compress.compressors.CompressorStreamFactory
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.UUID import java.util.UUID
@ -32,106 +34,113 @@ class BackupWorker @AssistedInject constructor(
private val backupRepository: BackupRepository, private val backupRepository: BackupRepository,
private val gameDataRepository: GameDataRepository private val gameDataRepository: GameDataRepository
) : CoroutineWorker(appContext, workerParams) { ) : CoroutineWorker(appContext, workerParams) {
private val notificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as
NotificationManager
private val notificationId: Int = private val notificationId: Int =
Random.nextInt() Random.nextInt()
private val errors = mutableListOf<Throwable>()
private lateinit var backup: Backup
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val backup = backupRepository.createBackup()
try { try {
backup = backupRepository.createBackup() doBackup(backup)
updateStatus("Initializing") 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
backupRepository.completeBackup(backup, BackupStatus.FAILURE)
return Result.failure(statusData(backup.id, "Backup failed"))
}
}
private suspend fun doBackup(backup: Backup) {
val settings = settingsRepository.getSettings() val settings = settingsRepository.getSettings()
val worldIds = settingsRepository.getIncludedWorldIds(settings.worldOrder) val worldIds = settingsRepository.getIncludedWorldIds(settings.worldOrder)
// TODO multiple destinations support and remove those !! // TODO multiple destinations support
val backupUri = settings.destinationsList.first().uri.toUri() val destinationUri = settings.destinationsList.first().uri.toUri()
val backupDirectory = DocumentFile.fromTreeUri(applicationContext, backupUri)!! val backupDirectory = createBackupDirectory(backup.id, destinationUri)
.createDirectory(DateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.now()) + " (" + backup.id + ")")!!
val documentFileCompressor = DocumentFileCompressor(
openInputStream = {
applicationContext.contentResolver.openInputStream(it.uri)!!
}
)
worldIds.forEach { worldIds.forEach {
updateStatus("Backing up world $it") updateStatus(backup.id, it)
val world = runCatching { try {
gameDataRepository.getWorld(it) val world = gameDataRepository.getWorld(it)
doBackupWorld(world, documentFileCompressor, backupDirectory)
} catch (e: Throwable) {
// TODO maybe continue with other worlds
throw BackupOfWorldException(backup, e)
} }
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 private fun doBackupWorld(
val documentFile = backupDirectory.createFile("application/zstd", "$it.tar.$compression")!! world: World,
documentFileCompressor: DocumentFileCompressor,
backupDirectory: DocumentFile
) {
val documentFile = backupDirectory.createFile("application/zstd", "${world.id}.tar.gz")
applicationContext.contentResolver.openOutputStream(documentFile.uri)!!.use { if (documentFile == null) {
PathTools.compressDocumentFileDirectory( // TODO generic Exception?
contentResolver = applicationContext.contentResolver, throw Exception("Failed to create file: \"${world.id}.tar.gz\" in: ${backupDirectory.uri}")
source = world.getOrThrow().documentFile!!, }
target = it,
compression = compression, val outputStream = applicationContext.contentResolver.openOutputStream(documentFile.uri)
inflate = settings.improvement
if (outputStream == null) {
// TODO generic Exception?
throw Exception("Failed to open for writing: ${documentFile.uri}")
}
outputStream.use {
documentFileCompressor.compressToOutputStream(
source = world.documentFile!!,
target = it
) )
} }
} }
} catch (e: Exception) {
val e = Exception("Error backing up", e)
errors.add(e)
e.printStackTrace() 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")
} }
if (errors.isEmpty()) { return destination
backupRepository.completeBackup(backup, BackupStatus.SUCCESS)
return Result.success(statusData("Backup completed successfully"))
} else {
backupRepository.completeBackup(backup, BackupStatus.FAILURE)
// TODO notify and tell error
return Result.failure(statusData("Backup failed"))
}
} }
fun statusData(status: String): Data { private fun statusData(uuid: UUID, status: String): Data {
return Data.Builder() return Data.Builder()
.putUuid("uuid", backup.id) .putUuid("uuid", uuid)
.putString("status", status) .putString("status", status)
.build() .build()
} }
suspend fun updateStatus(status: String) { private suspend fun updateStatus(uuid: UUID, status: String) {
setForeground(createForegroundInfo(status)) setForeground(createForegroundInfo(status))
val data = statusData(status) val data = statusData(uuid, status)
setProgress(data) setProgress(data)
} }
fun finish(status: String, success: Boolean) { private fun Data.Builder.putUuid(key: String, value: UUID): Data.Builder {
val data = statusData(status)
if (success) {
Result.success(data)
} else {
Result.failure(data)
}
}
fun Data.Builder.putUuid(key: String, value: UUID): Data.Builder {
return this.putLongArray(key, longArrayOf(value.mostSignificantBits, value.leastSignificantBits)) 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() 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 */ /* Worlds */
suspend fun getIncludedWorldIds(): List<String> { 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -119,7 +120,7 @@ fun HomeScaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text("PojavBackup") Text(stringResource(R.string.app_name))
} }
) )
}, },
@ -127,13 +128,13 @@ fun HomeScaffold(
NavigationBar { NavigationBar {
ScreenNavigationBarItem( ScreenNavigationBarItem(
navController = navController, navController = navController,
label = "Dashboard", label = stringResource(R.string.header_dashboard),
route = HomeScreen.Dashboard, route = HomeScreen.Dashboard,
iconResourceId = R.drawable.baseline_home_24 iconResourceId = R.drawable.baseline_home_24
) )
ScreenNavigationBarItem( ScreenNavigationBarItem(
navController = navController, navController = navController,
label = "History", label = stringResource(R.string.header_history),
route = HomeScreen.History, route = HomeScreen.History,
iconResourceId = R.drawable.baseline_history_24 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -45,21 +46,21 @@ fun DashboardScreen(
maxItemsInEachRow = 3 maxItemsInEachRow = 3
) { ) {
DashboardCard( DashboardCard(
title = "Worlds included", title = stringResource(R.string.dashboard_card_worlds),
value = settings.worldOrder.separatorIndex, value = settings.worldOrder.separatorIndex,
iconResourceId = R.drawable.baseline_mosque_24, iconResourceId = R.drawable.baseline_mosque_24,
onClick = onWorldsIncludedClick onClick = onWorldsIncludedClick
) )
DashboardCard( DashboardCard(
title = "Health", title = stringResource(R.string.dashboard_card_health),
value = "Good", value = "Good", // TODO
iconResourceId = R.drawable.baseline_heart_broken_24 iconResourceId = R.drawable.baseline_heart_broken_24
) )
DashboardCard( DashboardCard(
title = "Last backup", title = stringResource(R.string.dashboard_card_last_backup),
value = "1d ago", value = "1d ago", // TODO
iconResourceId = R.drawable.baseline_access_time_filled_24, iconResourceId = R.drawable.baseline_access_time_filled_24,
onClick = { onClick = {
navController.navigate(HomeScreen.History) navController.navigate(HomeScreen.History)
@ -119,7 +120,7 @@ fun DashboardCard(
if (onClick != null) { if (onClick != null) {
Icon( Icon(
painter = painterResource(R.drawable.baseline_arrow_forward_ios_24), 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), modifier = Modifier.padding(end = 2.dp).size(12.dp),
tint = MaterialTheme.colorScheme.secondary tint = MaterialTheme.colorScheme.secondary
) )

View file

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

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@ -69,7 +70,7 @@ class SettingsActivity : ComponentActivity() {
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text("Settings") Text(stringResource(R.string.header_settings))
}, },
navigationIcon = { navigationIcon = {
IconButton( IconButton(
@ -79,7 +80,7 @@ class SettingsActivity : ComponentActivity() {
) { ) {
Icon( Icon(
painter = painterResource(R.drawable.baseline_arrow_back_24), 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 { NavigationBar {
ScreenNavigationBarItem( ScreenNavigationBarItem(
navController = navController, navController = navController,
label = "Options", label = stringResource(R.string.settings_options),
route = SettingsScreen.Options, route = SettingsScreen.Options,
iconResourceId = R.drawable.baseline_settings_24 iconResourceId = R.drawable.baseline_settings_24
) )
ScreenNavigationBarItem( ScreenNavigationBarItem(
navController = navController, navController = navController,
label = "Content", label = stringResource(R.string.settings_content),
route = SettingsScreen.Content, route = SettingsScreen.Content,
iconResourceId = R.drawable.baseline_folder_copy_24 iconResourceId = R.drawable.baseline_folder_copy_24
) )
ScreenNavigationBarItem( ScreenNavigationBarItem(
navController = navController, navController = navController,
label = "Destination", label = stringResource(R.string.settings_destination),
route = SettingsScreen.Destination, route = SettingsScreen.Destination,
iconResourceId = R.drawable.baseline_cloud_24 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.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign 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.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -62,7 +61,7 @@ fun ContentScreen(
} else { } else {
if (uiState.worlds.size <= 1) { // separator if (uiState.worlds.size <= 1) { // separator
Text( Text(
text = "No worlds available!", text = stringResource(R.string.worlds_none),
modifier = Modifier.padding(top = 50.dp) modifier = Modifier.padding(top = 50.dp)
) )
} else { } else {
@ -91,7 +90,7 @@ fun ContentScreen(
) )
} else { } else {
Text( Text(
text = "Worlds above this line will be backed up", text = "" + stringResource(R.string.worlds_separator) + "",
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
@ -110,19 +109,14 @@ fun ContentScreen(
* *
* @param modifier Optional Modifier for the Card. * @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 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 id The ID text to display.
* @param displayName The display name text. * @param displayName The display name text.
* @param lastPlayed The ZonedDateTime timestamp to display, formatted by locale. * @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 @Composable
fun WorldInfoCard( fun WorldInfoCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
bitmap: Bitmap?, bitmap: Bitmap?,
iconSize: Dp = 64.dp, // Control icon size here
id: String, id: String,
displayName: String, displayName: String,
lastPlayed: ZonedDateTime lastPlayed: ZonedDateTime
@ -160,7 +154,7 @@ fun WorldInfoCard(
painter = painter, // Use the determined painter painter = painter, // Use the determined painter
contentDescription = "world icon", // Hardcoded content description contentDescription = "world icon", // Hardcoded content description
modifier = Modifier modifier = Modifier
.size(iconSize) .size(64.dp)
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)
.clip(CardDefaults.shape), // TODO match corner radius .clip(CardDefaults.shape), // TODO match corner radius
contentScale = ContentScale.Crop // Crop is usually best for fixed aspect ratio contentScale = ContentScale.Crop // Crop is usually best for fixed aspect ratio
@ -178,7 +172,7 @@ fun WorldInfoCard(
) )
Text( Text(
text = "Last played $formattedTimestamp", // Use formatted timestamp text = stringResource(R.string.worlds_last_played, formattedTimestamp),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -186,18 +180,3 @@ fun WorldInfoCard(
} }
} }
} }
@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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -38,7 +39,9 @@ fun DestinationScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (destinations.isEmpty()) { if (destinations.isEmpty()) {
Text("There are no destinations.") Text(
text = stringResource(R.string.destination_none)
)
} else { } else {
LazyColumn( LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@ -74,7 +77,7 @@ fun DestinationScreen(
) )
Text( 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import eu.m724.pojavbackup.R
@Composable @Composable
fun OptionsScreen( fun OptionsScreen(
@ -22,17 +22,6 @@ fun OptionsScreen(
val viewModel: OptionsScreenViewModel = hiltViewModel() val viewModel: OptionsScreenViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle() 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( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@ -43,30 +32,12 @@ fun OptionsScreen(
viewModel.backupNow() viewModel.backupNow()
} }
) { ) {
Text("Backup now") Text(
text = stringResource(R.string.button_backup_now)
)
} }
Text(uiState.backupStatusHeader) Text(uiState.backupStatusHeader)
Text(uiState.backupStatusDetails) 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 package eu.m724.pojavbackup.settings.screen.options
data class OptionsScreenUiState( data class OptionsScreenUiState(
val improvement: Boolean = false,
val backupStatusHeader: String = "", val backupStatusHeader: String = "",
val backupStatusDetails: String = "" val backupStatusDetails: String = ""
) )

View file

@ -29,18 +29,6 @@ class OptionsScreenViewModel @Inject constructor(
private val workManager = WorkManager private val workManager = WorkManager
.getInstance(appContext) .getInstance(appContext)
init {
viewModelScope.launch {
settingsRepository.getSettingsFlow().collect { settings ->
_uiState.update {
it.copy(
improvement = settings.improvement
)
}
}
}
}
fun backupNow() { fun backupNow() {
_uiState.update { _uiState.update {
it.copy( 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.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -30,6 +31,7 @@ import androidx.compose.ui.unit.sp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import eu.m724.pojavbackup.R
import eu.m724.pojavbackup.home.HomeActivity import eu.m724.pojavbackup.home.HomeActivity
import eu.m724.pojavbackup.ui.theme.PojavBackupTheme import eu.m724.pojavbackup.ui.theme.PojavBackupTheme
@ -113,25 +115,25 @@ fun SetupScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Text(
text = "Setup", text = stringResource(R.string.header_setup),
modifier = Modifier.padding(vertical = 30.dp), modifier = Modifier.padding(vertical = 30.dp),
fontSize = 24.sp fontSize = 24.sp
) )
GrantCard( GrantCard(
title = "Storage permission", title = stringResource(R.string.permission_storage),
description = "It's needed to access your saves etc.", description = stringResource(R.string.permission_storage_description),
granted = uiState.storagePermissionGranted, granted = uiState.storagePermissionGranted,
onClick = onStoragePermissionGrantClick onClick = onStoragePermissionGrantClick
) )
GrantCard( GrantCard(
title = "Notification permission", title = stringResource(R.string.permission_notification),
description = "It's needed to notify you about backup status.", description = stringResource(R.string.permission_notification_description),
granted = uiState.notificationPermissionGranted || uiState.notificationPermissionRejected, granted = uiState.notificationPermissionGranted || uiState.notificationPermissionRejected,
onClick = onNotificationPermissionGrantClick, 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 (customButtonText == null) {
if (granted) { if (granted) {
Text("Already granted") Text(stringResource(R.string.permission_granted))
} else { } else {
Text("Grant") Text(stringResource(R.string.permission_grant))
} }
} else { } else {
Text(customButtonText) Text(customButtonText)

View file

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

View file

@ -1,6 +1,28 @@
<resources> <resources>
<string name="app_name">PojavBackup</string> <string name="app_name">PojavBackup</string>
<string name="title_activity_setup">SetupActivity</string> <string name="permission_notification">Notifications permission</string>
<string name="title_activity_home">HomeActivity</string> <string name="permission_notification_description">It\'s needed to notify you about backup status.</string>
<string name="title_activity_settings">SettingsActivity</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> </resources>