Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,15 +53,18 @@ 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",
initials = "JS",
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 = { _ -> },
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
}
)
Expand Down
9 changes: 7 additions & 2 deletions multiplatform-lib/src/commonMain/kotlin/Account.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
10 changes: 5 additions & 5 deletions multiplatform-lib/src/commonMain/kotlin/Issue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -211,6 +206,20 @@ internal class AuthenticatorFacadeImpl(
emitAll(appStatusFlow)
}.distinctUntilChanged()

private suspend fun FlowCollector<AppStatus.LoginRequired>.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<Account.Status.NotConnected?> =
dao.getAccountAsFlow(userId).transformLatest { entity ->
emit(null)
Expand Down Expand Up @@ -343,12 +352,21 @@ internal class AuthenticatorFacadeImpl(
lastIssue = null,
sendCredentials = null
)
val issueDismissals = Channel<Unit>(capacity = Channel.CONFLATED)
loop@ while (true) {
Comment thread
LouisCAD marked this conversation as resolved.
status = runCatching {
val credentialsAsync = CompletableDeferred<CredentialsForMigration>()
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)
Expand All @@ -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<CredentialsForMigration>,
issueDismissals: ReceiveChannel<Unit>,
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<Account.Status.NotConnected>.restoreFromBackupAttempts(account: AccountEntity) {
withRetries(userId = account.id) {
emit(Account.Status.NotConnected.AttemptingToConnect)
Expand All @@ -388,30 +426,30 @@ internal class AuthenticatorFacadeImpl(
emit(Account.Status.NotConnected.LoginFailed(issue))
awaitCancellation()
}
val issueReason = it.toIssueReason(userId)
val issueReason = it.toIssueCause(userId)
val shouldRetryAsync = CompletableDeferred<Boolean>()
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()
}
}
}

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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,18 @@ 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()
val requestContextId = response?.getRequestContextId() ?: ""
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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
LouisCAD marked this conversation as resolved.
Loading