Coverage Summary for Class: BackupEffect (com.georgeci.moneysurfer.feature.settings.backup)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| BackupEffect$NavigateBack |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/2)
|
| BackupEffect$Notify |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/7)
|
| BackupEffect$RequestOpenFile |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(2/2)
|
| BackupEffect$RequestSaveFile |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(7/7)
|
| BackupEffect$RestartApp |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/2)
|
| Total |
40%
(2/5)
|
|
40%
(2/5)
|
45%
(9/20)
|
package com.georgeci.moneysurfer.feature.settings.backup
import com.georgeci.moneysurfer.domain.backup.BackupError
import com.georgeci.moneysurfer.domain.backup.BackupExporter
import com.georgeci.moneysurfer.domain.backup.BackupImporter
import com.georgeci.moneysurfer.domain.config.HostCapabilities
import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
import com.georgeci.moneysurfer.utils.MviViewModel
import kotlinx.coroutines.CancellationException
import okio.BufferedSink
import okio.BufferedSource
import okio.use
import org.koin.core.annotation.KoinViewModel
@KoinViewModel
class BackupViewModel(
private val exporter: BackupExporter,
private val importer: BackupImporter,
private val clock: ClockUseCase,
hostCapabilities: HostCapabilities,
) : MviViewModel<BackupState, BackupEvent, BackupEffect>(
initialState = BackupState(isOffline = hostCapabilities.isOffline),
) {
/** Passphrase chosen in the export dialog, held until the save picker returns a sink. */
private var pendingExportPassphrase: String? = null
/** Picked source of an encrypted backup, held while the passphrase prompt is up. */
private var pendingImportSource: BufferedSource? = null
override fun onEvent(event: BackupEvent) {
when (event) {
BackupEvent.OnBackClick -> postSideEffect(BackupEffect.NavigateBack)
BackupEvent.OnDownloadClick,
BackupEvent.OnExportOptionsDismissed,
is BackupEvent.OnExportOptionsConfirmed,
is BackupEvent.OnSaveSinkChosen,
-> onExportEvent(event)
is BackupEvent.OnOpenSourceChosen,
BackupEvent.OnImportPassphraseDismissed,
is BackupEvent.OnImportPassphraseSubmitted,
-> onImportEvent(event)
BackupEvent.OnRestoreClick,
BackupEvent.OnRestoreDismissed,
BackupEvent.OnRestoreConfirmed,
-> onRestoreEvent(event)
}
}
override fun onCleared() {
clearPendingImportSource()
super.onCleared()
}
private fun onExportEvent(event: BackupEvent) {
when (event) {
BackupEvent.OnDownloadClick ->
updateState { copy(showExportOptions = true) }
BackupEvent.OnExportOptionsDismissed -> {
pendingExportPassphrase = null
updateState { copy(showExportOptions = false) }
}
is BackupEvent.OnExportOptionsConfirmed -> {
pendingExportPassphrase = event.passphrase?.takeIf { it.isNotEmpty() }
updateState { copy(showExportOptions = false) }
postSideEffect(BackupEffect.RequestSaveFile(suggestedFilename()))
}
is BackupEvent.OnSaveSinkChosen -> handleSaveSink(event.sink)
else -> Unit
}
}
private fun onImportEvent(event: BackupEvent) {
when (event) {
is BackupEvent.OnOpenSourceChosen -> handleOpenSource(event.source)
BackupEvent.OnImportPassphraseDismissed -> {
clearPendingImportSource()
updateState { copy(showImportPassphrase = false) }
}
is BackupEvent.OnImportPassphraseSubmitted -> {
val source = pendingImportSource
pendingImportSource = null
updateState { copy(showImportPassphrase = false) }
if (source != null) runImport(source, event.passphrase)
}
else -> Unit
}
}
private fun onRestoreEvent(event: BackupEvent) {
when (event) {
BackupEvent.OnRestoreClick ->
updateState { copy(showRestoreConfirmation = true) }
BackupEvent.OnRestoreDismissed ->
updateState { copy(showRestoreConfirmation = false) }
BackupEvent.OnRestoreConfirmed -> {
updateState { copy(showRestoreConfirmation = false) }
postSideEffect(BackupEffect.RequestOpenFile)
}
else -> Unit
}
}
private fun handleSaveSink(sink: BufferedSink?) {
val passphrase = pendingExportPassphrase
pendingExportPassphrase = null
if (sink == null) return
updateState { copy(phase = BackupPhase.Exporting) }
launch(
onError = { error ->
// MviViewModel.launch catches Exception (including cancellation);
// rethrow so a cleared ViewModel doesn't surface as a fake error.
if (error is CancellationException) throw error
updateState { copy(phase = BackupPhase.Idle) }
postSideEffect(BackupEffect.Notify(BackupNotice.fromError(error)))
runCatching { sink.close() }
},
) {
sink.use { exporter.exportTo(it, passphrase).getOrThrow() }
updateState { copy(phase = BackupPhase.Idle) }
postSideEffect(BackupEffect.Notify(BackupNotice.ExportSuccess))
}
}
private fun handleOpenSource(source: BufferedSource?) {
if (source == null) return
launch(
onError = { error ->
if (error is CancellationException) throw error
postSideEffect(BackupEffect.Notify(BackupNotice.fromError(error)))
runCatching { source.close() }
},
) {
if (importer.isPassphraseProtected(source)) {
pendingImportSource = source
updateState { copy(showImportPassphrase = true) }
} else {
runImport(source, passphrase = null)
}
}
}
private fun runImport(source: BufferedSource, passphrase: String?) {
updateState { copy(phase = BackupPhase.Importing) }
launch(
onError = { error ->
if (error is CancellationException) throw error
updateState { copy(phase = BackupPhase.Idle) }
postSideEffect(BackupEffect.Notify(BackupNotice.fromError(error)))
runCatching { source.close() }
},
) {
source.use { importer.importFrom(it, passphrase).getOrThrow() }
postSideEffect(BackupEffect.RestartApp)
}
}
private fun clearPendingImportSource() {
pendingImportSource?.let { source -> runCatching { source.close() } }
pendingImportSource = null
}
private fun suggestedFilename(): String {
val now = clock.now().toEpochMilliseconds()
return "moneysurfer-backup-$now.zip"
}
}
/**
* State of the Backup screen.
*
* The screen is deliberately local-only: scheduled cloud backups, "Back up now"
* and "Delete cloud backup" used to render in the online build but had no
* backend behind them, so every tap — including a destructive confirmation —
* did nothing. They are gone until a real remote exists; what is left is the
* export/restore pair, which [BackupExporter] and [BackupImporter] serve
* entirely from the on-device database and which works in both build variants.
*
* [isOffline] therefore only picks the hero copy: the offline build can promise
* that nothing ever leaves the device, the online build cannot.
*/
data class BackupState(
val showRestoreConfirmation: Boolean = false,
val showExportOptions: Boolean = false,
val showImportPassphrase: Boolean = false,
val phase: BackupPhase = BackupPhase.Idle,
val isOffline: Boolean = false,
)
enum class BackupPhase { Idle, Exporting, Importing }
/**
* User-facing notice raised by the backup flow. Resolution to localised
* strings happens in the screen layer via Compose resources — keeping the
* ViewModel free of platform string APIs.
*/
sealed interface BackupNotice {
data object ExportSuccess : BackupNotice
data object Cancelled : BackupNotice
data class SchemaMismatch(val expected: Int, val actual: Int) : BackupNotice
data class FormatMismatch(val expected: Int, val actual: Int) : BackupNotice
data object InvalidArchive : BackupNotice
data class MissingFile(val name: String) : BackupNotice
data object Corrupted : BackupNotice
data object PassphraseRequired : BackupNotice
data object WrongPassphrase : BackupNotice
data object Generic : BackupNotice
companion object {
fun fromError(error: Throwable): BackupNotice = when (error) {
is BackupError.PickerCancelled -> Cancelled
is BackupError.SchemaMismatch -> SchemaMismatch(error.expected, error.actual)
is BackupError.FormatMismatch -> FormatMismatch(error.expected, error.actual)
is BackupError.InvalidArchive -> InvalidArchive
is BackupError.MissingFile -> MissingFile(error.name)
is BackupError.CrcMismatch -> Corrupted
is BackupError.CorruptDatabase -> Corrupted
is BackupError.PassphraseRequired -> PassphraseRequired
is BackupError.WrongPassphrase -> WrongPassphrase
else -> Generic
}
}
}
sealed interface BackupEvent {
data object OnBackClick : BackupEvent
data object OnDownloadClick : BackupEvent
data object OnExportOptionsDismissed : BackupEvent
data class OnExportOptionsConfirmed(val passphrase: String?) : BackupEvent
data class OnSaveSinkChosen(val sink: BufferedSink?) : BackupEvent
data object OnRestoreClick : BackupEvent
data object OnRestoreConfirmed : BackupEvent
data object OnRestoreDismissed : BackupEvent
data class OnOpenSourceChosen(val source: BufferedSource?) : BackupEvent
data object OnImportPassphraseDismissed : BackupEvent
data class OnImportPassphraseSubmitted(val passphrase: String) : BackupEvent
}
sealed interface BackupEffect {
data object NavigateBack : BackupEffect
data class RequestSaveFile(val suggestedName: String) : BackupEffect
data object RequestOpenFile : BackupEffect
data object RestartApp : BackupEffect
data class Notify(val notice: BackupNotice) : BackupEffect
}