Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions core/util/src/main/kotlin/de/davis/keygo/core/util/Combine.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package de.davis.keygo.core.util

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine as combineArray

fun <T1, T2, T3, T4, T5, T6, R> combine(
flow1: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combineArray(flow1, flow2, flow3, flow4, flow5, flow6) { values ->
@Suppress("UNCHECKED_CAST")
transform(
values[0] as T1,
values[1] as T2,
values[2] as T3,
values[3] as T4,
values[4] as T5,
values[5] as T6,
)
}

fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow1: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
transform: (T1, T2, T3, T4, T5, T6, T7) -> R,
): Flow<R> = combineArray(flow1, flow2, flow3, flow4, flow5, flow6, flow7) { values ->
@Suppress("UNCHECKED_CAST")
transform(
values[0] as T1,
values[1] as T2,
values[2] as T3,
values[3] as T4,
values[4] as T5,
values[5] as T6,
values[6] as T7,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator
import de.davis.keygo.core.item.domain.model.PasswordScore
import de.davis.keygo.core.util.combine
import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage
import de.davis.keygo.core.util.domain.snackbar.SnackbarManager
import de.davis.keygo.core.util.onFailure
Expand Down Expand Up @@ -33,13 +35,10 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
Expand All @@ -62,71 +61,66 @@ internal class ExportWizardViewModel(
private val _formatState = MutableStateFlow(SelectFormatState())
private val _scheduleState = MutableStateFlow(SelectScheduleState())
private val _destinationState = MutableStateFlow(SelectDestinationState())
private val _providePassphraseState = MutableStateFlow(
private val _step = MutableStateFlow(ExportWizardStep.SelectFormat)

private val _providePassphraseBaseState = MutableStateFlow(
ProvidePassphraseState(
passphraseTextFieldState = passphraseTextFieldState,
confirmPassphraseTextFieldState = confirmPassphraseTextFieldState,
)
),
)

private val _step = MutableStateFlow(ExportWizardStep.SelectFormat)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private val _passphraseMetricsFlow = snapshotFlow {
val passphrase = passphraseTextFieldState.text
val valid =
passphrase.isNotEmpty() && passphrase.contentEquals(confirmPassphraseTextFieldState.text)
passphrase to valid
}
.debounce(150.milliseconds)
.distinctUntilChanged()
.mapLatest { (pwd, valid) ->
val score = passwordStrengthEstimator(pwd.toString())
score to valid
}
.flowOn(Dispatchers.Default)
// combine withholds its first emission until every input has emitted, so without a value
// up front the whole wizard would sit on its initialValue until the debounce elapses.
.onStart { emit(PasswordScore.None to false) }

val state = combine(
_formatState,
_scheduleState,
_destinationState,
_providePassphraseState,
_step
) { formatState, scheduleState, destinationState, providePassphraseState, step ->
_providePassphraseBaseState,
_passphraseMetricsFlow,
_step,
) { formatState, scheduleState, destinationState, basePassphraseState, (score, valid), step ->
ExportWizardUiState(
formatState = formatState,
scheduleState = scheduleState,
destinationState = destinationState,
providePassphraseState = providePassphraseState,
providePassphraseState = basePassphraseState.copy(
passphraseScore = score,
valid = valid,
),
step = step,
)
}
.onStart {
observePassphrase()
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ExportWizardUiState(
formatState = _formatState.value,
scheduleState = _scheduleState.value,
destinationState = _destinationState.value,
providePassphraseState = _providePassphraseState.value,
step = _step.value,
)
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ExportWizardUiState(
formatState = _formatState.value,
scheduleState = _scheduleState.value,
destinationState = _destinationState.value,
providePassphraseState = _providePassphraseBaseState.value,
step = _step.value,
),
)

private val _event = Channel<ExportWizardEvent>(Channel.BUFFERED)
val event = _event.receiveAsFlow()

@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private fun observePassphrase() {
snapshotFlow {
val passphrase = passphraseTextFieldState.text
val valid =
passphrase.isNotEmpty() && passphrase.contentEquals(confirmPassphraseTextFieldState.text)
passphrase to valid
}
.debounce(150.milliseconds)
.distinctUntilChanged()
.mapLatest { (pwd, valid) -> passwordStrengthEstimator(pwd.toString()) to valid }
.onEach { (score, valid) ->
_providePassphraseState.update {
it.copy(
passphraseScore = score,
valid = valid
)
}
}
.flowOn(Dispatchers.Default)
.launchIn(viewModelScope)
}

fun onEvent(event: ExportWizardUiEvent) {
when (event) {
ExportWizardUiEvent.Back -> previousStep()
Expand Down Expand Up @@ -173,7 +167,7 @@ internal class ExportWizardViewModel(
it.copy(keepAll = event.keepAll)
}

is ExportWizardUiEvent.EncryptionMethodSelected -> _providePassphraseState.update {
is ExportWizardUiEvent.EncryptionMethodSelected -> _providePassphraseBaseState.update {
it.copy(method = event.method)
}

Expand Down Expand Up @@ -231,7 +225,7 @@ internal class ExportWizardViewModel(
interval = if (recurring) schedule.interval else null,
keepCount = if (recurring && !schedule.keepAll) schedule.keepCount else null,
passphrase = passphraseTextFieldState.text.toString(),
encryption = if (format.encrypted) _providePassphraseState.value.method else null,
encryption = if (format.encrypted) _providePassphraseBaseState.value.method else null,
csvPreset = if (format == FileFormat.CSV) _formatState.value.csvPreset else null,
)
}
Expand All @@ -245,4 +239,4 @@ internal class ExportWizardViewModel(
val steps = exportStepsFor(_formatState.value.format)
steps[(steps.indexOf(current) - 1).coerceAtLeast(0)]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ import de.davisalessandro.keygo.rust.ColumnMapping
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
Expand All @@ -44,20 +44,9 @@ internal class ImportWizardViewModel(
private val backupDestinationResolver: BackupDestinationResolver,
private val importBackup: ImportBackupUseCase,
private val analyzeCsv: AnalyzeCsvUseCase,
private val observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase,
observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase,
) : ViewModel() {

private val passphraseState = TextFieldState()
private val newVaultNameState = TextFieldState()

private val _state = MutableStateFlow(
ImportWizardUiState(
passphraseState = passphraseState,
newVaultNameState = newVaultNameState,
),
)
val state = _state.asStateFlow()

private val _event = Channel<ImportWizardEvent>(Channel.BUFFERED)
val event = _event.receiveAsFlow()

Expand All @@ -67,23 +56,36 @@ internal class ImportWizardViewModel(
private var vaultStepSeeded = false
private var seededUri: BackupDestinationUri? = null

init {
snapshotFlow { passphraseState.text.toString() }
.onEach { text -> _state.update { it.copy(passphraseValid = text.isNotBlank()) } }
.launchIn(viewModelScope)

snapshotFlow { newVaultNameState.text.toString() }
.onEach { text -> _state.update { it.copy(newVaultNameValid = text.isNotBlank()) } }
.launchIn(viewModelScope)
private val passphraseState = TextFieldState()
private val newVaultNameState = TextFieldState()

private val _state = MutableStateFlow(
ImportWizardUiState(
passphraseState = passphraseState,
newVaultNameState = newVaultNameState,
),
)

val state = combine(
_state,
snapshotFlow { passphraseState.text.toString() },
snapshotFlow { newVaultNameState.text.toString() },
observeVaultsAndSelection()
.onEach { (vaults, selection) ->
_state.update {
it.copy(vaults = vaults, contextVaultId = selection.getIdOrNull())
}
}
.launchIn(viewModelScope)
}
) { baseState, passphrase, vaultName, vaultData ->
val (vaults, selection) = vaultData

baseState.copy(
passphraseValid = passphrase.isNotBlank(),
newVaultNameValid = vaultName.isNotBlank(),
vaults = vaults,
contextVaultId = selection.getIdOrNull()
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = _state.value
)

fun onEvent(event: ImportWizardUiEvent) {
when (event) {
Expand Down Expand Up @@ -182,7 +184,11 @@ internal class ImportWizardViewModel(
analysisJob = viewModelScope.launch {
analyzeCsv(uri).fold(
onSuccess = ::onAnalyzed,
onFailure = { error -> _state.update { it.copy(progress = ImportProgress.Failed(error)) } },
onFailure = { error ->
_state.update {
it.copy(progress = ImportProgress.Failed(error))
}
},
)
}
}
Expand Down Expand Up @@ -220,7 +226,10 @@ internal class ImportWizardViewModel(
}
vaultStepSeeded = true

val current = _state.value
// state, not _state: the vault list and the vault context are folded in by the flow above
// and never written back, so _state has neither. This runs from a Continue tap, so the
// screen is collecting and the values are present.
val current = state.value
val contextVault = current.contextVaultId
?.takeIf { id -> current.vaults.any { it.vaultId == id } }

Expand All @@ -242,7 +251,8 @@ internal class ImportWizardViewModel(

startImport(
passphrase = null,
csvMapping = current.columns.associate { it.index to it.selectedType }.toColumnMapping(),
csvMapping = current.columns.associate { it.index to it.selectedType }
.toColumnMapping(),
target = target,
)
}
Expand Down
Loading
Loading