diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt index 4dd9133..8cf7f47 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -48,10 +48,6 @@ fun LoginRoute( ) { LaunchedEffect(profile.id) { viewModel.loadProfile(profile) } val state = viewModel.uiState.collectAsStateWithLifecycle().value - val switchProvider = { - viewModel.clearSensitiveState() - onSwitchProvider() - } LoginScreen( profile = profile, state = state.copy( @@ -62,7 +58,7 @@ fun LoginRoute( onRememberUsernameChange = viewModel::setRememberUsername, onAutoLoginChange = viewModel::setAutoLogin, onSubmit = viewModel::submit, - onSwitchProvider = switchProvider, + onSwitchProvider = onSwitchProvider, switchingProvider = actionInProgress, ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt index 43f1b07..ab21e02 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -32,8 +32,7 @@ class LoginViewModel @Inject constructor( private var autoLoginEdited = false fun loadProfile(value: ProviderProfile) { - val profileChanged = profile?.id != value.id - if (!profileChanged && loadingJob != null) return + if (profile?.id == value.id && loadingJob?.isActive == true) return loadingJob?.cancel() profile = value usernameEdited = false @@ -41,26 +40,30 @@ class LoginViewModel @Inject constructor( autoLoginEdited = false _uiState.value = LoginUiState(isLoadingPreferences = true) loadingJob = viewModelScope.launch { - val preferences = profileStore.loginPreferences(value.id) - if (profile?.id != value.id) return@launch - _uiState.value = _uiState.value.copy( - username = if (usernameEdited) { - _uiState.value.username - } else { - preferences.rememberedUsername?.value.orEmpty() - }, - rememberUsername = if (rememberUsernameEdited) { - _uiState.value.rememberUsername - } else { - preferences.rememberUsername - }, - autoLogin = if (autoLoginEdited) { - _uiState.value.autoLogin - } else { - preferences.autoLoginEnabled - }, - isLoadingPreferences = false, - ) + try { + val preferences = profileStore.loginPreferences(value.id) + if (profile?.id != value.id) return@launch + _uiState.value = _uiState.value.copy( + username = if (usernameEdited) { + _uiState.value.username + } else { + preferences.rememberedUsername?.value.orEmpty() + }, + rememberUsername = if (rememberUsernameEdited) { + _uiState.value.rememberUsername + } else { + preferences.rememberUsername + }, + autoLogin = if (autoLoginEdited) { + _uiState.value.autoLogin + } else { + preferences.autoLoginEnabled + }, + isLoadingPreferences = false, + ) + } finally { + loadingJob = null + } } } diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt index 2ddadcc..c326082 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import com.flagship.abox.manager.domain.DomainResult @@ -13,19 +14,34 @@ import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.Username +import java.io.IOException +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.retryWhen class ProviderProfileStoreImpl( private val dataStore: DataStore, ) : ProviderProfileStore { - override fun observeProfiles(): Flow> = dataStore.data.map(::profilesFrom) + private val safeData: Flow + get() = dataStore.data + .retryWhen { cause, attempt -> + val retry = cause is IOException && attempt < MAX_READ_RETRIES + if (retry) delay(READ_RETRY_DELAY_MILLIS) + retry + } + .catch { error -> + if (error is IOException) emit(emptyPreferences()) else throw error + } - override fun observeActiveProfile(): Flow = dataStore.data.map(::activeProfileFrom) + override fun observeProfiles(): Flow> = safeData.map(::profilesFrom) + + override fun observeActiveProfile(): Flow = safeData.map(::activeProfileFrom) override fun observeLoginPreferences(profileId: ProviderProfileId): Flow = - dataStore.data.map { preferencesFrom(it, profileId) } + safeData.map { preferencesFrom(it, profileId) } override suspend fun profiles(): List = profilesFrom(dataStore.data.first()) @@ -99,6 +115,8 @@ class ProviderProfileStoreImpl( } companion object { + private const val MAX_READ_RETRIES = 2L + private const val READ_RETRY_DELAY_MILLIS = 50L private val PROFILE_IDS = stringSetPreferencesKey("profile_ids") private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id") diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt index 19d9c14..d656d83 100644 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt @@ -2,11 +2,14 @@ package com.flagship.abox.manager.session.profile import android.content.Context import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStore private const val SESSION_DATA_STORE_NAME = "provider_session" val Context.sessionDataStore: DataStore by preferencesDataStore( name = SESSION_DATA_STORE_NAME, + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, ) diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt new file mode 100644 index 0000000..011ca53 --- /dev/null +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreFailureTest.kt @@ -0,0 +1,33 @@ +package com.flagship.abox.manager.session + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl +import java.io.IOException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProviderProfileStoreFailureTest { + @Test + fun ioFailureFallsBackToEmptyPreferences() = runTest { + val store = ProviderProfileStoreImpl(FailingReadDataStore()) + + assertEquals(emptyList(), store.observeProfiles().first()) + assertEquals(null, store.observeActiveProfile().first()) + } + + private class FailingReadDataStore : DataStore { + override val data: Flow = flow { + throw IOException("disk unavailable") + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = transform(emptyPreferences()) + } +}