Coverage Summary for Class: ZipStoredWriter (com.georgeci.moneysurfer.data.backup.zip)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| ZipStoredWriter |
100%
(7/7)
|
70%
(7/10)
|
98.7%
(77/78)
|
97.5%
(398/408)
|
| ZipStoredWriter$CentralDirEntry |
100%
(1/1)
|
|
100%
(5/5)
|
100%
(22/22)
|
| Total |
100%
(8/8)
|
70%
(7/10)
|
98.8%
(82/83)
|
97.7%
(420/430)
|
package com.georgeci.moneysurfer.data.backup.zip
import com.georgeci.moneysurfer.domain.backup.BackupError
import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource
/**
* Minimal ZIP encoder using only the STORED (no compression) method.
*
* SQLite databases and DataStore preference files do not compress meaningfully,
* so STORED is the right trade-off — and it lets us keep the implementation
* tiny and KMP-portable (no `java.util.zip` on iOS).
*
* Each entry is fully buffered in memory before its local-file-header is
* written (so size + CRC are known up front and we avoid data-descriptors).
* Files in this app are bounded — a personal-finance database is rarely above
* tens of MB — so this is acceptable.
*/
internal class ZipStoredWriter(private val sink: BufferedSink) {
private val entries: MutableList<CentralDirEntry> = mutableListOf()
private var bytesWritten: Long = 0
private var closed: Boolean = false
fun writeEntry(name: String, data: BufferedSource) {
check(!closed) { "ZIP archive already finished" }
val payload = Buffer()
val crc = Crc32()
val readBuffer = ByteArray(COPY_BUFFER_SIZE)
while (true) {
val n = data.read(readBuffer)
if (n == -1) break
crc.update(readBuffer, length = n)
payload.write(readBuffer, 0, n)
}
val nameBytes = name.encodeToByteArray()
val crcValue = crc.getValue()
val size = payload.size
// Plain ZIP (no Zip64) header fields are 32-bit. Symmetry with the
// reader: reject before we silently truncate via `toInt()`.
require32Bit(size, "Entry size for $name")
require32Bit(bytesWritten, "Local-header offset for $name")
val localHeaderOffset = bytesWritten
bytesWritten += writeLocalFileHeader(nameBytes, crcValue, size)
sink.writeAll(payload)
bytesWritten += size
entries += CentralDirEntry(
nameBytes = nameBytes,
crc = crcValue,
size = size,
localHeaderOffset = localHeaderOffset,
)
}
fun finish() {
check(!closed) { "ZIP archive already finished" }
closed = true
val centralDirOffset = bytesWritten
require32Bit(centralDirOffset, "Central-directory offset")
var centralDirSize = 0L
for (entry in entries) {
centralDirSize += writeCentralDirHeader(entry)
}
bytesWritten += centralDirSize
require32Bit(centralDirSize, "Central-directory size")
writeEndOfCentralDir(centralDirOffset, centralDirSize)
sink.flush()
}
private fun require32Bit(value: Long, what: String) {
if (value > Int.MAX_VALUE.toLong()) {
throw BackupError.InvalidArchive("$what exceeds 32-bit ZIP limit: $value bytes")
}
}
private fun writeLocalFileHeader(nameBytes: ByteArray, crc: Long, size: Long): Long {
sink.writeIntLe(LOCAL_FILE_HEADER_SIGNATURE)
sink.writeShortLe(VERSION_NEEDED_TO_EXTRACT)
sink.writeShortLe(GENERAL_PURPOSE_BIT_FLAG)
sink.writeShortLe(COMPRESSION_METHOD_STORED)
sink.writeShortLe(MS_DOS_TIME)
sink.writeShortLe(MS_DOS_DATE)
sink.writeIntLe(crc.toInt())
sink.writeIntLe(size.toInt())
sink.writeIntLe(size.toInt())
sink.writeShortLe(nameBytes.size)
sink.writeShortLe(0)
sink.write(nameBytes)
return LOCAL_FILE_HEADER_FIXED_SIZE + nameBytes.size.toLong()
}
private fun writeCentralDirHeader(entry: CentralDirEntry): Long {
sink.writeIntLe(CENTRAL_DIR_HEADER_SIGNATURE)
sink.writeShortLe(VERSION_MADE_BY)
sink.writeShortLe(VERSION_NEEDED_TO_EXTRACT)
sink.writeShortLe(GENERAL_PURPOSE_BIT_FLAG)
sink.writeShortLe(COMPRESSION_METHOD_STORED)
sink.writeShortLe(MS_DOS_TIME)
sink.writeShortLe(MS_DOS_DATE)
sink.writeIntLe(entry.crc.toInt())
sink.writeIntLe(entry.size.toInt())
sink.writeIntLe(entry.size.toInt())
sink.writeShortLe(entry.nameBytes.size)
sink.writeShortLe(0)
sink.writeShortLe(0)
sink.writeShortLe(0)
sink.writeShortLe(0)
sink.writeIntLe(0)
sink.writeIntLe(entry.localHeaderOffset.toInt())
sink.write(entry.nameBytes)
return CENTRAL_DIR_HEADER_FIXED_SIZE + entry.nameBytes.size.toLong()
}
private fun writeEndOfCentralDir(centralDirOffset: Long, centralDirSize: Long) {
sink.writeIntLe(END_OF_CENTRAL_DIR_SIGNATURE)
sink.writeShortLe(0)
sink.writeShortLe(0)
sink.writeShortLe(entries.size)
sink.writeShortLe(entries.size)
sink.writeIntLe(centralDirSize.toInt())
sink.writeIntLe(centralDirOffset.toInt())
sink.writeShortLe(0)
}
private data class CentralDirEntry(
val nameBytes: ByteArray,
val crc: Long,
val size: Long,
val localHeaderOffset: Long,
)
}
@Suppress("MagicNumber")
internal const val LOCAL_FILE_HEADER_SIGNATURE: Int = 0x04034b50
@Suppress("MagicNumber")
internal const val CENTRAL_DIR_HEADER_SIGNATURE: Int = 0x02014b50
@Suppress("MagicNumber")
internal const val END_OF_CENTRAL_DIR_SIGNATURE: Int = 0x06054b50
internal const val VERSION_NEEDED_TO_EXTRACT: Int = 20
internal const val VERSION_MADE_BY: Int = 20
internal const val GENERAL_PURPOSE_BIT_FLAG: Int = 0
internal const val COMPRESSION_METHOD_STORED: Int = 0
internal const val MS_DOS_TIME: Int = 0
internal const val MS_DOS_DATE: Int = 0
internal const val LOCAL_FILE_HEADER_FIXED_SIZE: Long = 30
internal const val CENTRAL_DIR_HEADER_FIXED_SIZE: Long = 46
private const val COPY_BUFFER_SIZE: Int = 64 * 1024