Loading app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +17 −24 Original line number Diff line number Diff line Loading @@ -208,16 +208,6 @@ class PlayStoreRepository @Inject constructor( } } private suspend fun refreshPlayStoreToken() { Timber.i("Refreshing authentication.") when (val refreshResult = tokenRefreshHandler.refreshPlayStoreToken()) { is AuthResult.Success -> Unit is AuthResult.Failure -> { Timber.w("Token refresh failed: %s", refreshResult.error.describe()) } } } private suspend fun <T> executeWithPlayAuthRecovery( operationName: String, request: suspend () -> T, Loading Loading @@ -282,21 +272,24 @@ class PlayStoreRepository @Inject constructor( } } private suspend fun <T> retryAfterPlayAuthRefresh( private suspend fun <T> retryAfterSuccessfulPlayAuthRefreshOrNull( operationName: String, reason: String, invalidateBootstrapTokens: Boolean = false, request: suspend () -> T, ): T { ): T? { Timber.i("Retrying %s after refreshing Play auth because %s", operationName, reason) if (invalidateBootstrapTokens) { // A 401 means cached Play auth is definitively rejected. Evict the stored AAS token // too, so Google-mode refresh does not silently recreate auth from the same stale // bootstrap credential. playStoreAuthStore.saveAasToken("") return when (val refreshResult = tokenRefreshHandler.refreshPlayStoreToken()) { is AuthResult.Success -> request() is AuthResult.Failure -> { Timber.w( "Skipping %s retry because Play auth refresh failed: %s", operationName, refreshResult.error.describe(), ) null } } refreshPlayStoreToken() return request() } private suspend fun <T> retryAfterSuccessfulPlayAuthRefreshOrThrow( Loading Loading @@ -353,11 +346,11 @@ class PlayStoreRepository @Inject constructor( } Timber.i("Version code is 0 for all app details.") val refreshedAppDetails = retryAfterPlayAuthRefresh( val refreshedAppDetails = retryAfterSuccessfulPlayAuthRefreshOrNull( operationName = "app details list", reason = "all version codes are 0", request = request, ) ) ?: return appDetails if (refreshedAppDetails.all { it.versionCode == 0L }) { Timber.w("After refreshing auth, version code is still 0 for all app details.") Loading @@ -375,11 +368,11 @@ class PlayStoreRepository @Inject constructor( } Timber.i("Version code is 0 for %s.", appDetails.packageName) val refreshedAppDetails = retryAfterPlayAuthRefresh( val refreshedAppDetails = retryAfterSuccessfulPlayAuthRefreshOrNull( operationName = "app details", reason = "version code is 0 for ${appDetails.packageName}", request = request, ) ) ?: appDetails if (refreshedAppDetails.versionCode == 0L) { Timber.w("After refreshing auth, version code is still 0. Giving up installation.") Loading app/src/main/java/foundation/e/apps/feature/auth/source/UpdateSourceSelectionCoordinator.kt +84 −1 Original line number Diff line number Diff line Loading @@ -22,9 +22,19 @@ import androidx.work.ExistingPeriodicWorkPolicy import foundation.e.apps.domain.source.SourceSelection import foundation.e.apps.domain.source.SourceSelectionRepository import foundation.e.apps.domain.updates.PendingUpdatesRepository import foundation.e.apps.feature.auth.session.SessionRefreshException import foundation.e.apps.feature.auth.session.SessionStateController import foundation.e.apps.updates.PeriodicUpdatesScheduler import timber.log.Timber import java.io.IOException import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException class SourceSelectionUpdateException( val requestedSelection: SourceSelection, val restoredSelection: SourceSelection, cause: Throwable, ) : IllegalStateException("Failed to update source selection", cause) class UpdateSourceSelectionCoordinator @Inject constructor( private val pendingUpdatesRepository: PendingUpdatesRepository, Loading @@ -33,7 +43,20 @@ class UpdateSourceSelectionCoordinator @Inject constructor( private val sourceSelectionRepository: SourceSelectionRepository, ) { suspend operator fun invoke(sourceSelection: SourceSelection) { val previousSelection = sourceSelectionRepository.currentSourceSelection() try { sourceSelectionRepository.saveSourceSelection(sourceSelection) applySourceSelectionSideEffects() } catch (exception: CancellationException) { throw exception } catch (exception: IOException) { failAfterRollback(sourceSelection, previousSelection, exception) } catch (exception: SessionRefreshException) { failAfterRollback(sourceSelection, previousSelection, exception) } } private suspend fun applySourceSelectionSideEffects() { pendingUpdatesRepository.clearPendingUpdates() periodicUpdatesScheduler.syncPeriodicUpdates( existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, Loading @@ -41,4 +64,64 @@ class UpdateSourceSelectionCoordinator @Inject constructor( sessionStateController.clearLoadedSessions() sessionStateController.refreshSessions() } private suspend fun failAfterRollback( requestedSelection: SourceSelection, previousSelection: SourceSelection, originalFailure: Exception, ): Nothing { rollbackSourceSelection(previousSelection, originalFailure) throw SourceSelectionUpdateException( requestedSelection = requestedSelection, restoredSelection = previousSelection, cause = originalFailure, ) } private suspend fun rollbackSourceSelection( previousSelection: SourceSelection, originalFailure: Exception, ) { try { sourceSelectionRepository.saveSourceSelection(previousSelection) } catch (exception: IllegalStateException) { originalFailure.addSuppressed(exception) Timber.w(exception, "Failed to restore previous source selection") return } runRollbackStep( description = "reschedule updates for restored source selection", originalFailure = originalFailure, ) { periodicUpdatesScheduler.syncPeriodicUpdates( existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, ) } runRollbackStep( description = "refresh sessions for restored source selection", originalFailure = originalFailure, ) { sessionStateController.clearLoadedSessions() sessionStateController.refreshSessions() } } private suspend fun runRollbackStep( description: String, originalFailure: Exception, block: suspend () -> Unit, ) { try { block() } catch (exception: CancellationException) { throw exception } catch (exception: IOException) { originalFailure.addSuppressed(exception) Timber.w(exception, "Failed to %s", description) } catch (exception: SessionRefreshException) { originalFailure.addSuppressed(exception) Timber.w(exception, "Failed to %s", description) } } } app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt +9 −0 Original line number Diff line number Diff line Loading @@ -190,6 +190,7 @@ class SettingsFragment : PreferenceFragmentCompat() { when (event) { SettingsUiEvent.PlayStoreLoginRequired -> showPlayStoreLoginRequiredMessage() SettingsUiEvent.SelectAtLeastOneSource -> showOnlyRemainingSourceWarning() SettingsUiEvent.SourceSelectionUpdateFailed -> showSourceSelectionUpdateFailed() } } } Loading Loading @@ -314,6 +315,14 @@ class SettingsFragment : PreferenceFragmentCompat() { ).show() } private fun showSourceSelectionUpdateFailed() { Toast.makeText( requireActivity(), R.string.source_selection_update_failed, Toast.LENGTH_SHORT ).show() } private fun disableDependentCheckbox( checkBox: CheckBoxPreference?, parentCheckBox: CheckBoxPreference? Loading app/src/main/java/foundation/e/apps/ui/settings/SettingsUiEvent.kt +1 −0 Original line number Diff line number Diff line Loading @@ -21,4 +21,5 @@ package foundation.e.apps.ui.settings sealed interface SettingsUiEvent { data object PlayStoreLoginRequired : SettingsUiEvent data object SelectAtLeastOneSource : SettingsUiEvent data object SourceSelectionUpdateFailed : SettingsUiEvent } app/src/main/java/foundation/e/apps/ui/settings/SettingsViewModel.kt +3 −5 Original line number Diff line number Diff line Loading @@ -30,7 +30,7 @@ import foundation.e.apps.domain.auth.PlayStoreLoginMode import foundation.e.apps.domain.source.AppSource import foundation.e.apps.domain.source.SourceSelection import foundation.e.apps.domain.source.SourceSelectionRepository import foundation.e.apps.feature.auth.session.SessionRefreshException import foundation.e.apps.feature.auth.source.SourceSelectionUpdateException import foundation.e.apps.feature.auth.source.UpdateSourceSelectionCoordinator import foundation.e.apps.updates.PeriodicUpdatesScheduler import kotlinx.coroutines.flow.MutableSharedFlow Loading @@ -42,7 +42,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import java.io.IOException import javax.inject.Inject @HiltViewModel Loading Loading @@ -102,10 +101,9 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { try { updateSourceSelectionCoordinator(sourceSelection) } catch (exception: IOException) { Timber.e(exception, "Failed to update source selection") } catch (exception: SessionRefreshException) { } catch (exception: SourceSelectionUpdateException) { Timber.e(exception, "Failed to update source selection") _uiEvents.emit(SettingsUiEvent.SourceSelectionUpdateFailed) } } } Loading Loading
app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +17 −24 Original line number Diff line number Diff line Loading @@ -208,16 +208,6 @@ class PlayStoreRepository @Inject constructor( } } private suspend fun refreshPlayStoreToken() { Timber.i("Refreshing authentication.") when (val refreshResult = tokenRefreshHandler.refreshPlayStoreToken()) { is AuthResult.Success -> Unit is AuthResult.Failure -> { Timber.w("Token refresh failed: %s", refreshResult.error.describe()) } } } private suspend fun <T> executeWithPlayAuthRecovery( operationName: String, request: suspend () -> T, Loading Loading @@ -282,21 +272,24 @@ class PlayStoreRepository @Inject constructor( } } private suspend fun <T> retryAfterPlayAuthRefresh( private suspend fun <T> retryAfterSuccessfulPlayAuthRefreshOrNull( operationName: String, reason: String, invalidateBootstrapTokens: Boolean = false, request: suspend () -> T, ): T { ): T? { Timber.i("Retrying %s after refreshing Play auth because %s", operationName, reason) if (invalidateBootstrapTokens) { // A 401 means cached Play auth is definitively rejected. Evict the stored AAS token // too, so Google-mode refresh does not silently recreate auth from the same stale // bootstrap credential. playStoreAuthStore.saveAasToken("") return when (val refreshResult = tokenRefreshHandler.refreshPlayStoreToken()) { is AuthResult.Success -> request() is AuthResult.Failure -> { Timber.w( "Skipping %s retry because Play auth refresh failed: %s", operationName, refreshResult.error.describe(), ) null } } refreshPlayStoreToken() return request() } private suspend fun <T> retryAfterSuccessfulPlayAuthRefreshOrThrow( Loading Loading @@ -353,11 +346,11 @@ class PlayStoreRepository @Inject constructor( } Timber.i("Version code is 0 for all app details.") val refreshedAppDetails = retryAfterPlayAuthRefresh( val refreshedAppDetails = retryAfterSuccessfulPlayAuthRefreshOrNull( operationName = "app details list", reason = "all version codes are 0", request = request, ) ) ?: return appDetails if (refreshedAppDetails.all { it.versionCode == 0L }) { Timber.w("After refreshing auth, version code is still 0 for all app details.") Loading @@ -375,11 +368,11 @@ class PlayStoreRepository @Inject constructor( } Timber.i("Version code is 0 for %s.", appDetails.packageName) val refreshedAppDetails = retryAfterPlayAuthRefresh( val refreshedAppDetails = retryAfterSuccessfulPlayAuthRefreshOrNull( operationName = "app details", reason = "version code is 0 for ${appDetails.packageName}", request = request, ) ) ?: appDetails if (refreshedAppDetails.versionCode == 0L) { Timber.w("After refreshing auth, version code is still 0. Giving up installation.") Loading
app/src/main/java/foundation/e/apps/feature/auth/source/UpdateSourceSelectionCoordinator.kt +84 −1 Original line number Diff line number Diff line Loading @@ -22,9 +22,19 @@ import androidx.work.ExistingPeriodicWorkPolicy import foundation.e.apps.domain.source.SourceSelection import foundation.e.apps.domain.source.SourceSelectionRepository import foundation.e.apps.domain.updates.PendingUpdatesRepository import foundation.e.apps.feature.auth.session.SessionRefreshException import foundation.e.apps.feature.auth.session.SessionStateController import foundation.e.apps.updates.PeriodicUpdatesScheduler import timber.log.Timber import java.io.IOException import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException class SourceSelectionUpdateException( val requestedSelection: SourceSelection, val restoredSelection: SourceSelection, cause: Throwable, ) : IllegalStateException("Failed to update source selection", cause) class UpdateSourceSelectionCoordinator @Inject constructor( private val pendingUpdatesRepository: PendingUpdatesRepository, Loading @@ -33,7 +43,20 @@ class UpdateSourceSelectionCoordinator @Inject constructor( private val sourceSelectionRepository: SourceSelectionRepository, ) { suspend operator fun invoke(sourceSelection: SourceSelection) { val previousSelection = sourceSelectionRepository.currentSourceSelection() try { sourceSelectionRepository.saveSourceSelection(sourceSelection) applySourceSelectionSideEffects() } catch (exception: CancellationException) { throw exception } catch (exception: IOException) { failAfterRollback(sourceSelection, previousSelection, exception) } catch (exception: SessionRefreshException) { failAfterRollback(sourceSelection, previousSelection, exception) } } private suspend fun applySourceSelectionSideEffects() { pendingUpdatesRepository.clearPendingUpdates() periodicUpdatesScheduler.syncPeriodicUpdates( existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, Loading @@ -41,4 +64,64 @@ class UpdateSourceSelectionCoordinator @Inject constructor( sessionStateController.clearLoadedSessions() sessionStateController.refreshSessions() } private suspend fun failAfterRollback( requestedSelection: SourceSelection, previousSelection: SourceSelection, originalFailure: Exception, ): Nothing { rollbackSourceSelection(previousSelection, originalFailure) throw SourceSelectionUpdateException( requestedSelection = requestedSelection, restoredSelection = previousSelection, cause = originalFailure, ) } private suspend fun rollbackSourceSelection( previousSelection: SourceSelection, originalFailure: Exception, ) { try { sourceSelectionRepository.saveSourceSelection(previousSelection) } catch (exception: IllegalStateException) { originalFailure.addSuppressed(exception) Timber.w(exception, "Failed to restore previous source selection") return } runRollbackStep( description = "reschedule updates for restored source selection", originalFailure = originalFailure, ) { periodicUpdatesScheduler.syncPeriodicUpdates( existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, ) } runRollbackStep( description = "refresh sessions for restored source selection", originalFailure = originalFailure, ) { sessionStateController.clearLoadedSessions() sessionStateController.refreshSessions() } } private suspend fun runRollbackStep( description: String, originalFailure: Exception, block: suspend () -> Unit, ) { try { block() } catch (exception: CancellationException) { throw exception } catch (exception: IOException) { originalFailure.addSuppressed(exception) Timber.w(exception, "Failed to %s", description) } catch (exception: SessionRefreshException) { originalFailure.addSuppressed(exception) Timber.w(exception, "Failed to %s", description) } } }
app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt +9 −0 Original line number Diff line number Diff line Loading @@ -190,6 +190,7 @@ class SettingsFragment : PreferenceFragmentCompat() { when (event) { SettingsUiEvent.PlayStoreLoginRequired -> showPlayStoreLoginRequiredMessage() SettingsUiEvent.SelectAtLeastOneSource -> showOnlyRemainingSourceWarning() SettingsUiEvent.SourceSelectionUpdateFailed -> showSourceSelectionUpdateFailed() } } } Loading Loading @@ -314,6 +315,14 @@ class SettingsFragment : PreferenceFragmentCompat() { ).show() } private fun showSourceSelectionUpdateFailed() { Toast.makeText( requireActivity(), R.string.source_selection_update_failed, Toast.LENGTH_SHORT ).show() } private fun disableDependentCheckbox( checkBox: CheckBoxPreference?, parentCheckBox: CheckBoxPreference? Loading
app/src/main/java/foundation/e/apps/ui/settings/SettingsUiEvent.kt +1 −0 Original line number Diff line number Diff line Loading @@ -21,4 +21,5 @@ package foundation.e.apps.ui.settings sealed interface SettingsUiEvent { data object PlayStoreLoginRequired : SettingsUiEvent data object SelectAtLeastOneSource : SettingsUiEvent data object SourceSelectionUpdateFailed : SettingsUiEvent }
app/src/main/java/foundation/e/apps/ui/settings/SettingsViewModel.kt +3 −5 Original line number Diff line number Diff line Loading @@ -30,7 +30,7 @@ import foundation.e.apps.domain.auth.PlayStoreLoginMode import foundation.e.apps.domain.source.AppSource import foundation.e.apps.domain.source.SourceSelection import foundation.e.apps.domain.source.SourceSelectionRepository import foundation.e.apps.feature.auth.session.SessionRefreshException import foundation.e.apps.feature.auth.source.SourceSelectionUpdateException import foundation.e.apps.feature.auth.source.UpdateSourceSelectionCoordinator import foundation.e.apps.updates.PeriodicUpdatesScheduler import kotlinx.coroutines.flow.MutableSharedFlow Loading @@ -42,7 +42,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import java.io.IOException import javax.inject.Inject @HiltViewModel Loading Loading @@ -102,10 +101,9 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { try { updateSourceSelectionCoordinator(sourceSelection) } catch (exception: IOException) { Timber.e(exception, "Failed to update source selection") } catch (exception: SessionRefreshException) { } catch (exception: SourceSelectionUpdateException) { Timber.e(exception, "Failed to update source selection") _uiEvents.emit(SettingsUiEvent.SourceSelectionUpdateFailed) } } } Loading