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() }
+}