Refactor
This commit is contained in:
parent
ed17587bf8
commit
392c0efc83
23 changed files with 240 additions and 645 deletions
|
@ -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" />
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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) {
|
||||
}
|
|
@ -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)
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
package eu.m724.pojavbackup.settings.screen.options
|
||||
|
||||
data class OptionsScreenUiState(
|
||||
val improvement: Boolean = false,
|
||||
val backupStatusHeader: String = "",
|
||||
val backupStatusDetails: String = ""
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -20,7 +20,6 @@ message Settings {
|
|||
repeated BackupDestination destinations = 3;
|
||||
|
||||
string sourceUri = 4;
|
||||
bool improvement = 5;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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>
|
Loading…
Add table
Add a link
Reference in a new issue