diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/previewparameter/AccountPreviewParameter.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/previewparameter/AccountPreviewParameter.kt index 929f396f..8a29757f 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/previewparameter/AccountPreviewParameter.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/previewparameter/AccountPreviewParameter.kt @@ -19,6 +19,7 @@ package com.infomaniak.auth.ui.previewparameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.infomaniak.auth.lib.Account +import com.infomaniak.auth.lib.Account.Status.NotConnected.ReLogin import com.infomaniak.auth.lib.Issue import com.infomaniak.core.auth.models.user.User import com.infomaniak.core.ui.compose.preview.previewparameter.dummyUserOf @@ -52,7 +53,7 @@ val fakeAccounts = persistentListOf( initials = "JS", email = "john.smith.relogin@ik.me", avatarUrl = null, - status = Account.Status.NotConnected.ReLogin( + status = ReLogin( legacyAccount = Account( id = 2, fullName = "John Issue ReLogin", @@ -60,7 +61,10 @@ val fakeAccounts = persistentListOf( email = "john.smith.relogin@ik.me", status = Account.Status.NotConnected.AttemptingToConnect, ), - lastIssue = Issue.Retriable.Reason.NetworkIssue, + lastIssue = ReLogin.DismissableIssue( + dismiss = {}, + cause = Issue.Retriable.Cause.NetworkIssue + ), sendCredentials = { _ -> }, ), ), diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt index 8b51477a..048be932 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountdetails/ActionRequiredCard.kt @@ -82,12 +82,12 @@ fun ActionRequiredCard( ) } is Account.Status.NotConnected.LoginFailed -> { - when (status.cause) { + when (val issue = status.issue) { is Issue.Retriable -> { - val code = when (val reason = (status.cause as Issue.Retriable).reason) { - Issue.Retriable.Reason.NetworkIssue -> -1 - Issue.Retriable.Reason.ServerUnavailable -> 503 - is Issue.Retriable.Reason.Other -> reason.errorCode + val code = when (val cause = issue.cause) { + Issue.Retriable.Cause.NetworkIssue -> -1 + Issue.Retriable.Cause.ServerUnavailable -> 503 + is Issue.Retriable.Cause.Other -> cause.errorCode } ActionRequiredCard( configuration = ActionRequiredConfiguration( @@ -108,7 +108,7 @@ fun ActionRequiredCard( title = stringResource(RCore.string.buttonRetry), style = ButtonStyle.Tertiary, onClick = { - val cause = status.cause as? Issue.Retriable + val cause = status.issue as? Issue.Retriable cause?.proceed?.invoke(true) } ) diff --git a/multiplatform-lib/src/commonMain/kotlin/Account.kt b/multiplatform-lib/src/commonMain/kotlin/Account.kt index 98fe80ae..db4a5d00 100644 --- a/multiplatform-lib/src/commonMain/kotlin/Account.kt +++ b/multiplatform-lib/src/commonMain/kotlin/Account.kt @@ -41,14 +41,19 @@ data class Account( data class ReLogin( val legacyAccount: Account, val hadIncorrectPassword: Boolean = false, - val lastIssue: Issue.Retriable.Reason?, + val lastIssue: DismissableIssue?, val sendCredentials: ((CredentialsForMigration) -> Unit)?, ) : NotConnected { + data class DismissableIssue( + val dismiss: () -> Unit, + val cause: Issue.Retriable.Cause, + ) + val isSendingCredentials: Boolean get() = sendCredentials == null } - data class LoginFailed(val cause: Issue) : NotConnected + data class LoginFailed(val issue: Issue) : NotConnected } } } diff --git a/multiplatform-lib/src/commonMain/kotlin/Issue.kt b/multiplatform-lib/src/commonMain/kotlin/Issue.kt index 73d26e48..9a6ad243 100644 --- a/multiplatform-lib/src/commonMain/kotlin/Issue.kt +++ b/multiplatform-lib/src/commonMain/kotlin/Issue.kt @@ -24,13 +24,13 @@ sealed interface Issue { * @property proceed Typically called when the user presses a button labeled "Skip" or "Retry". */ data class Retriable( - val reason: Reason, + val cause: Cause, val proceed: (shouldRetry: Boolean) -> Unit ) : Issue { - sealed interface Reason { - data object NetworkIssue : Reason - data object ServerUnavailable : Reason - data class Other(val errorCode: Int, val message: String) : Reason + sealed interface Cause { + data object NetworkIssue : Cause + data object ServerUnavailable : Cause + data class Other(val errorCode: Int, val message: String) : Cause } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 5499ce8d..5e83f250 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -25,7 +25,7 @@ import com.infomaniak.auth.lib.AppStatus import com.infomaniak.auth.lib.AuthenticatorFacade import com.infomaniak.auth.lib.CredentialsForMigration import com.infomaniak.auth.lib.Issue -import com.infomaniak.auth.lib.Issue.Retriable.Reason +import com.infomaniak.auth.lib.Issue.Retriable.Cause import com.infomaniak.auth.lib.internal.db.AccountEntity import com.infomaniak.auth.lib.internal.db.AccountsDatabase import com.infomaniak.auth.lib.internal.extensions.cancellable @@ -35,11 +35,14 @@ import com.infomaniak.auth.lib.internal.extensions.toEntity import com.infomaniak.auth.lib.internal.managers.AuthenticatorManager import com.infomaniak.auth.lib.internal.managers.MigrationManager import com.infomaniak.auth.lib.internal.utils.DynamicLazyMap +import com.infomaniak.auth.lib.internal.utils.launchRacer +import com.infomaniak.auth.lib.internal.utils.race import com.infomaniak.auth.lib.internal.utils.raceOf import com.infomaniak.auth.lib.internal.utils.sharedFlow import com.infomaniak.auth.lib.internal.utils.waitForComplete import com.infomaniak.auth.lib.internal.utils.withTimeoutOrNull import com.infomaniak.auth.lib.network.exceptions.ApiException +import com.infomaniak.auth.lib.network.exceptions.NetworkException import com.infomaniak.auth.lib.network.interfaces.AuthenticatorBridge import com.infomaniak.auth.lib.network.interfaces.CrashReportInterface import kotlinx.coroutines.CompletableDeferred @@ -49,6 +52,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector @@ -178,17 +183,7 @@ internal class AuthenticatorFacadeImpl( if (!stillTryingToConnect) emit(null) // Emit to skip }.first() accountToReloginOrSkip?.let { accountToRelogin -> - val skipAsync: CompletableJob = Job() - emit(AppStatus.LoginRequired.MustReLogin(accountToRelogin.id, skipAsync::complete)) - raceOf( - { skipAsync.join() }, - { - accounts.first { list -> - val account = list.singleOrNull() ?: return@first false - account.status is Account.Status.LoggedIn - } - } - ) + handleSingleAccountToReLoginOrSkip(accountToRelogin) } } } else if (needsToShowEverythingReady) { @@ -211,6 +206,20 @@ internal class AuthenticatorFacadeImpl( emitAll(appStatusFlow) }.distinctUntilChanged() + private suspend fun FlowCollector.handleSingleAccountToReLoginOrSkip(account: Account) { + val skipAsync: CompletableJob = Job() + emit(AppStatus.LoginRequired.MustReLogin(account.id, skipAsync::complete)) + raceOf( + { skipAsync.join() }, + { + val _ = accounts.first { list -> + val account = list.singleOrNull() ?: return@first false + account.status is Account.Status.LoggedIn + } + } + ) + } + private fun loginAttemptsFlow(userId: Long): Flow = dao.getAccountAsFlow(userId).transformLatest { entity -> emit(null) @@ -343,12 +352,21 @@ internal class AuthenticatorFacadeImpl( lastIssue = null, sendCredentials = null ) + val issueDismissals = Channel(capacity = Channel.CONFLATED) loop@ while (true) { status = runCatching { val credentialsAsync = CompletableDeferred() status = status.copy(sendCredentials = credentialsAsync::complete) emit(status) - val credentialsForMigration = credentialsAsync.await() + val credentialsForMigration = awaitCredentialsWhileAllowingIssueDismissal( + credentialsAsync = credentialsAsync, + issueDismissals = issueDismissals, + lastStatus = status, + onStatusUpdate = { newStatus -> + status = newStatus + emit(status) + } + ) status = status.copy(hadIncorrectPassword = false, lastIssue = null, sendCredentials = null) emit(status) val authentication = MigrationAuthentication.NoOngoingLogin(credentialsForMigration.password) @@ -359,11 +377,31 @@ internal class AuthenticatorFacadeImpl( } }.cancellable().getOrElse { it.printStackTrace() - status.copy(lastIssue = it.toIssueReason(accountToMigrate.id)) + val issue = ReLogin.DismissableIssue( + dismiss = { issueDismissals.trySend(Unit) }, + cause = it.toIssueCause(accountToMigrate.id) + ) + status.copy(lastIssue = issue) } } } + private suspend inline fun awaitCredentialsWhileAllowingIssueDismissal( + credentialsAsync: CompletableDeferred, + issueDismissals: ReceiveChannel, + lastStatus: ReLogin, + onStatusUpdate: (newStatus: ReLogin) -> Unit, + ): CredentialsForMigration = race { + launchRacer { credentialsAsync.await() } + if (lastStatus.lastIssue != null) launchRacer { + issueDismissals.receive() + null + } + } ?: run { + onStatusUpdate(lastStatus.copy(lastIssue = null)) + credentialsAsync.await() + } + private suspend fun FlowCollector.restoreFromBackupAttempts(account: AccountEntity) { withRetries(userId = account.id) { emit(Account.Status.NotConnected.AttemptingToConnect) @@ -388,9 +426,9 @@ internal class AuthenticatorFacadeImpl( emit(Account.Status.NotConnected.LoginFailed(issue)) awaitCancellation() } - val issueReason = it.toIssueReason(userId) + val issueReason = it.toIssueCause(userId) val shouldRetryAsync = CompletableDeferred() - val issue = Issue.Retriable(reason = issueReason, proceed = shouldRetryAsync::complete) + val issue = Issue.Retriable(cause = issueReason, proceed = shouldRetryAsync::complete) emit(Account.Status.NotConnected.LoginFailed(issue)) val shouldRetry = shouldRetryAsync.await() if (shouldRetry) continue else onGiveUp() @@ -398,20 +436,20 @@ internal class AuthenticatorFacadeImpl( } } - private fun Throwable.toIssueReason(userId: Long): Reason = when (this) { - is IOException -> Reason.NetworkIssue - is ApiException if (statusCode == 503) -> Reason.ServerUnavailable + private fun Throwable.toIssueCause(userId: Long): Cause = when (this) { + is NetworkException, is IOException -> Cause.NetworkIssue + is ApiException if (statusCode == 503) -> Cause.ServerUnavailable is ApiException.ApiErrorException -> { crashReport.capture(userId, "re-login migration attempt failed", this) - Reason.Other(12_000 + statusCode, "http $statusCode $errorCode $errorMessage") + Cause.Other(12_000 + statusCode, "http $statusCode $errorCode $errorMessage") } is ApiException.UnexpectedApiErrorFormatException -> { crashReport.capture(userId, "re-login migration attempt failed", this) - Reason.Other(22_000 + statusCode, "http $statusCode $bodyResponse") + Cause.Other(22_000 + statusCode, "http $statusCode $bodyResponse") } else -> { crashReport.capture(userId, "re-login migration attempt failed", this) - Reason.Other(11_000, message ?: this::class.simpleName ?: "$this") + Cause.Other(11_000, message ?: this::class.simpleName ?: "$this") } } } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt index f9f9a59d..b5962fb7 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt @@ -124,7 +124,7 @@ internal class ApiClientProvider( private suspend fun handleResponseExceptionWithRequest(cause: Throwable, request: HttpRequest) { when (cause) { - is IOException -> throw NetworkException("Network error: ${cause.message}") + is IOException -> throw NetworkException("Network error: ${cause.message}", cause) is ApiException, is CancellationException -> throw cause else -> { val response = runCatching { request.call.response }.getOrNull() @@ -132,10 +132,10 @@ internal class ApiClientProvider( val bodyResponse = response?.bodyAsText() ?: cause.message ?: "" val statusCode = response?.status?.value ?: -1 throw ApiException.UnexpectedApiErrorFormatException( - statusCode, - bodyResponse, - cause, - requestContextId + statusCode = statusCode, + bodyResponse = bodyResponse, + cause = cause, + requestContextId = requestContextId ) } } diff --git a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt index 23ff3bf3..8a6a09d5 100644 --- a/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt +++ b/multiplatform-lib/src/commonMain/kotlin/network/exceptions/NetworkException.kt @@ -22,4 +22,4 @@ package com.infomaniak.auth.lib.network.exceptions * * @param message A detailed message describing the network error. */ -class NetworkException(message: String) : Exception(message) +class NetworkException(message: String, cause: Throwable) : Exception(message, cause)