fix: harden provider session restoration

This commit is contained in:
2026-08-23 23:30:11 +08:00
parent 344fa6dafa
commit 7cb0480a7a
5 changed files with 83 additions and 30 deletions
@@ -48,10 +48,6 @@ fun LoginRoute(
) { ) {
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) } LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
val state = viewModel.uiState.collectAsStateWithLifecycle().value val state = viewModel.uiState.collectAsStateWithLifecycle().value
val switchProvider = {
viewModel.clearSensitiveState()
onSwitchProvider()
}
LoginScreen( LoginScreen(
profile = profile, profile = profile,
state = state.copy( state = state.copy(
@@ -62,7 +58,7 @@ fun LoginRoute(
onRememberUsernameChange = viewModel::setRememberUsername, onRememberUsernameChange = viewModel::setRememberUsername,
onAutoLoginChange = viewModel::setAutoLogin, onAutoLoginChange = viewModel::setAutoLogin,
onSubmit = viewModel::submit, onSubmit = viewModel::submit,
onSwitchProvider = switchProvider, onSwitchProvider = onSwitchProvider,
switchingProvider = actionInProgress, switchingProvider = actionInProgress,
) )
} }
@@ -32,8 +32,7 @@ class LoginViewModel @Inject constructor(
private var autoLoginEdited = false private var autoLoginEdited = false
fun loadProfile(value: ProviderProfile) { fun loadProfile(value: ProviderProfile) {
val profileChanged = profile?.id != value.id if (profile?.id == value.id && loadingJob?.isActive == true) return
if (!profileChanged && loadingJob != null) return
loadingJob?.cancel() loadingJob?.cancel()
profile = value profile = value
usernameEdited = false usernameEdited = false
@@ -41,6 +40,7 @@ class LoginViewModel @Inject constructor(
autoLoginEdited = false autoLoginEdited = false
_uiState.value = LoginUiState(isLoadingPreferences = true) _uiState.value = LoginUiState(isLoadingPreferences = true)
loadingJob = viewModelScope.launch { loadingJob = viewModelScope.launch {
try {
val preferences = profileStore.loginPreferences(value.id) val preferences = profileStore.loginPreferences(value.id)
if (profile?.id != value.id) return@launch if (profile?.id != value.id) return@launch
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
@@ -61,6 +61,9 @@ class LoginViewModel @Inject constructor(
}, },
isLoadingPreferences = false, isLoadingPreferences = false,
) )
} finally {
loadingJob = null
}
} }
} }
@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.flagship.abox.manager.domain.DomainResult 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.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.domain.Username
import java.io.IOException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retryWhen
class ProviderProfileStoreImpl( class ProviderProfileStoreImpl(
private val dataStore: DataStore<Preferences>, private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore { ) : ProviderProfileStore {
override fun observeProfiles(): Flow<List<ProviderProfile>> = dataStore.data.map(::profilesFrom) private val safeData: Flow<Preferences>
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<ProviderProfile?> = dataStore.data.map(::activeProfileFrom) override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
override fun observeActiveProfile(): Flow<ProviderProfile?> = safeData.map(::activeProfileFrom)
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> = override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
dataStore.data.map { preferencesFrom(it, profileId) } safeData.map { preferencesFrom(it, profileId) }
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(dataStore.data.first()) override suspend fun profiles(): List<ProviderProfile> = profilesFrom(dataStore.data.first())
@@ -99,6 +115,8 @@ class ProviderProfileStoreImpl(
} }
companion object { 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 PROFILE_IDS = stringSetPreferencesKey("profile_ids")
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id") private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
@@ -2,11 +2,14 @@ package com.flagship.abox.manager.session.profile
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
private const val SESSION_DATA_STORE_NAME = "provider_session" private const val SESSION_DATA_STORE_NAME = "provider_session"
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore( val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
name = SESSION_DATA_STORE_NAME, name = SESSION_DATA_STORE_NAME,
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
) )
@@ -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<Any>(), store.observeProfiles().first())
assertEquals(null, store.observeActiveProfile().first())
}
private class FailingReadDataStore : DataStore<Preferences> {
override val data: Flow<Preferences> = flow {
throw IOException("disk unavailable")
}
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = transform(emptyPreferences())
}
}