Coverage Summary for Class: BackupExporterImpl (com.georgeci.moneysurfer.data.backup)

Class Method, % Branch, % Line, % Instruction, %
BackupExporterImpl 100% (8/8) 100% (4/4) 98% (49/50) 97.3% (290/298)
BackupExporterImpl$checkpointMainDatabase$1
BackupExporterImpl$checkpointMainDatabase$2 100% (2/2) 100% (2/2) 100% (18/18)
BackupExporterImpl$exportTo$1
BackupExporterImpl$exportTo$2$1 100% (1/1) 100% (1/1) 100% (24/24)
BackupExporterImpl$runExport$1
Total 100% (11/11) 100% (4/4) 98.1% (52/53) 97.6% (332/340)


 package com.georgeci.moneysurfer.data.backup
 
 import androidx.room.useWriterConnection
 import com.georgeci.moneysurfer.data.backup.crypto.EncryptedBackupCodec
 import com.georgeci.moneysurfer.data.backup.zip.ZipStoredWriter
 import com.georgeci.moneysurfer.data.db.MONEY_SURFER_DB_VERSION
 import com.georgeci.moneysurfer.data.db.MoneySurferDatabase
 import com.georgeci.moneysurfer.domain.AppInfo
 import com.georgeci.moneysurfer.domain.backup.BackupEncryptionInfo
 import com.georgeci.moneysurfer.domain.backup.BackupError
 import com.georgeci.moneysurfer.domain.backup.BackupExporter
 import com.georgeci.moneysurfer.domain.backup.BackupManifest
 import com.georgeci.moneysurfer.domain.backup.BackupStorageLocator
 import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.IO
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
 import kotlinx.serialization.json.Json
 import okio.Buffer
 import okio.BufferedSink
 import okio.FileSystem
 import okio.SYSTEM
 import okio.buffer
 import okio.use
 import org.koin.core.annotation.Single
 
 @Single(binds = [BackupExporter::class])
 class BackupExporterImpl(
     private val database: MoneySurferDatabase,
     private val locator: BackupStorageLocator,
     private val appInfo: AppInfo,
     private val clock: ClockUseCase,
 ) : BackupExporter {
 
     private val mutex = Mutex()
     private val fs: FileSystem = FileSystem.SYSTEM
 
     override suspend fun exportTo(sink: BufferedSink, passphrase: String?): Result<BackupManifest> =
         mutex.withLock {
             try {
                 Result.success(withContext(Dispatchers.IO) { runExport(sink, passphrase) })
             } catch (ce: CancellationException) {
                 throw ce
             } catch (be: BackupError) {
                 Result.failure(be)
             } catch (
                 @Suppress("TooGenericExceptionCaught") t: Throwable,
             ) {
                 Result.failure(BackupError.Io(t))
             }
         }
 
     private suspend fun runExport(sink: BufferedSink, passphrase: String?): BackupManifest {
         checkpointMainDatabase()
         return if (passphrase.isNullOrEmpty()) {
             writePlainArchive(sink, buildManifest())
         } else {
             writeEncryptedArchive(sink, passphrase)
         }
     }
 
     private fun writePlainArchive(sink: BufferedSink, manifest: BackupManifest): BackupManifest {
         val zip = ZipStoredWriter(sink)
         zip.writeEntry(BackupManifest.MANIFEST_ENTRY_NAME, manifestSource(manifest))
         fs.source(locator.moneySurferDbFile()).buffer().use {
             zip.writeEntry(BackupManifest.MAIN_DB_ENTRY_NAME, it)
         }
         fs.source(locator.dataStoreFile()).buffer().use {
             zip.writeEntry(BackupManifest.DATASTORE_ENTRY_NAME, it)
         }
         zip.finish()
         return manifest
     }
 
     /**
      * The outer container stays a valid plain ZIP (same `.zip` extension and
      * MIME type): a readable manifest that flags the encryption scheme, plus a
      * single sealed `payload.enc` entry. Inside the sealed blob sits a full
      * plain backup archive — including its own manifest, which is the
      * authenticated copy the importer trusts after decryption.
      */
     private fun writeEncryptedArchive(sink: BufferedSink, passphrase: String): BackupManifest {
         val innerZip = Buffer()
         val innerManifest = writePlainArchive(innerZip, buildManifest())
         val sealed = EncryptedBackupCodec.seal(innerZip.readByteArray(), passphrase)
 
         val outerManifest = innerManifest.copy(
             files = listOf(BackupManifest.ENCRYPTED_PAYLOAD_ENTRY_NAME),
             encryption = BackupEncryptionInfo(BackupEncryptionInfo.SCHEME_V1),
         )
         val zip = ZipStoredWriter(sink)
         zip.writeEntry(BackupManifest.MANIFEST_ENTRY_NAME, manifestSource(outerManifest))
         zip.writeEntry(BackupManifest.ENCRYPTED_PAYLOAD_ENTRY_NAME, Buffer().apply { write(sealed) })
         zip.finish()
         return outerManifest
     }
 
     private suspend fun checkpointMainDatabase() {
         // wal_checkpoint(TRUNCATE) needs exclusive access to truncate the WAL,
         // so we run it through the writer connection (which serialises against
         // ongoing reads / writes) rather than a reader.
         database.useWriterConnection { connection ->
             connection.usePrepared(PRAGMA_WAL_CHECKPOINT) { statement ->
                 statement.step()
             }
         }
     }
 
     private fun buildManifest(): BackupManifest = BackupManifest(
         backupFormatVersion = BackupManifest.CURRENT_FORMAT_VERSION,
         moneySurferDbVersion = MONEY_SURFER_DB_VERSION,
         appVersion = appInfo.version,
         appVersionCode = appInfo.versionCode,
         sourcePlatform = locator.platformName,
         createdAtEpochMillis = clock.now().toEpochMilliseconds(),
         files = listOf(
             BackupManifest.MAIN_DB_ENTRY_NAME,
             BackupManifest.DATASTORE_ENTRY_NAME,
         ),
     )
 
     private fun manifestSource(manifest: BackupManifest): Buffer {
         val json = Json.encodeToString(BackupManifest.serializer(), manifest)
         return Buffer().apply { writeUtf8(json) }
     }
 }
 
 private const val PRAGMA_WAL_CHECKPOINT = "PRAGMA wal_checkpoint(TRUNCATE)"