From 00d65e301e4b08d6085f2eb6714ac77dc14bace8 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 30 Apr 2026 10:28:44 +0200 Subject: [PATCH 1/7] refactor: Rename Reason to Cause --- .../AccountPreviewParameter.kt | 2 +- .../accountdetails/ActionRequiredCard.kt | 8 ++++---- .../src/commonMain/kotlin/Account.kt | 2 +- .../src/commonMain/kotlin/Issue.kt | 10 +++++----- .../internal/AuthenticatorFacadeImpl.kt | 20 +++++++++---------- 5 files changed, 21 insertions(+), 21 deletions(-) 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..902d3ae7 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 @@ -60,7 +60,7 @@ val fakeAccounts = persistentListOf( email = "john.smith.relogin@ik.me", status = Account.Status.NotConnected.AttemptingToConnect, ), - lastIssue = Issue.Retriable.Reason.NetworkIssue, + lastIssue = 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..50dbee88 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 @@ -84,10 +84,10 @@ fun ActionRequiredCard( is Account.Status.NotConnected.LoginFailed -> { when (status.cause) { 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 = (status.cause as Issue.Retriable).cause) { + Issue.Retriable.Cause.NetworkIssue -> -1 + Issue.Retriable.Cause.ServerUnavailable -> 503 + is Issue.Retriable.Cause.Other -> cause.errorCode } ActionRequiredCard( configuration = ActionRequiredConfiguration( diff --git a/multiplatform-lib/src/commonMain/kotlin/Account.kt b/multiplatform-lib/src/commonMain/kotlin/Account.kt index 98fe80ae..242fec83 100644 --- a/multiplatform-lib/src/commonMain/kotlin/Account.kt +++ b/multiplatform-lib/src/commonMain/kotlin/Account.kt @@ -41,7 +41,7 @@ data class Account( data class ReLogin( val legacyAccount: Account, val hadIncorrectPassword: Boolean = false, - val lastIssue: Issue.Retriable.Reason?, + val lastIssue: Issue.Retriable.Cause?, val sendCredentials: ((CredentialsForMigration) -> Unit)?, ) : 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..d055f410 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 @@ -359,7 +359,7 @@ internal class AuthenticatorFacadeImpl( } }.cancellable().getOrElse { it.printStackTrace() - status.copy(lastIssue = it.toIssueReason(accountToMigrate.id)) + status.copy(lastIssue = it.toIssueCause(accountToMigrate.id)) } } } @@ -388,9 +388,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 +398,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 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") } } } From bf994c8632f2cebbd3fcf85f95fd88ec53a2fd52 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 30 Apr 2026 10:33:47 +0200 Subject: [PATCH 2/7] fix: Add missing custom NetworkException handling --- .../src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index d055f410..f82f7a47 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -40,6 +40,7 @@ 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 @@ -399,7 +400,7 @@ internal class AuthenticatorFacadeImpl( } private fun Throwable.toIssueCause(userId: Long): Cause = when (this) { - is IOException -> Cause.NetworkIssue + 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) From 07d1b877ef3d3f6c6d2c94462a3eaf3a4a8265d0 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 30 Apr 2026 10:34:45 +0200 Subject: [PATCH 3/7] chore: Add missing cause in custom NetworkException --- .../src/commonMain/kotlin/internal/network/ApiClientProvider.kt | 2 +- .../commonMain/kotlin/network/exceptions/NetworkException.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt index f9f9a59d..c8b5f5b7 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() 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) From 4a0b20360e79ae1774fcf18864ad5725639077d9 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 30 Apr 2026 10:35:01 +0200 Subject: [PATCH 4/7] chore: Add parameter names to constructor call --- .../kotlin/internal/network/ApiClientProvider.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt index c8b5f5b7..b5962fb7 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/network/ApiClientProvider.kt @@ -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 ) } } From 0ba70e6e323e05582d9180acd71b44be3980a980 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 30 Apr 2026 10:36:32 +0200 Subject: [PATCH 5/7] feat: Allow the dismissal of an issue during ReLogin --- .../AccountPreviewParameter.kt | 8 +++- .../src/commonMain/kotlin/Account.kt | 7 +++- .../internal/AuthenticatorFacadeImpl.kt | 37 ++++++++++++++++++- 3 files changed, 47 insertions(+), 5 deletions(-) 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 902d3ae7..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.Cause.NetworkIssue, + lastIssue = ReLogin.DismissableIssue( + dismiss = {}, + cause = Issue.Retriable.Cause.NetworkIssue + ), sendCredentials = { _ -> }, ), ), diff --git a/multiplatform-lib/src/commonMain/kotlin/Account.kt b/multiplatform-lib/src/commonMain/kotlin/Account.kt index 242fec83..d2a27e15 100644 --- a/multiplatform-lib/src/commonMain/kotlin/Account.kt +++ b/multiplatform-lib/src/commonMain/kotlin/Account.kt @@ -41,10 +41,15 @@ data class Account( data class ReLogin( val legacyAccount: Account, val hadIncorrectPassword: Boolean = false, - val lastIssue: Issue.Retriable.Cause?, + 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 } diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index f82f7a47..0fe65969 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -35,6 +35,8 @@ 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 @@ -50,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 @@ -344,12 +348,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) @@ -360,11 +373,31 @@ internal class AuthenticatorFacadeImpl( } }.cancellable().getOrElse { it.printStackTrace() - status.copy(lastIssue = it.toIssueCause(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) From 5d0c74edb304539b4c08eeca12d5a6c343523d0b Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 30 Apr 2026 10:37:11 +0200 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20Extract=20handleSingleAccountToReL?= =?UTF-8?q?oginOrSkip(=E2=80=A6)=20to=20simplify=20appStatusFlow()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/AuthenticatorFacadeImpl.kt | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt index 0fe65969..5e83f250 100644 --- a/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt +++ b/multiplatform-lib/src/commonMain/kotlin/internal/AuthenticatorFacadeImpl.kt @@ -183,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) { @@ -216,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) From 76528c6aa73123e0ee467bfe7d741c5151f26fe1 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 30 Apr 2026 10:47:33 +0200 Subject: [PATCH 7/7] refactor: Rename cause to issue in LoginFailed --- .../auth/ui/screen/accountdetails/ActionRequiredCard.kt | 6 +++--- multiplatform-lib/src/commonMain/kotlin/Account.kt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 50dbee88..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,9 +82,9 @@ fun ActionRequiredCard( ) } is Account.Status.NotConnected.LoginFailed -> { - when (status.cause) { + when (val issue = status.issue) { is Issue.Retriable -> { - val code = when (val cause = (status.cause as Issue.Retriable).cause) { + val code = when (val cause = issue.cause) { Issue.Retriable.Cause.NetworkIssue -> -1 Issue.Retriable.Cause.ServerUnavailable -> 503 is Issue.Retriable.Cause.Other -> cause.errorCode @@ -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 d2a27e15..db4a5d00 100644 --- a/multiplatform-lib/src/commonMain/kotlin/Account.kt +++ b/multiplatform-lib/src/commonMain/kotlin/Account.kt @@ -53,7 +53,7 @@ data class Account( val isSendingCredentials: Boolean get() = sendCredentials == null } - data class LoginFailed(val cause: Issue) : NotConnected + data class LoginFailed(val issue: Issue) : NotConnected } } }