fix: enforce provider availability on restore
This commit is contained in:
@@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.SessionCoordinator
|
|||||||
import com.flagship.abox.manager.domain.Username
|
import com.flagship.abox.manager.domain.Username
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -61,6 +62,15 @@ class LoginViewModel @Inject constructor(
|
|||||||
},
|
},
|
||||||
isLoadingPreferences = false,
|
isLoadingPreferences = false,
|
||||||
)
|
)
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (profile?.id == value.id) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoadingPreferences = false,
|
||||||
|
errorMessage = "Unable to load saved login settings",
|
||||||
|
)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loadingJob = null
|
loadingJob = null
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -7,6 +7,8 @@ import com.flagship.abox.manager.domain.ProviderProfile
|
|||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
import com.flagship.abox.manager.domain.Session
|
import com.flagship.abox.manager.domain.Session
|
||||||
import com.flagship.abox.manager.domain.SessionCoordinator
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
|
import com.flagship.abox.manager.ui.provider.Availability
|
||||||
|
import com.flagship.abox.manager.ui.provider.ProviderAvailability
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -19,7 +21,8 @@ import kotlinx.coroutines.launch
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AppSessionViewModel @Inject constructor(
|
class AppSessionViewModel @Inject constructor(
|
||||||
private val coordinator: SessionCoordinator,
|
private val coordinator: SessionCoordinator,
|
||||||
profileStore: ProviderProfileStore,
|
private val profileStore: ProviderProfileStore,
|
||||||
|
private val availability: ProviderAvailability,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val bootstrapResult = MutableStateFlow<DomainResult<Session?>?>(null)
|
private val bootstrapResult = MutableStateFlow<DomainResult<Session?>?>(null)
|
||||||
|
|
||||||
@@ -38,7 +41,13 @@ class AppSessionViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
bootstrapResult.value = coordinator.bootstrap()
|
val activeProfile = profileStore.activeProfile()
|
||||||
|
if (activeProfile != null && availability.forType(activeProfile.type) is Availability.Disabled) {
|
||||||
|
coordinator.switchProvider()
|
||||||
|
bootstrapResult.value = DomainResult.Success(null)
|
||||||
|
} else {
|
||||||
|
bootstrapResult.value = coordinator.bootstrap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +57,7 @@ class AppSessionViewModel @Inject constructor(
|
|||||||
bootstrapError: String?,
|
bootstrapError: String?,
|
||||||
): AppSessionUiState = when {
|
): AppSessionUiState = when {
|
||||||
profile == null -> AppSessionUiState.ProviderSelection
|
profile == null -> AppSessionUiState.ProviderSelection
|
||||||
|
availability.forType(profile.type) is Availability.Disabled -> AppSessionUiState.ProviderSelection
|
||||||
session != null -> AppSessionUiState.Authenticated(profile)
|
session != null -> AppSessionUiState.Authenticated(profile)
|
||||||
else -> AppSessionUiState.Login(profile, bootstrapError)
|
else -> AppSessionUiState.Login(profile, bootstrapError)
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-5
@@ -18,20 +18,34 @@ import java.io.IOException
|
|||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.retryWhen
|
import kotlinx.coroutines.flow.retryWhen
|
||||||
|
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
class ProviderProfileStoreImpl(
|
class ProviderProfileStoreImpl(
|
||||||
private val dataStore: DataStore<Preferences>,
|
private val dataStore: DataStore<Preferences>,
|
||||||
) : ProviderProfileStore {
|
) : ProviderProfileStore {
|
||||||
|
private val refresh = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
private val safeData: Flow<Preferences>
|
private val safeData: Flow<Preferences>
|
||||||
get() = dataStore.data.retryWhen { cause, attempt ->
|
get() = refresh
|
||||||
if (cause !is IOException) return@retryWhen false
|
.onStart { emit(Unit) }
|
||||||
if (attempt >= MAX_READ_RETRIES) emit(emptyPreferences())
|
.flatMapLatest { readDataOnce() }
|
||||||
delay(READ_RETRY_DELAY_MILLIS)
|
.distinctUntilChanged()
|
||||||
true
|
|
||||||
|
private fun readDataOnce(): Flow<Preferences> = dataStore.data
|
||||||
|
.retryWhen { cause, attempt ->
|
||||||
|
val retry = cause is IOException && attempt < MAX_READ_RETRIES
|
||||||
|
if (retry) delay(READ_RETRY_DELAY_MILLIS)
|
||||||
|
retry
|
||||||
}
|
}
|
||||||
|
.catchIOExceptionAsEmpty()
|
||||||
|
|
||||||
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
|
override fun observeProfiles(): Flow<List<ProviderProfile>> = safeData.map(::profilesFrom)
|
||||||
|
|
||||||
@@ -53,6 +67,7 @@ class ProviderProfileStoreImpl(
|
|||||||
preferences[profileTypeKey(profile.id)] = profile.type.name
|
preferences[profileTypeKey(profile.id)] = profile.type.name
|
||||||
preferences[profileNameKey(profile.id)] = profile.displayName
|
preferences[profileNameKey(profile.id)] = profile.displayName
|
||||||
}
|
}
|
||||||
|
refresh.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
|
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
|
||||||
@@ -60,6 +75,7 @@ class ProviderProfileStoreImpl(
|
|||||||
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
|
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
|
||||||
else preferences[ACTIVE_PROFILE_ID] = profileId.value
|
else preferences[ACTIVE_PROFILE_ID] = profileId.value
|
||||||
}
|
}
|
||||||
|
refresh.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun saveLoginPreferences(
|
override suspend fun saveLoginPreferences(
|
||||||
@@ -73,6 +89,7 @@ class ProviderProfileStoreImpl(
|
|||||||
if (username == null) values.remove(usernameKey(profileId))
|
if (username == null) values.remove(usernameKey(profileId))
|
||||||
else values[usernameKey(profileId)] = username
|
else values[usernameKey(profileId)] = username
|
||||||
}
|
}
|
||||||
|
refresh.tryEmit(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
|
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
|
||||||
@@ -135,3 +152,7 @@ class ProviderProfileStoreImpl(
|
|||||||
booleanPreferencesKey("profile_${id.value}_auto_login")
|
booleanPreferencesKey("profile_${id.value}_auto_login")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Flow<Preferences>.catchIOExceptionAsEmpty(): Flow<Preferences> = catch { error ->
|
||||||
|
if (error is IOException) emit(emptyPreferences()) else throw error
|
||||||
|
}
|
||||||
|
|||||||
+31
-14
@@ -1,11 +1,11 @@
|
|||||||
package com.flagship.abox.manager.session
|
package com.flagship.abox.manager.session
|
||||||
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.MutablePreferences
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.emptyPreferences
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -13,6 +13,9 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.take
|
import kotlinx.coroutines.flow.take
|
||||||
import kotlinx.coroutines.flow.toList
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -29,14 +32,29 @@ class ProviderProfileStoreFailureTest {
|
|||||||
assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled)
|
assertEquals(false, store.loginPreferences(ProviderProfileId("profile")).autoLoginEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun observerContinuesAfterStorageRecovers() = runTest {
|
fun observerResubscribesAfterSuccessfulWrite() = runTest {
|
||||||
val store = ProviderProfileStoreImpl(RecoveringReadDataStore())
|
val store = ProviderProfileStoreImpl(RecoveringReadDataStore())
|
||||||
|
val emissions = mutableListOf<ProviderProfile?>()
|
||||||
|
val collector = backgroundScope.launch {
|
||||||
|
store.observeActiveProfile().take(2).toList(emissions)
|
||||||
|
}
|
||||||
|
advanceTimeBy(200)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(listOf<ProviderProfile?>(null), emissions)
|
||||||
|
|
||||||
val emissions = store.observeActiveProfile().take(2).toList()
|
val profile = ProviderProfile(
|
||||||
|
ProviderProfileId("recovered"),
|
||||||
|
ProviderType.LOCAL_EXPERIENCE,
|
||||||
|
"Recovered",
|
||||||
|
)
|
||||||
|
store.saveProfile(profile)
|
||||||
|
store.setActiveProfile(profile.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
collector.join()
|
||||||
|
|
||||||
assertEquals(null, emissions[0])
|
assertEquals(listOf(null, profile), emissions)
|
||||||
assertEquals("recovered", emissions[1]?.id?.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FailingReadDataStore : DataStore<Preferences> {
|
private class FailingReadDataStore : DataStore<Preferences> {
|
||||||
@@ -51,20 +69,19 @@ class ProviderProfileStoreFailureTest {
|
|||||||
|
|
||||||
private class RecoveringReadDataStore : DataStore<Preferences> {
|
private class RecoveringReadDataStore : DataStore<Preferences> {
|
||||||
private var attempts = 0
|
private var attempts = 0
|
||||||
|
private var current: Preferences = emptyPreferences()
|
||||||
|
|
||||||
override val data: Flow<Preferences>
|
override val data: Flow<Preferences>
|
||||||
get() = flow {
|
get() = flow {
|
||||||
if (attempts++ < 3) throw IOException("temporarily unavailable")
|
if (attempts++ < 3) throw IOException("temporarily unavailable")
|
||||||
val values: MutablePreferences = emptyPreferences().toMutablePreferences()
|
emit(current)
|
||||||
values[stringPreferencesKey("active_profile_id")] = "recovered"
|
|
||||||
values[androidx.datastore.preferences.core.stringSetPreferencesKey("profile_ids")] =
|
|
||||||
setOf("recovered")
|
|
||||||
values[stringPreferencesKey("profile_recovered_type")] = "LOCAL_EXPERIENCE"
|
|
||||||
values[stringPreferencesKey("profile_recovered_name")] = "Recovered"
|
|
||||||
emit(values)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateData(
|
override suspend fun updateData(
|
||||||
transform: suspend (t: Preferences) -> Preferences,
|
transform: suspend (t: Preferences) -> Preferences,
|
||||||
): Preferences = transform(emptyPreferences())
|
): Preferences {
|
||||||
|
current = transform(current)
|
||||||
|
return current
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user