Skip to content

feat(authenticator): Allow retry if fetchAuthSession fails #210

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
16 changes: 13 additions & 3 deletions authenticator/api/authenticator.api
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@ public final class com/amplifyframework/ui/authenticator/BuildConfig {

public final class com/amplifyframework/ui/authenticator/ErrorState : com/amplifyframework/ui/authenticator/AuthenticatorStepState {
public static final field $stable I
public fun <init> (Lcom/amplifyframework/auth/AuthException;)V
public fun <init> (Lcom/amplifyframework/auth/AuthException;Lkotlin/jvm/functions/Function1;)V
public synthetic fun <init> (Lcom/amplifyframework/auth/AuthException;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Lcom/amplifyframework/auth/AuthException;
public final fun copy (Lcom/amplifyframework/auth/AuthException;)Lcom/amplifyframework/ui/authenticator/ErrorState;
public static synthetic fun copy$default (Lcom/amplifyframework/ui/authenticator/ErrorState;Lcom/amplifyframework/auth/AuthException;ILjava/lang/Object;)Lcom/amplifyframework/ui/authenticator/ErrorState;
public final fun copy (Lcom/amplifyframework/auth/AuthException;Lkotlin/jvm/functions/Function1;)Lcom/amplifyframework/ui/authenticator/ErrorState;
public static synthetic fun copy$default (Lcom/amplifyframework/ui/authenticator/ErrorState;Lcom/amplifyframework/auth/AuthException;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/amplifyframework/ui/authenticator/ErrorState;
public fun equals (Ljava/lang/Object;)Z
public final fun getCanRetry ()Z
public final fun getError ()Lcom/amplifyframework/auth/AuthException;
public fun getStep ()Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$Error;
public synthetic fun getStep ()Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep;
public fun hashCode ()I
public final fun retry (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public fun toString ()Ljava/lang/String;
}

Expand Down Expand Up @@ -674,6 +677,13 @@ public final class com/amplifyframework/ui/authenticator/ui/AuthenticatorLoading
public static final fun AuthenticatorLoading (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
}

public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorErrorKt {
public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorErrorKt;
public static field lambda-1 Lkotlin/jvm/functions/Function3;
public fun <init> ()V
public final fun getLambda-1$authenticator_release ()Lkotlin/jvm/functions/Function3;
}

public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorFormKt {
public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorFormKt;
public static field lambda-1 Lkotlin/jvm/functions/Function2;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,14 @@ object LoadingState : AuthenticatorStepState {
* @param error The error that occurred.
*/
@Immutable
data class ErrorState(val error: AuthException) : AuthenticatorStepState {
data class ErrorState(
val error: AuthException,
private val onRetry: (suspend () -> Unit)? = null
) : AuthenticatorStepState {
override val step = AuthenticatorStep.Error
val canRetry = onRetry != null

suspend fun retry() = onRetry?.invoke()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.VisibleForTesting

internal class AuthenticatorViewModel(
application: Application,
private val authProvider: AuthProvider
) : AndroidViewModel(application) {
internal class AuthenticatorViewModel(application: Application, private val authProvider: AuthProvider) :
AndroidViewModel(application) {

// Constructor for compose viewModels provider
constructor(application: Application) : this(application, RealAuthProvider())
Expand Down Expand Up @@ -148,13 +146,7 @@ internal class AuthenticatorViewModel(
::moveTo
)

// Fetch the current session to determine if the user is already authenticated
val result = authProvider.fetchAuthSession()
when {
result is AmplifyResult.Error -> handleGeneralFailure(result.error)
result is AmplifyResult.Success && result.data.isSignedIn -> handleSignedIn()
else -> moveTo(configuration.initialStep)
}
checkInitialLogin()
}

// Respond to any events from Amplify Auth
Expand All @@ -168,6 +160,20 @@ internal class AuthenticatorViewModel(
}
}

private suspend fun checkInitialLogin() {
// Fetch the current session to determine if the user is already authenticated
val result = authProvider.fetchAuthSession()
when {
// Allow user to retry a failure from fetchAuthSession
result is AmplifyResult.Error -> handleRetryableGeneralFailure(
error = result.error,
onRetry = { viewModelScope.launch { checkInitialLogin() }.join() }
)
result is AmplifyResult.Success && result.data.isSignedIn -> handleSignedIn()
else -> moveTo(configuration.initialStep)
}
}

fun moveTo(initialStep: AuthenticatorInitialStep) {
logger.debug("Moving to initial step: $initialStep")
val state = when (initialStep) {
Expand Down Expand Up @@ -355,22 +361,15 @@ internal class AuthenticatorViewModel(
)
}

private suspend fun handleEmailMfaSetupRequired(
username: String,
password: String
) {
private suspend fun handleEmailMfaSetupRequired(username: String, password: String) {
moveTo(
stateFactory.newSignInContinueWithEmailSetupState(
onSubmit = { mfaType -> confirmSignIn(username, password, mfaType) }
)
)
}

private suspend fun handleMfaSelectionRequired(
username: String,
password: String,
allowedMfaTypes: Set<MFAType>?
) {
private suspend fun handleMfaSelectionRequired(username: String, password: String, allowedMfaTypes: Set<MFAType>?) {
if (allowedMfaTypes.isNullOrEmpty()) {
handleGeneralFailure(AuthException("Missing allowedMfaTypes", "Please open a bug with Amplify"))
return
Expand Down Expand Up @@ -492,10 +491,7 @@ internal class AuthenticatorViewModel(
}.join()
}

private suspend fun handleResetPasswordSuccess(
username: String,
result: AuthResetPasswordResult
) {
private suspend fun handleResetPasswordSuccess(username: String, result: AuthResetPasswordResult) {
when (result.nextStep.resetPasswordStep) {
AuthResetPasswordStep.DONE -> handlePasswordResetComplete()
AuthResetPasswordStep.CONFIRM_RESET_PASSWORD_WITH_CODE -> {
Expand Down Expand Up @@ -626,7 +622,10 @@ internal class AuthenticatorViewModel(
logger.error("Current signed in user session has expired, signing out.")
signOut()
} else {
handleGeneralFailure(result.error)
handleRetryableGeneralFailure(
error = result.error,
onRetry = { viewModelScope.launch { handleSignedIn() }.join() }
)
}
}

Expand All @@ -649,6 +648,11 @@ internal class AuthenticatorViewModel(
moveTo(ErrorState(error))
}

private fun handleRetryableGeneralFailure(error: AuthException, onRetry: suspend () -> Unit) {
logger.error(error.toString())
moveTo(ErrorState(error, onRetry))
}

private suspend fun sendMessage(event: AuthenticatorMessage) {
logger.debug("Sending message: $event")
_events.emit(event)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ fun Authenticator(
Box(modifier = modifier) {
AnimatedContent(
targetState = stepState,
contentKey = { targetState -> targetState::class },
transitionSpec = { defaultTransition() },
label = "AuthenticatorContentTransition"
) { targetState ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,39 +15,65 @@

package com.amplifyframework.ui.authenticator.ui

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.amplifyframework.ui.authenticator.ErrorState
import com.amplifyframework.ui.authenticator.R
import com.amplifyframework.ui.authenticator.strings.StringResolver
import kotlinx.coroutines.launch

/**
* The content displayed when Authenticator is in the ErrorState
*/
@Composable
fun AuthenticatorError(
state: ErrorState,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
val message = StringResolver.error(state.error)
Text(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer
)
fun AuthenticatorError(state: ErrorState, modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(MaterialTheme.colorScheme.errorContainer)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
val message = StringResolver.error(state.error)
Text(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
AnimatedVisibility(state.canRetry) {
var retrying by remember { mutableStateOf(false) }
TextButton(
onClick = {
scope.launch {
retrying = true
state.retry()
retrying = false
}
},
enabled = !retrying
) {
Text(stringResource(R.string.amplify_ui_authenticator_button_retry))
}
}
}
}
1 change: 1 addition & 0 deletions authenticator/src/main/res/values/buttons.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@
<string name="amplify_ui_authenticator_button_resend_code">Send Code</string>
<string name="amplify_ui_authenticator_button_skip">Skip</string>
<string name="amplify_ui_authenticator_button_copy_key">Copy Key</string>
<string name="amplify_ui_authenticator_button_retry">Retry</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import com.amplifyframework.auth.result.step.AuthResetPasswordStep
import com.amplifyframework.auth.result.step.AuthSignInStep
import com.amplifyframework.ui.authenticator.auth.VerificationMechanism
import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep
import com.amplifyframework.ui.authenticator.util.AmplifyResult
import com.amplifyframework.ui.authenticator.util.AmplifyResult.Error
import com.amplifyframework.ui.authenticator.util.AmplifyResult.Success
import com.amplifyframework.ui.authenticator.util.AuthConfigurationResult
Expand All @@ -38,6 +37,7 @@ import com.amplifyframework.ui.authenticator.util.LimitExceededMessage
import com.amplifyframework.ui.authenticator.util.NetworkErrorMessage
import com.amplifyframework.ui.testing.CoroutineTestRule
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
Expand Down Expand Up @@ -108,7 +108,7 @@ class AuthenticatorViewModelTest {

@Test
fun `fetchAuthSession error during start results in an error`() = runTest {
coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Error(mockAuthException())
coEvery { authProvider.fetchAuthSession() } returns Error(mockAuthException())

viewModel.start(mockAuthenticatorConfiguration())
advanceUntilIdle()
Expand All @@ -117,10 +117,28 @@ class AuthenticatorViewModelTest {
viewModel.currentStep shouldBe AuthenticatorStep.Error
}

@Test
fun `fetchAuthSession error can be retried`() = runTest {
coEvery { authProvider.fetchAuthSession() } returns
Error(mockAuthException()) andThen Success(mockAuthSession())

viewModel.start(mockAuthenticatorConfiguration())
advanceUntilIdle()

val state = viewModel.stepState.value.shouldBeInstanceOf<ErrorState>()
state.retry()
advanceUntilIdle()

viewModel.currentStep shouldBe AuthenticatorStep.SignIn
coVerify(exactly = 2) {
authProvider.fetchAuthSession()
}
}

@Test
fun `getCurrentUser error during start results in an error`() = runTest {
coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true))
coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Error(mockAuthException())
coEvery { authProvider.getCurrentUser() } returns Error(mockAuthException())

viewModel.start(mockAuthenticatorConfiguration())
advanceUntilIdle()
Expand All @@ -135,7 +153,7 @@ class AuthenticatorViewModelTest {
@Test
fun `getCurrentUser error with session expired exception during start results in being signed out`() = runTest {
coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true))
coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Error(SessionExpiredException())
coEvery { authProvider.getCurrentUser() } returns Error(SessionExpiredException())

viewModel.start(mockAuthenticatorConfiguration())
advanceUntilIdle()
Expand All @@ -147,6 +165,27 @@ class AuthenticatorViewModelTest {
}
}

@Test
fun `getCurrentUser error can be retried`() = runTest {
coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true))
coEvery { authProvider.getCurrentUser() } returns Error(mockAuthException()) andThen Success(mockAuthUser())

viewModel.start(mockAuthenticatorConfiguration())
advanceUntilIdle()

val state = viewModel.stepState.value.shouldBeInstanceOf<ErrorState>()
state.retry()
advanceUntilIdle()

viewModel.currentStep shouldBe AuthenticatorStep.SignedIn
coVerify(exactly = 1) {
authProvider.fetchAuthSession()
}
coVerify(exactly = 2) {
authProvider.getCurrentUser()
}
}

@Test
fun `when already signed in during start the initial state should be signed in`() = runTest {
coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true))
Expand Down Expand Up @@ -265,7 +304,7 @@ class AuthenticatorViewModelTest {
coEvery { authProvider.signIn(any(), any()) } returns Success(
mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_UP)
)
coEvery { authProvider.resendSignUpCode(any()) } returns AmplifyResult.Error(mockAuthException())
coEvery { authProvider.resendSignUpCode(any()) } returns Error(mockAuthException())

viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn))

Expand Down Expand Up @@ -394,7 +433,7 @@ class AuthenticatorViewModelTest {
verificationMechanisms = setOf(VerificationMechanism.Email)
)
// cannot fetch user attributes
coEvery { authProvider.fetchUserAttributes() } returns AmplifyResult.Error(mockk(relaxed = true))
coEvery { authProvider.fetchUserAttributes() } returns Error(mockk(relaxed = true))

viewModel.start(mockAuthenticatorConfiguration())
viewModel.signIn("username", "password")
Expand Down Expand Up @@ -571,6 +610,7 @@ class AuthenticatorViewModelTest {
viewModel.resetPassword("username")
}
}

//endregion
//region helpers
private val AuthenticatorViewModel.currentStep: AuthenticatorStep
Expand Down