diff --git a/app/src/main/kotlin/org/cru/godtools/ui/login/LoginActivity.kt b/app/src/main/kotlin/org/cru/godtools/ui/login/LoginActivity.kt index dac91e7f16..7fbf6d34fc 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/login/LoginActivity.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/login/LoginActivity.kt @@ -11,7 +11,6 @@ import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import org.cru.godtools.account.GodToolsAccountManager import org.cru.godtools.base.ui.activity.BaseActivity import org.cru.godtools.base.ui.theme.GodToolsTheme @@ -28,13 +27,11 @@ fun Context.startLoginActivity(createAccount: Boolean = false) = startActivity( class LoginActivity : BaseActivity() { @Inject internal lateinit var accountManager: GodToolsAccountManager - private lateinit var loginState: GodToolsAccountManager.LoginState private val createAccount get() = intent?.getBooleanExtra(EXTRA_CREATE, false) ?: false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - loginState = accountManager.prepareForLogin(this) finishWhenAuthenticated() setContent { @@ -43,8 +40,6 @@ class LoginActivity : BaseActivity() { createAccount = createAccount, onEvent = { when (it) { - is LoginLayoutEvent.Login -> - lifecycleScope.launch { accountManager.login(it.type, loginState) } LoginLayoutEvent.Close -> finish() } } diff --git a/app/src/main/kotlin/org/cru/godtools/ui/login/LoginLayout.kt b/app/src/main/kotlin/org/cru/godtools/ui/login/LoginLayout.kt index 982c5139d4..f74a0bf9c0 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/login/LoginLayout.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/login/LoginLayout.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -19,10 +20,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -33,6 +39,8 @@ import androidx.compose.ui.unit.dp import org.ccci.gto.android.common.androidx.compose.foundation.layout.padding import org.cru.godtools.R import org.cru.godtools.account.AccountType +import org.cru.godtools.account.LoginResponse +import org.cru.godtools.account.compose.rememberLoginLauncher import org.cru.godtools.base.ui.theme.GodToolsTheme private val MARGIN_HORIZONTAL = 32.dp @@ -40,7 +48,17 @@ private val FACEBOOK_BLUE = Color(red = 0x18, green = 0x77, blue = 0xf2) @Composable @OptIn(ExperimentalMaterial3Api::class) -fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEvent) -> Unit = {}) { +fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEvent) -> Unit) { + var loginError: LoginResponse.Error? by rememberSaveable { mutableStateOf(null) } + val loginLauncher = rememberLoginLauncher(createAccount) { + when (it) { + LoginResponse.Success -> onEvent(LoginLayoutEvent.Close) + is LoginResponse.Error -> loginError = it + } + } + + LoginError(loginError, onDismiss = { loginError = null }) + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -86,7 +104,7 @@ fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEven ) Button( - onClick = { onEvent(LoginLayoutEvent.Login(AccountType.GOOGLE)) }, + onClick = { loginLauncher.launch(AccountType.GOOGLE) }, colors = ButtonDefaults.buttonColors( containerColor = Color.White, contentColor = Color.Black.copy(alpha = 0.54f) @@ -104,7 +122,7 @@ fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEven Text(stringResource(com.google.android.gms.base.R.string.common_signin_button_text_long)) } Button( - onClick = { onEvent(LoginLayoutEvent.Login(AccountType.FACEBOOK)) }, + onClick = { loginLauncher.launch(AccountType.FACEBOOK) }, colors = ButtonDefaults.buttonColors( containerColor = FACEBOOK_BLUE, contentColor = Color.White @@ -124,3 +142,28 @@ fun LoginLayout(createAccount: Boolean = false, onEvent: (event: LoginLayoutEven } } } + +@Composable +private fun LoginError(error: LoginResponse.Error?, onDismiss: () -> Unit) { + if (error != null) { + AlertDialog( + text = { + Text( + stringResource( + when (error) { + LoginResponse.Error.UserAlreadyExists -> R.string.account_error_user_already_exists + LoginResponse.Error.UserNotFound -> R.string.account_error_user_not_found + else -> R.string.account_error_unknown + } + ) + ) + }, + confirmButton = { + TextButton(onClick = { onDismiss() }) { + Text(stringResource(R.string.account_error_dialog_dismiss)) + } + }, + onDismissRequest = { onDismiss() }, + ) + } +} diff --git a/app/src/main/kotlin/org/cru/godtools/ui/login/LoginLayoutEvent.kt b/app/src/main/kotlin/org/cru/godtools/ui/login/LoginLayoutEvent.kt index 9d3e3bb188..6f3cb8c1d5 100644 --- a/app/src/main/kotlin/org/cru/godtools/ui/login/LoginLayoutEvent.kt +++ b/app/src/main/kotlin/org/cru/godtools/ui/login/LoginLayoutEvent.kt @@ -1,8 +1,5 @@ package org.cru.godtools.ui.login -import org.cru.godtools.account.AccountType - sealed class LoginLayoutEvent { - class Login(val type: AccountType) : LoginLayoutEvent() - object Close : LoginLayoutEvent() + data object Close : LoginLayoutEvent() } diff --git a/app/src/main/res/values/strings_account.xml b/app/src/main/res/values/strings_account.xml index 578dda81c9..490b6fe52b 100644 --- a/app/src/main/res/values/strings_account.xml +++ b/app/src/main/res/values/strings_account.xml @@ -9,6 +9,10 @@ Create an account to have real stories, encouragement, and practical tips at your fingertips. Sign in Log in to your account to have real stories, encouragement, and practical tips at your fingertips. + User account already exists. + User account not found. + Error logging in. + OK diff --git a/app/src/test/kotlin/org/cru/godtools/ExternalSingletonsModule.kt b/app/src/test/kotlin/org/cru/godtools/ExternalSingletonsModule.kt index 01b27784a7..bda7e0e6cb 100644 --- a/app/src/test/kotlin/org/cru/godtools/ExternalSingletonsModule.kt +++ b/app/src/test/kotlin/org/cru/godtools/ExternalSingletonsModule.kt @@ -53,7 +53,6 @@ class ExternalSingletonsModule { val accountManager by lazy { mockk { every { isAuthenticatedFlow } returns flowOf(false) - every { prepareForLogin(any()) } returns mockk() } } @get:Provides diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0be36d0c3f..a2698e04fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -103,6 +103,7 @@ godtoolsShared-user-activity = { module = "org.cru.godtools.kotlin:user-activity google-auto-value = { module = "com.google.auto.value:auto-value", version.ref = "google-auto-value" } google-auto-value-annotations = { module = "com.google.auto.value:auto-value-annotations", version.ref = "google-auto-value" } gradleDownloadTask = "de.undercouch:gradle-download-task:5.5.0" +gtoSupport-androidx-activity = { module = "org.ccci.gto.android:gto-support-androidx-activity", version.ref = "gtoSupport" } gtoSupport-androidx-collection = { module = "org.ccci.gto.android:gto-support-androidx-collection", version.ref = "gtoSupport" } gtoSupport-androidx-compose = { module = "org.ccci.gto.android:gto-support-androidx-compose", version.ref = "gtoSupport" } gtoSupport-androidx-compose-material3 = { module = "org.ccci.gto.android:gto-support-androidx-compose-material3", version.ref = "gtoSupport" } diff --git a/library/account/build.gradle.kts b/library/account/build.gradle.kts index 6498ab441d..b2a11d2b5e 100644 --- a/library/account/build.gradle.kts +++ b/library/account/build.gradle.kts @@ -3,11 +3,18 @@ plugins { alias(libs.plugins.ksp) } -android.namespace = "org.cru.godtools.account" +android { + namespace = "org.cru.godtools.account" + + configureCompose(project) +} dependencies { implementation(project(":library:api")) + implementation(libs.androidx.activity.compose) + + implementation(libs.gtoSupport.androidx.activity) implementation(libs.gtoSupport.core) implementation(libs.gtoSupport.facebook) implementation(libs.gtoSupport.kotlin.coroutines) @@ -28,6 +35,7 @@ dependencies { ksp(libs.dagger.compiler) ksp(libs.hilt.compiler) + testImplementation(libs.json) testImplementation(libs.kotlin.coroutines.test) testImplementation(libs.turbine) } diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/AccountModule.kt b/library/account/src/main/kotlin/org/cru/godtools/account/AccountModule.kt index 265ad9008b..4639c8ba3d 100644 --- a/library/account/src/main/kotlin/org/cru/godtools/account/AccountModule.kt +++ b/library/account/src/main/kotlin/org/cru/godtools/account/AccountModule.kt @@ -25,7 +25,7 @@ abstract class AccountModule { accountManager: GodToolsAccountManager ) = object : MobileContentApiSessionInterceptor(context) { override fun userId() = accountManager.userId - override suspend fun authenticate() = accountManager.authenticateWithMobileContentApi() + override suspend fun authenticate() = accountManager.authenticateWithMobileContentApi().getOrNull() } } } diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/GodToolsAccountManager.kt b/library/account/src/main/kotlin/org/cru/godtools/account/GodToolsAccountManager.kt index 20477a6d59..2236d2e01b 100644 --- a/library/account/src/main/kotlin/org/cru/godtools/account/GodToolsAccountManager.kt +++ b/library/account/src/main/kotlin/org/cru/godtools/account/GodToolsAccountManager.kt @@ -1,7 +1,10 @@ package org.cru.godtools.account -import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.core.app.ActivityOptionsCompat import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.ccci.gto.android.common.Ordered import org.cru.godtools.account.provider.AccountProvider +import org.cru.godtools.account.provider.AuthenticationException @Singleton @OptIn(ExperimentalCoroutinesApi::class) @@ -58,19 +62,44 @@ class GodToolsAccountManager @VisibleForTesting internal constructor( .distinctUntilChanged() // region Login/Logout - class LoginState internal constructor(internal val providerState: Map) + @Composable + internal fun rememberLauncherForLogin( + createAccount: Boolean, + onResponse: (LoginResponse) -> Unit, + ): ActivityResultLauncher { + val launchers = providers.associate { + it.type to it.rememberLauncherForLogin(createAccount) { result -> + onResponse( + when { + result.isSuccess -> LoginResponse.Success + else -> when (result.exceptionOrNull()) { + AuthenticationException.UserNotFound -> LoginResponse.Error.UserNotFound + AuthenticationException.UserAlreadyExists -> LoginResponse.Error.UserAlreadyExists + else -> LoginResponse.Error() + } + } + ) + } + } - fun prepareForLogin(activity: ComponentActivity) = - LoginState(providers.associate { it.type to it.prepareForLogin(activity) }) - suspend fun login(type: AccountType, state: LoginState) { - val providerState = state.providerState[type] ?: return - providers.first { it.type == type }.login(providerState) + return remember(launchers) { + object : ActivityResultLauncher() { + override fun launch(input: AccountType, options: ActivityOptionsCompat?) { + launchers[input]?.launch(input, options) + } + + override fun unregister() = TODO("Unsupported") + override fun getContract() = TODO("Unsupported") + } + } } + suspend fun logout() = coroutineScope { // trigger a logout for any provider we happen to be logged into providers.forEach { launch { it.logout() } } } // endregion Login/Logout - internal suspend fun authenticateWithMobileContentApi() = activeProvider?.authenticateWithMobileContentApi() + internal suspend fun authenticateWithMobileContentApi() = activeProvider?.authenticateWithMobileContentApi(false) + ?: Result.failure(AuthenticationException.NoActiveProvider) } diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/LoginResponse.kt b/library/account/src/main/kotlin/org/cru/godtools/account/LoginResponse.kt new file mode 100644 index 0000000000..8c15df88c6 --- /dev/null +++ b/library/account/src/main/kotlin/org/cru/godtools/account/LoginResponse.kt @@ -0,0 +1,9 @@ +package org.cru.godtools.account + +sealed interface LoginResponse { + data object Success : LoginResponse + open class Error : LoginResponse { + data object UserNotFound : Error() + data object UserAlreadyExists : Error() + } +} diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/compose/LocalGodToolsAccountManager.kt b/library/account/src/main/kotlin/org/cru/godtools/account/compose/LocalGodToolsAccountManager.kt new file mode 100644 index 0000000000..a8269f6683 --- /dev/null +++ b/library/account/src/main/kotlin/org/cru/godtools/account/compose/LocalGodToolsAccountManager.kt @@ -0,0 +1,36 @@ +package org.cru.godtools.account.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.platform.LocalContext +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import org.cru.godtools.account.GodToolsAccountManager + +internal object LocalGodToolsAccountManager { + private val LocalComposition = compositionLocalOf { null } + + /** + * Returns current composition local value for the manager or fallback to resolution using Dagger. + */ + val current: GodToolsAccountManager + @Composable + get() = LocalComposition.current + ?: EntryPointAccessors.fromApplication(LocalContext.current).accountManager + + /** + * Associates a [GodToolsAccountManager] key to a value in a call to [CompositionLocalProvider]. + */ + infix fun provides(accountManager: GodToolsAccountManager): ProvidedValue { + return LocalComposition.provides(accountManager) + } +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +internal interface AccountEntryPoint { + val accountManager: GodToolsAccountManager +} diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/compose/LoginLauncher.kt b/library/account/src/main/kotlin/org/cru/godtools/account/compose/LoginLauncher.kt new file mode 100644 index 0000000000..c5f5f651dd --- /dev/null +++ b/library/account/src/main/kotlin/org/cru/godtools/account/compose/LoginLauncher.kt @@ -0,0 +1,8 @@ +package org.cru.godtools.account.compose + +import androidx.compose.runtime.Composable +import org.cru.godtools.account.LoginResponse + +@Composable +fun rememberLoginLauncher(createAccount: Boolean, onResponse: (LoginResponse) -> Unit) = + LocalGodToolsAccountManager.current.rememberLauncherForLogin(createAccount, onResponse) diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/provider/AccountProvider.kt b/library/account/src/main/kotlin/org/cru/godtools/account/provider/AccountProvider.kt index 7038298c9b..d1acf58a8d 100644 --- a/library/account/src/main/kotlin/org/cru/godtools/account/provider/AccountProvider.kt +++ b/library/account/src/main/kotlin/org/cru/godtools/account/provider/AccountProvider.kt @@ -1,10 +1,15 @@ package org.cru.godtools.account.provider -import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.compose.runtime.Composable import kotlinx.coroutines.flow.Flow import org.ccci.gto.android.common.Ordered +import org.ccci.gto.android.common.jsonapi.JsonApiConverter +import org.ccci.gto.android.common.jsonapi.model.JsonApiObject import org.cru.godtools.account.AccountType import org.cru.godtools.api.model.AuthToken +import org.json.JSONException +import retrofit2.Response internal interface AccountProvider : Ordered { val type: AccountType @@ -15,12 +20,48 @@ internal interface AccountProvider : Ordered { fun userIdFlow(): Flow // region Login/Logout - open class LoginState internal constructor(val activity: ComponentActivity) + @Composable + fun rememberLauncherForLogin( + createUser: Boolean, + onAuthResult: (Result) -> Unit, + ): ActivityResultLauncher - fun prepareForLogin(activity: ComponentActivity) = LoginState(activity) - suspend fun login(state: LoginState) suspend fun logout() // endregion Login/Logout - suspend fun authenticateWithMobileContentApi(): AuthToken? + suspend fun authenticateWithMobileContentApi(createUser: Boolean): Result +} + +internal fun Response>.extractAuthToken() = when { + isSuccessful -> { + val obj = body() + val data = obj?.dataSingle + + when { + obj == null -> Result.failure(AuthenticationException.UnknownError) + obj.hasErrors -> obj.parseErrors() + data == null -> Result.failure(AuthenticationException.UnknownError) + else -> Result.success(data) + } + } + else -> errorBody()?.string() + ?.let { + try { + JsonApiConverter.Builder().build().fromJson(it, AuthToken::class.java) + } catch (_: JSONException) { + null + } + } + ?.parseErrors() + ?: Result.failure(AuthenticationException.UnknownError) +} + +private fun JsonApiObject.parseErrors(): Result { + errors.forEach { + when (it.code) { + AuthToken.ERROR_USER_ALREADY_EXISTS -> return Result.failure(AuthenticationException.UserAlreadyExists) + AuthToken.ERROR_USER_NOT_FOUND -> return Result.failure(AuthenticationException.UserNotFound) + } + } + return Result.failure(AuthenticationException.UnknownError) } diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/provider/AuthenticationException.kt b/library/account/src/main/kotlin/org/cru/godtools/account/provider/AuthenticationException.kt new file mode 100644 index 0000000000..cd2c297614 --- /dev/null +++ b/library/account/src/main/kotlin/org/cru/godtools/account/provider/AuthenticationException.kt @@ -0,0 +1,11 @@ +package org.cru.godtools.account.provider + +internal sealed class AuthenticationException : Exception() { + data object NoActiveProvider : AuthenticationException() + data object MissingCredentials : AuthenticationException() + data object UnableToRefreshCredentials : AuthenticationException() + data object UnknownError : AuthenticationException() + + data object UserAlreadyExists : AuthenticationException() + data object UserNotFound : AuthenticationException() +} diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProvider.kt b/library/account/src/main/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProvider.kt index 17be788548..9de66fdf79 100644 --- a/library/account/src/main/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProvider.kt +++ b/library/account/src/main/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProvider.kt @@ -1,8 +1,11 @@ package org.cru.godtools.account.provider.facebook import android.content.Context -import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.core.content.edit import com.facebook.AccessToken import com.facebook.AccessTokenManager @@ -12,14 +15,19 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import org.ccci.gto.android.common.androidx.activity.result.contract.transformInput import org.ccci.gto.android.common.facebook.login.currentAccessTokenFlow -import org.ccci.gto.android.common.facebook.login.isAuthenticatedFlow +import org.ccci.gto.android.common.facebook.login.isExpiredFlow import org.ccci.gto.android.common.facebook.login.refreshCurrentAccessToken import org.ccci.gto.android.common.kotlin.coroutines.getStringFlow import org.cru.godtools.account.AccountType import org.cru.godtools.account.provider.AccountProvider +import org.cru.godtools.account.provider.AuthenticationException +import org.cru.godtools.account.provider.extractAuthToken import org.cru.godtools.api.AuthApi import org.cru.godtools.api.model.AuthToken @@ -46,28 +54,56 @@ internal class FacebookAccountProvider @Inject constructor( @VisibleForTesting internal val prefs by lazy { context.getSharedPreferences(PREFS_FACEBOOK_ACCOUNT_PROVIDER, Context.MODE_PRIVATE) } - override val userId get() = accessTokenManager.currentAccessToken?.let { prefs.getString(it.PREF_USER_ID, null) } - override val isAuthenticated get() = accessTokenManager.currentAccessToken?.isExpired == false + override val userId get() = accessTokenManager.currentAccessToken?.apiUserId + override val isAuthenticated + get() = accessTokenManager.currentAccessToken?.let { !it.isExpired && it.apiUserId != null } == true override fun userIdFlow() = accessTokenManager.currentAccessTokenFlow() - .flatMapLatest { it?.let { prefs.getStringFlow(it.PREF_USER_ID, null) } ?: flowOf(null) } - override fun isAuthenticatedFlow() = accessTokenManager.isAuthenticatedFlow() + .flatMapLatest { it?.apiUserIdFlow() ?: flowOf(null) } + override fun isAuthenticatedFlow() = accessTokenManager.currentAccessTokenFlow() + .flatMapLatest { + when { + it != null -> combine(it.isExpiredFlow(), it.apiUserIdFlow()) { isExpired, userId -> + !isExpired && userId != null + } + else -> flowOf(false) + } + } + + private val AccessToken.apiUserId get() = prefs.getString(PREF_USER_ID, null) + private fun AccessToken.apiUserIdFlow() = prefs.getStringFlow(PREF_USER_ID, null) // region Login/Logout - inner class FacebookLoginState(activity: ComponentActivity) : AccountProvider.LoginState(activity) { - val launcher = activity.registerForActivityResult(loginManager.createLogInActivityResultContract()) {} + @Composable + override fun rememberLauncherForLogin( + createUser: Boolean, + onAuthResult: (Result) -> Unit + ): ActivityResultLauncher { + val coroutineScope = rememberCoroutineScope() + + return rememberLauncherForActivityResult( + contract = loginManager.createLogInActivityResultContract() + .transformInput { _: AccountType -> FACEBOOK_SCOPE }, + onResult = { + coroutineScope.launch { + onAuthResult( + authenticateWithMobileContentApi(createUser) + .onFailure { logout() } + ) + } + }, + ) } - override fun prepareForLogin(activity: ComponentActivity) = FacebookLoginState(activity) - override suspend fun login(state: AccountProvider.LoginState) { - if (state !is FacebookLoginState) return - state.launcher.launch(FACEBOOK_SCOPE) + override suspend fun logout() { + loginManager.logOut() + prefs.edit { clear() } } - override suspend fun logout() = loginManager.logOut() // endregion Login/Logout - override suspend fun authenticateWithMobileContentApi(): AuthToken? { - var accessToken = accessTokenManager.currentAccessToken ?: return null - var resp = accessToken.authenticateWithMobileContentApi() + override suspend fun authenticateWithMobileContentApi(createUser: Boolean): Result { + var accessToken = accessTokenManager.currentAccessToken + ?: return Result.failure(AuthenticationException.MissingCredentials) + var resp = accessToken.authenticateWithMobileContentApi(createUser) // try refreshing the access token if the API rejected it if (!resp.isSuccessful) { @@ -75,15 +111,14 @@ internal class FacebookAccountProvider @Inject constructor( accessTokenManager.refreshCurrentAccessToken() } catch (e: FacebookException) { null - } ?: return null - resp = accessToken.authenticateWithMobileContentApi() + } ?: return Result.failure(AuthenticationException.UnableToRefreshCredentials) + resp = accessToken.authenticateWithMobileContentApi(createUser) } - return resp.takeIf { it.isSuccessful } - ?.body()?.takeUnless { it.hasErrors }?.dataSingle - ?.also { prefs.edit { putString(accessToken.PREF_USER_ID, it.userId) } } + return resp.extractAuthToken() + .onSuccess { prefs.edit { putString(accessToken.PREF_USER_ID, it.userId) } } } - private suspend fun AccessToken.authenticateWithMobileContentApi() = - authApi.authenticate(AuthToken.Request(fbAccessToken = token)) + private suspend fun AccessToken.authenticateWithMobileContentApi(createUser: Boolean) = + authApi.authenticate(AuthToken.Request(fbAccessToken = token, createUser = createUser)) } diff --git a/library/account/src/main/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProvider.kt b/library/account/src/main/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProvider.kt index 45277d75df..9e29e1c195 100644 --- a/library/account/src/main/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProvider.kt +++ b/library/account/src/main/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProvider.kt @@ -1,7 +1,12 @@ package org.cru.godtools.account.provider.google import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.core.content.edit import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInAccount @@ -14,11 +19,15 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await +import org.ccci.gto.android.common.androidx.activity.result.contract.transformInput import org.ccci.gto.android.common.kotlin.coroutines.getStringFlow import org.ccci.gto.android.common.play.auth.signin.GoogleSignInKtx import org.cru.godtools.account.AccountType import org.cru.godtools.account.provider.AccountProvider +import org.cru.godtools.account.provider.AuthenticationException +import org.cru.godtools.account.provider.extractAuthToken import org.cru.godtools.api.AuthApi import org.cru.godtools.api.model.AuthToken import timber.log.Timber @@ -44,16 +53,33 @@ internal class GoogleAccountProvider @Inject constructor( internal val prefs by lazy { context.getSharedPreferences(PREFS_GOOGLE_ACCOUNT_PROVIDER, Context.MODE_PRIVATE) } override val type = AccountType.GOOGLE - override val isAuthenticated get() = GoogleSignIn.getLastSignedInAccount(context) != null + override val isAuthenticated get() = userId != null override val userId get() = GoogleSignIn.getLastSignedInAccount(context) ?.let { prefs.getString(it.PREF_USER_ID, null) } - override fun isAuthenticatedFlow() = GoogleSignInKtx.getLastSignedInAccountFlow(context).map { it != null } + override fun isAuthenticatedFlow() = userIdFlow().map { it != null } override fun userIdFlow() = GoogleSignInKtx.getLastSignedInAccountFlow(context) .flatMapLatest { it?.let { prefs.getStringFlow(it.PREF_USER_ID, null) } ?: flowOf(null) } // region Login/Logout - override suspend fun login(state: AccountProvider.LoginState) { - state.activity.startActivity(googleSignInClient.signInIntent) + @Composable + override fun rememberLauncherForLogin( + createUser: Boolean, + onAuthResult: (Result) -> Unit + ): ActivityResultLauncher { + val coroutineScope = rememberCoroutineScope() + + return rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + .transformInput { _: AccountType -> googleSignInClient.signInIntent }, + onResult = { + coroutineScope.launch { + onAuthResult( + authenticateWithMobileContentApi(createUser) + .onFailure { logout() } + ) + } + }, + ) } private suspend fun refreshSignIn() = try { @@ -65,23 +91,25 @@ internal class GoogleAccountProvider @Inject constructor( override suspend fun logout() { googleSignInClient.signOut().await() + prefs.edit { clear() } } // endregion Login/Logout - override suspend fun authenticateWithMobileContentApi(): AuthToken? { - var account = GoogleSignIn.getLastSignedInAccount(context) ?: return null - var resp = account.authenticateWithMobileContentApi() + override suspend fun authenticateWithMobileContentApi(createUser: Boolean): Result { + var account = GoogleSignIn.getLastSignedInAccount(context) + ?: return Result.failure(AuthenticationException.MissingCredentials) + var resp = account.authenticateWithMobileContentApi(createUser) if (account.idToken == null || resp?.isSuccessful != true) { - account = refreshSignIn() ?: return null - resp = account.authenticateWithMobileContentApi() ?: return null + account = refreshSignIn() ?: return Result.failure(AuthenticationException.UnableToRefreshCredentials) + resp = account.authenticateWithMobileContentApi(createUser) + ?: return Result.failure(AuthenticationException.MissingCredentials) } - val token = resp.takeIf { it.isSuccessful }?.body()?.takeUnless { it.hasErrors }?.dataSingle ?: return null - prefs.edit { putString(account.PREF_USER_ID, token.userId) } - return token + return resp.extractAuthToken() + .onSuccess { prefs.edit { putString(account.PREF_USER_ID, it.userId) } } } - private suspend fun GoogleSignInAccount.authenticateWithMobileContentApi() = - idToken?.let { authApi.authenticate(AuthToken.Request(googleIdToken = it)) } + private suspend fun GoogleSignInAccount.authenticateWithMobileContentApi(createUser: Boolean) = + idToken?.let { authApi.authenticate(AuthToken.Request(googleIdToken = it, createUser = createUser)) } } diff --git a/library/account/src/test/kotlin/org/cru/godtools/account/provider/AccountProviderTest.kt b/library/account/src/test/kotlin/org/cru/godtools/account/provider/AccountProviderTest.kt new file mode 100644 index 0000000000..d3824ce525 --- /dev/null +++ b/library/account/src/test/kotlin/org/cru/godtools/account/provider/AccountProviderTest.kt @@ -0,0 +1,87 @@ +package org.cru.godtools.account.provider + +import kotlin.test.Test +import kotlin.test.assertEquals +import okhttp3.ResponseBody.Companion.toResponseBody +import org.ccci.gto.android.common.jsonapi.JsonApiConverter +import org.ccci.gto.android.common.jsonapi.model.JsonApiError +import org.ccci.gto.android.common.jsonapi.model.JsonApiObject +import org.cru.godtools.api.model.AuthToken +import retrofit2.Response + +private const val ERROR_USER_ALREADY_EXISTS = """ + {"errors":[{"code":"user_already_exists","detail":"User account already exists."}]} +""" +private const val ERROR_USER_NOT_FOUND = """ + {"errors":[{"code":"user_not_found","detail":"User account not found."}]} +""" + +class AccountProviderTest { + // region extractAuthToken() + @Test + fun `extractAuthToken() - Successful`() { + val token = AuthToken() + val response = Response.success(JsonApiObject.of(token)) + + assertEquals(token, response.extractAuthToken().getOrNull()) + } + + @Test + fun `extractAuthToken() - Error - Successful status with jsonapi error`() { + val error = JsonApiError(code = AuthToken.ERROR_USER_ALREADY_EXISTS) + val response = Response.success(JsonApiObject.error(error)) + + assertEquals( + AuthenticationException.UserAlreadyExists, + response.extractAuthToken().exceptionOrNull() + ) + } + + @Test + fun `extractAuthToken() - Error - 500 ISE`() { + val response = Response.error>(500, "500 ISE".toResponseBody()) + + assertEquals( + AuthenticationException.UnknownError, + response.extractAuthToken().exceptionOrNull() + ) + } + + @Test + fun `extractAuthToken() - Error - User Already Exists`() { + val response = Response.error>(400, ERROR_USER_ALREADY_EXISTS.toResponseBody()) + + assertEquals( + AuthenticationException.UserAlreadyExists, + response.extractAuthToken().exceptionOrNull() + ) + } + + @Test + fun `extractAuthToken() - Error - User Not Found`() { + val response = Response.error>(400, ERROR_USER_NOT_FOUND.toResponseBody()) + + assertEquals( + AuthenticationException.UserNotFound, + response.extractAuthToken().exceptionOrNull() + ) + } + + @Test + fun `extractAuthToken() - Error - Valid jsonapi response with error status`() { + val converter = JsonApiConverter.Builder() + .addClasses(AuthToken::class.java) + .build() + + val response = Response.error>( + 400, + converter.toJson(JsonApiObject.of(AuthToken())).toResponseBody() + ) + + assertEquals( + AuthenticationException.UnknownError, + response.extractAuthToken().exceptionOrNull() + ) + } + // endregion extractAuthToken() +} diff --git a/library/account/src/test/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProviderTest.kt b/library/account/src/test/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProviderTest.kt index 800860b6bc..c8ae9e1aa4 100644 --- a/library/account/src/test/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProviderTest.kt +++ b/library/account/src/test/kotlin/org/cru/godtools/account/provider/facebook/FacebookAccountProviderTest.kt @@ -15,21 +15,24 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic +import java.util.Date import java.util.UUID +import kotlin.random.Random import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlin.test.assertTrue import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody.Companion.toResponseBody import org.ccci.gto.android.common.facebook.login.currentAccessTokenFlow import org.ccci.gto.android.common.facebook.login.refreshCurrentAccessToken import org.ccci.gto.android.common.jsonapi.model.JsonApiError import org.ccci.gto.android.common.jsonapi.model.JsonApiObject +import org.cru.godtools.account.provider.AuthenticationException import org.cru.godtools.account.provider.facebook.FacebookAccountProvider.Companion.PREF_USER_ID import org.cru.godtools.api.AuthApi import org.cru.godtools.api.model.AuthToken @@ -39,7 +42,6 @@ import retrofit2.Response private const val CLASS_ACCESS_TOKEN_MANAGER_KTX = "org.ccci.gto.android.common.facebook.login.AccessTokenManagerKt" @RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalCoroutinesApi::class) class FacebookAccountProviderTest { private val currentAccessTokenFlow = MutableStateFlow(null) @@ -68,8 +70,39 @@ class FacebookAccountProviderTest { unmockkStatic(CLASS_ACCESS_TOKEN_MANAGER_KTX) } + // region Property: isAuthenticated @Test - fun `userId()`() = runTest { + fun `Property isAuthenticated`() { + assertFalse(provider.isAuthenticated) + + val token = accessToken(expirationTime = Date(System.currentTimeMillis() + 100_000)) + currentAccessTokenFlow.value = token + assertFalse(provider.isAuthenticated) + + val user = UUID.randomUUID().toString() + provider.prefs.edit { putString(token.PREF_USER_ID, user) } + assertTrue(provider.isAuthenticated) + + currentAccessTokenFlow.value = null + assertFalse(provider.isAuthenticated) + } + + @Test + fun `Property isAuthenticated - token expired`() { + val token = accessToken(expirationTime = Date(System.currentTimeMillis() - 100_000)) + val user = UUID.randomUUID().toString() + provider.prefs.edit { putString(token.PREF_USER_ID, user) } + currentAccessTokenFlow.value = token + assertFalse(provider.isAuthenticated) + + currentAccessTokenFlow.value = accessToken(expirationTime = Date(System.currentTimeMillis() + 100_000)) + assertTrue(provider.isAuthenticated) + } + // endregion Property: isAuthenticated + + // region Property userId + @Test + fun `Property userId`() = runTest { assertNull(provider.userId) val user = UUID.randomUUID().toString() @@ -78,6 +111,7 @@ class FacebookAccountProviderTest { provider.prefs.edit { putString(token.PREF_USER_ID, user) } assertEquals(user, provider.userId) } + // endregion Property userId // region userIdFlow() @Test @@ -87,15 +121,13 @@ class FacebookAccountProviderTest { provider.prefs.edit { putString(token.PREF_USER_ID, user) } provider.userIdFlow().test { - assertNull(expectMostRecentItem()) + assertNull(awaitItem()) currentAccessTokenFlow.value = token - runCurrent() - assertEquals(user, expectMostRecentItem()) + assertEquals(user, awaitItem()) currentAccessTokenFlow.value = null - runCurrent() - assertNull(expectMostRecentItem()) + assertNull(awaitItem()) } } @@ -103,15 +135,13 @@ class FacebookAccountProviderTest { fun `userIdFlow() - Emit new userId when it changes`() = runTest { val user = UUID.randomUUID().toString() val token = accessToken() + currentAccessTokenFlow.value = token provider.userIdFlow().test { - currentAccessTokenFlow.value = token - runCurrent() - assertNull(expectMostRecentItem()) + assertNull(awaitItem()) provider.prefs.edit { putString(token.PREF_USER_ID, user) } - runCurrent() - assertEquals(user, expectMostRecentItem()) + assertEquals(user, awaitItem()) } } // endregion userIdFlow() @@ -120,20 +150,24 @@ class FacebookAccountProviderTest { @Test fun `authenticateWithMobileContentApi()`() = runTest { val accessToken = accessToken() + val createUser = Random.nextBoolean() val token = AuthToken(userId = UUID.randomUUID().toString()) currentAccessTokenFlow.value = accessToken coEvery { api.authenticate(any()) } returns Response.success(JsonApiObject.of(token)) - assertEquals(token, provider.authenticateWithMobileContentApi()) + assertEquals(Result.success(token), provider.authenticateWithMobileContentApi(createUser)) assertEquals(token.userId, provider.userId) coVerifyAll { - api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token)) + api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token, createUser = createUser)) } } @Test fun `authenticateWithMobileContentApi() - Error - No Valid AccessToken`() = runTest { - assertNull(provider.authenticateWithMobileContentApi()) + assertEquals( + Result.failure(AuthenticationException.MissingCredentials), + provider.authenticateWithMobileContentApi(true) + ) coVerifyAll { api wasNot Called @@ -144,33 +178,38 @@ class FacebookAccountProviderTest { fun `authenticateWithMobileContentApi() - Error - Refresh successful`() = runTest { val accessToken = accessToken() val accessToken2 = accessToken() + val createUser = Random.nextBoolean() val token = AuthToken(userId = UUID.randomUUID().toString()) currentAccessTokenFlow.value = accessToken - coEvery { api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token)) } + coEvery { api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token, createUser = createUser)) } .returns(Response.error(401, "".toResponseBody())) coEvery { accessTokenManager.refreshCurrentAccessToken() } returns accessToken2 - coEvery { api.authenticate(AuthToken.Request(fbAccessToken = accessToken2.token)) } + coEvery { api.authenticate(AuthToken.Request(fbAccessToken = accessToken2.token, createUser = createUser)) } .returns(Response.success(JsonApiObject.of(token))) - assertEquals(token, provider.authenticateWithMobileContentApi()) + assertEquals(Result.success(token), provider.authenticateWithMobileContentApi(createUser)) assertEquals(token.userId, provider.userId) coVerifyAll { - api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token)) + api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token, createUser = createUser)) accessTokenManager.refreshCurrentAccessToken() - api.authenticate(AuthToken.Request(fbAccessToken = accessToken2.token)) + api.authenticate(AuthToken.Request(fbAccessToken = accessToken2.token, createUser = createUser)) } } @Test fun `authenticateWithMobileContentApi() - Error - Refresh doesn't return access_token`() = runTest { val accessToken = accessToken() + val createUser = Random.nextBoolean() currentAccessTokenFlow.value = accessToken coEvery { api.authenticate(any()) } returns Response.error(401, "".toResponseBody()) coEvery { accessTokenManager.refreshCurrentAccessToken() } returns null - assertNull(provider.authenticateWithMobileContentApi()) + assertEquals( + Result.failure(AuthenticationException.UnableToRefreshCredentials), + provider.authenticateWithMobileContentApi(createUser) + ) coVerifyAll { - api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token)) + api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token, createUser = createUser)) accessTokenManager.refreshCurrentAccessToken() } } @@ -178,13 +217,17 @@ class FacebookAccountProviderTest { @Test fun `authenticateWithMobileContentApi() - Error - Refresh throws exception`() = runTest { val accessToken = accessToken() + val createUser = Random.nextBoolean() currentAccessTokenFlow.value = accessToken coEvery { api.authenticate(any()) } returns Response.error(401, "".toResponseBody()) coEvery { accessTokenManager.refreshCurrentAccessToken() } throws FacebookException() - assertNull(provider.authenticateWithMobileContentApi()) + assertEquals( + Result.failure(AuthenticationException.UnableToRefreshCredentials), + provider.authenticateWithMobileContentApi(createUser) + ) coVerifyAll { - api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token)) + api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token, createUser = createUser)) accessTokenManager.refreshCurrentAccessToken() } } @@ -192,43 +235,52 @@ class FacebookAccountProviderTest { @Test fun `authenticateWithMobileContentApi() - Error - jsonapi errors`() = runTest { val accessToken = accessToken() + val createUser = Random.nextBoolean() currentAccessTokenFlow.value = accessToken coEvery { api.authenticate(any()) } returns Response.success(JsonApiObject.error(JsonApiError())) - assertNull(provider.authenticateWithMobileContentApi()) + assertEquals( + Result.failure(AuthenticationException.UnknownError), + provider.authenticateWithMobileContentApi(createUser) + ) coVerifyAll { - api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token)) + api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token, createUser = createUser)) } } @Test - fun `authenticateWithMobileContentApi() - Error - missing token`() = runTest { + fun `authenticateWithMobileContentApi() - Error - missing response auth_token`() = runTest { val accessToken = accessToken() + val createUser = Random.nextBoolean() currentAccessTokenFlow.value = accessToken coEvery { api.authenticate(any()) } returns Response.success(JsonApiObject.of()) - assertNull(provider.authenticateWithMobileContentApi()) + assertEquals( + Result.failure(AuthenticationException.UnknownError), + provider.authenticateWithMobileContentApi(createUser) + ) coVerifyAll { - api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token)) + api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token, createUser = createUser)) } } @Test - fun `authenticateWithMobileContentApi() - Error - token without userId`() = runTest { + fun `authenticateWithMobileContentApi() - Error - auth_token without userId`() = runTest { val accessToken = accessToken() + val createUser = Random.nextBoolean() val token = AuthToken() currentAccessTokenFlow.value = accessToken coEvery { api.authenticate(any()) } returns Response.success(JsonApiObject.of(token)) - assertEquals(token, provider.authenticateWithMobileContentApi()) + assertEquals(Result.success(token), provider.authenticateWithMobileContentApi(createUser)) assertNull(provider.userId) coVerifyAll { - api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token)) + api.authenticate(AuthToken.Request(fbAccessToken = accessToken.token, createUser = createUser)) } } // endregion authenticateWithMobileContentApi() - private fun accessToken(userId: String = "user") = AccessToken( + private fun accessToken(userId: String = "user", expirationTime: Date? = null) = AccessToken( accessToken = UUID.randomUUID().toString(), applicationId = "application", userId = userId, @@ -236,7 +288,7 @@ class FacebookAccountProviderTest { declinedPermissions = null, expiredPermissions = null, accessTokenSource = null, - expirationTime = null, + expirationTime = expirationTime, lastRefreshTime = null, dataAccessExpirationTime = null, ) diff --git a/library/account/src/test/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProviderTest.kt b/library/account/src/test/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProviderTest.kt index ff93f3a068..ce69275316 100644 --- a/library/account/src/test/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProviderTest.kt +++ b/library/account/src/test/kotlin/org/cru/godtools/account/provider/google/GoogleAccountProviderTest.kt @@ -20,11 +20,14 @@ import io.mockk.mockkStatic import io.mockk.unmockkObject import io.mockk.unmockkStatic import java.util.UUID +import kotlin.random.Random import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent @@ -32,6 +35,7 @@ import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody.Companion.toResponseBody import org.ccci.gto.android.common.jsonapi.model.JsonApiObject import org.ccci.gto.android.common.play.auth.signin.GoogleSignInKtx +import org.cru.godtools.account.provider.AuthenticationException import org.cru.godtools.account.provider.google.GoogleAccountProvider.Companion.PREF_USER_ID import org.cru.godtools.api.AuthApi import org.cru.godtools.api.model.AuthToken @@ -72,6 +76,47 @@ class GoogleAccountProviderTest { unmockkObject(GoogleSignInKtx) } + // region Property: isAuthenticated + @Test + fun `Property isAuthenticated`() = runTest { + assertFalse(provider.isAuthenticated, "No GoogleSignInAccount") + + val account = GoogleSignInAccount.createDefault() + lastSignedInAccount.value = account + assertFalse(provider.isAuthenticated, "GoogleSignInAccount but no userId") + + provider.prefs.edit { putString(account.PREF_USER_ID, userId) } + assertTrue(provider.isAuthenticated, "GoogleSignInAccount w/ userId") + + lastSignedInAccount.value = null + assertFalse(provider.isAuthenticated, "No GoogleSignInAccount, still has userId") + } + // endregion Property: isAuthenticated + + // region isAuthenticatedFlow() + @Test + fun `isAuthenticatedFlow()`() = runTest { + val account = GoogleSignInAccount.createDefault() + provider.prefs.edit { putString(account.PREF_USER_ID, userId) } + + provider.isAuthenticatedFlow().test { + assertFalse(awaitItem()) + + lastSignedInAccount.value = account + assertTrue(awaitItem()) + + provider.prefs.edit { clear() } + assertFalse(awaitItem()) + + provider.prefs.edit { putString(account.PREF_USER_ID, userId) } + assertTrue(awaitItem()) + + lastSignedInAccount.value = null + assertFalse(awaitItem()) + } + } + // endregion isAuthenticatedFlow() + // region userIdFlow() @Test fun `userIdFlow()`() = runTest { @@ -79,16 +124,13 @@ class GoogleAccountProviderTest { provider.prefs.edit { putString(account.PREF_USER_ID, userId) } provider.userIdFlow().test { - runCurrent() - assertNull(expectMostRecentItem()) + assertNull(awaitItem()) lastSignedInAccount.value = account - runCurrent() - assertEquals(userId, expectMostRecentItem()) + assertEquals(userId, awaitItem()) lastSignedInAccount.value = null - runCurrent() - assertNull(expectMostRecentItem()) + assertNull(awaitItem()) } } @@ -117,6 +159,7 @@ class GoogleAccountProviderTest { // region authenticateWithMobileContentApi() private val authToken = AuthToken(userId, "token") + private val createUser = Random.nextBoolean() private val validAccount: GoogleSignInAccount = mockk { every { id } returns UUID.randomUUID().toString() every { idToken } returns ID_TOKEN_VALID @@ -126,9 +169,9 @@ class GoogleAccountProviderTest { fun `Setup authenticateWithMobileContentApi()`() { every { googleSignInClient.silentSignIn() } returns Tasks.forResult(validAccount) - coEvery { authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_VALID)) } + coEvery { authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_VALID, createUser = createUser)) } .returns(Response.success(JsonApiObject.single(authToken))) - coEvery { authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_INVALID)) } + coEvery { authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_INVALID, createUser = createUser)) } .returns(Response.error(401, "".toResponseBody())) } @@ -136,9 +179,9 @@ class GoogleAccountProviderTest { fun `authenticateWithMobileContentApi()`() = runTest { lastSignedInAccount.value = validAccount - assertEquals(authToken, provider.authenticateWithMobileContentApi()) + assertEquals(Result.success(authToken), provider.authenticateWithMobileContentApi(createUser)) coVerifySequence { - authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_VALID)) + authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_VALID, createUser = createUser)) googleSignInClient wasNot Called } @@ -152,7 +195,10 @@ class GoogleAccountProviderTest { fun `authenticateWithMobileContentApi() - Not authenticated`() = runTest { lastSignedInAccount.value = null - assertNull(provider.authenticateWithMobileContentApi()) + assertEquals( + Result.failure(AuthenticationException.MissingCredentials), + provider.authenticateWithMobileContentApi(createUser) + ) coVerifyAll { authApi wasNot Called googleSignInClient wasNot Called @@ -163,10 +209,10 @@ class GoogleAccountProviderTest { fun `authenticateWithMobileContentApi() - No id_token`() = runTest { lastSignedInAccount.value = mockk { every { idToken } returns null } - assertEquals(authToken, provider.authenticateWithMobileContentApi()) + assertEquals(Result.success(authToken), provider.authenticateWithMobileContentApi(createUser)) coVerifySequence { googleSignInClient.silentSignIn() - authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_VALID)) + authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_VALID, createUser = createUser)) } } @@ -174,11 +220,11 @@ class GoogleAccountProviderTest { fun `authenticateWithMobileContentApi() - invalid id_token`() = runTest { lastSignedInAccount.value = mockk { every { idToken } returns ID_TOKEN_INVALID } - assertEquals(authToken, provider.authenticateWithMobileContentApi()) + assertEquals(Result.success(authToken), provider.authenticateWithMobileContentApi(createUser)) coVerifySequence { - authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_INVALID)) + authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_INVALID, createUser = createUser)) googleSignInClient.silentSignIn() - authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_VALID)) + authApi.authenticate(AuthToken.Request(googleIdToken = ID_TOKEN_VALID, createUser = createUser)) } } // endregion authenticateWithMobileContentApi() diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/model/AuthToken.kt b/library/api/src/main/kotlin/org/cru/godtools/api/model/AuthToken.kt index c4520b40a5..c4fdb9df60 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/model/AuthToken.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/model/AuthToken.kt @@ -6,6 +6,7 @@ import org.ccci.gto.android.common.jsonapi.annotation.JsonApiType private const val ATTR_FACEBOOK_ACCESS_TOKEN = "facebook_access_token" private const val ATTR_GOOGLE_ID_TOKEN = "google_id_token" private const val ATTR_OKTA_TOKEN = "okta_access_token" +private const val ATTR_CREATE_USER = "create_user" private const val ATTR_USER_ID = "user-id" private const val ATTR_TOKEN = "token" @@ -16,10 +17,16 @@ data class AuthToken( @JsonApiAttribute(ATTR_TOKEN) var token: String? = null ) { + companion object { + const val ERROR_USER_ALREADY_EXISTS = "user_already_exists" + const val ERROR_USER_NOT_FOUND = "user_not_found" + } + @JsonApiType("auth-token-request") data class Request( @JsonApiAttribute(ATTR_FACEBOOK_ACCESS_TOKEN) val fbAccessToken: String? = null, @JsonApiAttribute(ATTR_GOOGLE_ID_TOKEN) val googleIdToken: String? = null, @JsonApiAttribute(ATTR_OKTA_TOKEN) val oktaAccessToken: String? = null, + @JsonApiAttribute(ATTR_CREATE_USER) val createUser: Boolean? = null, ) } diff --git a/ui/article-renderer/build.gradle.kts b/ui/article-renderer/build.gradle.kts index e4cecc2036..1d99d2bb44 100644 --- a/ui/article-renderer/build.gradle.kts +++ b/ui/article-renderer/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { kapt(libs.dagger.compiler) kapt(libs.hilt.compiler) + testImplementation(project(":library:account")) testImplementation(project(":library:model")) testImplementation(libs.androidx.arch.core.testing) testImplementation(libs.hilt.testing) diff --git a/ui/article-renderer/src/test/kotlin/org/cru/godtools/tool/article/MockAccountModule.kt b/ui/article-renderer/src/test/kotlin/org/cru/godtools/tool/article/MockAccountModule.kt new file mode 100644 index 0000000000..50973b5ac3 --- /dev/null +++ b/ui/article-renderer/src/test/kotlin/org/cru/godtools/tool/article/MockAccountModule.kt @@ -0,0 +1,19 @@ +package org.cru.godtools.tool.article + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.mockk +import org.cru.godtools.account.AccountModule +import org.cru.godtools.account.GodToolsAccountManager + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [AccountModule::class] +) +class MockAccountModule { + @get:Provides + val accountManager: GodToolsAccountManager by lazy { mockk() } +} diff --git a/ui/cyoa-renderer/build.gradle.kts b/ui/cyoa-renderer/build.gradle.kts index 7febe78b90..be00e453cc 100644 --- a/ui/cyoa-renderer/build.gradle.kts +++ b/ui/cyoa-renderer/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { kapt(libs.dagger.compiler) kapt(libs.hilt.compiler) + testImplementation(project(":library:account")) testImplementation(project(":library:model")) testImplementation(libs.androidx.arch.core.testing) testImplementation(libs.androidx.lifecycle.runtime.testing) diff --git a/ui/cyoa-renderer/src/test/kotlin/org/cru/godtools/tool/cyoa/MockAccountModule.kt b/ui/cyoa-renderer/src/test/kotlin/org/cru/godtools/tool/cyoa/MockAccountModule.kt new file mode 100644 index 0000000000..df0aedfb62 --- /dev/null +++ b/ui/cyoa-renderer/src/test/kotlin/org/cru/godtools/tool/cyoa/MockAccountModule.kt @@ -0,0 +1,19 @@ +package org.cru.godtools.tool.cyoa + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.mockk +import org.cru.godtools.account.AccountModule +import org.cru.godtools.account.GodToolsAccountManager + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [AccountModule::class] +) +class MockAccountModule { + @get:Provides + val accountManager: GodToolsAccountManager by lazy { mockk() } +} diff --git a/ui/lesson-renderer/build.gradle.kts b/ui/lesson-renderer/build.gradle.kts index b2dc7e9b49..6d8758e0fc 100644 --- a/ui/lesson-renderer/build.gradle.kts +++ b/ui/lesson-renderer/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { kapt(libs.dagger.compiler) kapt(libs.hilt.compiler) + testImplementation(project(":library:account")) testImplementation(project(":library:model")) testImplementation(libs.hilt.testing) kaptTest(libs.hilt.compiler) diff --git a/ui/lesson-renderer/src/test/kotlin/org/cru/godtools/tool/lesson/MockAccountModule.kt b/ui/lesson-renderer/src/test/kotlin/org/cru/godtools/tool/lesson/MockAccountModule.kt new file mode 100644 index 0000000000..cbd0ee7b5d --- /dev/null +++ b/ui/lesson-renderer/src/test/kotlin/org/cru/godtools/tool/lesson/MockAccountModule.kt @@ -0,0 +1,19 @@ +package org.cru.godtools.tool.lesson + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.mockk +import org.cru.godtools.account.AccountModule +import org.cru.godtools.account.GodToolsAccountManager + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [AccountModule::class] +) +class MockAccountModule { + @get:Provides + val accountManager: GodToolsAccountManager by lazy { mockk() } +} diff --git a/ui/tract-renderer/build.gradle.kts b/ui/tract-renderer/build.gradle.kts index ebcd22d872..9e05c4e152 100644 --- a/ui/tract-renderer/build.gradle.kts +++ b/ui/tract-renderer/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { kapt(libs.dagger.compiler) kapt(libs.hilt.compiler) + testImplementation(project(":library:account")) testImplementation(libs.androidx.arch.core.testing) testImplementation(libs.androidx.lifecycle.runtime.testing) testImplementation(libs.gtoSupport.testing.dagger) diff --git a/ui/tract-renderer/src/test/kotlin/org/cru/godtools/tool/tract/MockAccountModule.kt b/ui/tract-renderer/src/test/kotlin/org/cru/godtools/tool/tract/MockAccountModule.kt new file mode 100644 index 0000000000..44be0837ad --- /dev/null +++ b/ui/tract-renderer/src/test/kotlin/org/cru/godtools/tool/tract/MockAccountModule.kt @@ -0,0 +1,19 @@ +package org.cru.godtools.tool.tract + +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.mockk +import org.cru.godtools.account.AccountModule +import org.cru.godtools.account.GodToolsAccountManager + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [AccountModule::class] +) +class MockAccountModule { + @get:Provides + val accountManager: GodToolsAccountManager by lazy { mockk() } +}