feat(sdk): AuthGoogleApp mutation and Google ID token auth without Firebase

Made-with: Cursor
This commit is contained in:
2026-04-06 22:48:37 +02:00
parent 9165d26b72
commit 34ad7e4af7
6 changed files with 194 additions and 133 deletions

View File

@@ -1,4 +1,4 @@
mutation AuthGoogleNew($accessToken: String!) { mutation AuthGoogleApp($accessToken: String!) {
authGoogleApp(input: { accessToken: $accessToken }) { authGoogleApp(input: { accessToken: $accessToken }) {
token token
name name

View File

@@ -4,7 +4,7 @@ import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Optional import com.apollographql.apollo.api.Optional
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import org.db3.airmq.sdk.AuthGoogleNewMutation import org.db3.airmq.sdk.AuthGoogleAppMutation
import org.db3.airmq.sdk.LoginLocalMutation import org.db3.airmq.sdk.LoginLocalMutation
import org.db3.airmq.sdk.RegisterMutation import org.db3.airmq.sdk.RegisterMutation
import org.db3.airmq.sdk.auth.model.AuthProvider import org.db3.airmq.sdk.auth.model.AuthProvider
@@ -82,28 +82,22 @@ class FirebaseAuthService @Inject constructor(
localEmailAuthStore.clearProfile().getOrThrow() localEmailAuthStore.clearProfile().getOrThrow()
} }
private suspend fun signInWithGoogle(token: String): User { /**
* Google OAuth: send the Google ID token from Credential Manager to [authGoogleApp], then persist the API JWT
* (same storage as email login). Does not sign in to Firebase; the backend verifies the Google JWT.
*/
private suspend fun signInWithGoogle(googleIdToken: String): User {
localEmailAuthStore.clearProfile().getOrThrow() localEmailAuthStore.clearProfile().getOrThrow()
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token) val response = apolloClient
try { .mutation(AuthGoogleAppMutation(accessToken = googleIdToken))
val apiToken = exchangeFirebaseIdTokenWithBackend(firebaseSessionUser.firebaseAccessToken) .execute()
apiTokenStore.saveToken(apiToken).getOrThrow() response.exception?.let { throw it }
return firebaseSessionUser.user response.errors?.firstOrNull()?.let { gqlError ->
} catch (e: Throwable) { throw IllegalStateException(gqlError.message)
rollbackSessionAfterFailedGoogleBackendAuth()
throw e
}
}
/** Firebase may already be signed in when the backend exchange fails; clear everything so the user stays logged out. */
private suspend fun rollbackSessionAfterFailedGoogleBackendAuth() {
try {
firebaseSessionManager.signOut()
apiTokenStore.clearToken().getOrThrow()
localEmailAuthStore.clearProfile().getOrThrow()
} catch (_: Throwable) {
// Best-effort; caller still receives the original sign-in failure.
} }
val auth = response.data?.authGoogleApp
?: error("Google sign-in response missing data.")
return commitEmailAuthSession(auth.token, auth._id, auth.email, auth.name)
} }
private suspend fun commitEmailAuthSession( private suspend fun commitEmailAuthSession(
@@ -130,16 +124,4 @@ class FirebaseAuthService @Inject constructor(
isAuthenticated = true isAuthenticated = true
) )
} }
private suspend fun exchangeFirebaseIdTokenWithBackend(firebaseIdToken: String): String {
val response = apolloClient
.mutation(AuthGoogleNewMutation(accessToken = firebaseIdToken))
.execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { gqlError ->
throw IllegalStateException(gqlError.message)
}
return response.data?.authGoogleApp?.token
?: error("Backend auth exchange succeeded without API token.")
}
} }

View File

@@ -5,11 +5,5 @@ import org.db3.airmq.sdk.auth.model.User
interface FirebaseSessionManager { interface FirebaseSessionManager {
suspend fun getUser(): User? suspend fun getUser(): User?
suspend fun isSignedIn(): Boolean suspend fun isSignedIn(): Boolean
suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser
suspend fun signOut() suspend fun signOut()
} }
data class FirebaseSessionUser(
val user: User,
val firebaseAccessToken: String
)

View File

@@ -1,14 +1,9 @@
package org.db3.airmq.sdk.auth package org.db3.airmq.sdk.auth
import com.google.android.gms.tasks.Task
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.GoogleAuthProvider
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import org.db3.airmq.sdk.auth.model.User import org.db3.airmq.sdk.auth.model.User
@Singleton @Singleton
@@ -20,18 +15,6 @@ class FirebaseSessionManagerImpl @Inject constructor(
override suspend fun isSignedIn(): Boolean = firebaseAuth.currentUser != null override suspend fun isSignedIn(): Boolean = firebaseAuth.currentUser != null
override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser {
val credential = GoogleAuthProvider.getCredential(idToken, null)
val authResult = firebaseAuth.signInWithCredential(credential).awaitResult()
val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.")
val firebaseAccessToken = firebaseUser.getIdToken(true).awaitResult().token
?: error("Firebase token exchange completed without an ID token.")
return FirebaseSessionUser(
user = firebaseUser.toUser(),
firebaseAccessToken = firebaseAccessToken
)
}
override suspend fun signOut() { override suspend fun signOut() {
firebaseAuth.signOut() firebaseAuth.signOut()
} }
@@ -43,15 +26,3 @@ class FirebaseSessionManagerImpl @Inject constructor(
isAuthenticated = true isAuthenticated = true
) )
} }
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { continuation ->
addOnSuccessListener { result ->
continuation.resume(result)
}
addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
addOnCanceledListener {
continuation.cancel()
}
}

View File

@@ -3,6 +3,7 @@ package org.db3.airmq.sdk.network
import android.util.Log import android.util.Log
import com.apollographql.apollo.api.ApolloRequest import com.apollographql.apollo.api.ApolloRequest
import com.apollographql.apollo.api.ApolloResponse import com.apollographql.apollo.api.ApolloResponse
import com.apollographql.apollo.api.Error
import com.apollographql.apollo.api.Operation import com.apollographql.apollo.api.Operation
import com.apollographql.apollo.interceptor.ApolloInterceptor import com.apollographql.apollo.interceptor.ApolloInterceptor
import com.apollographql.apollo.interceptor.ApolloInterceptorChain import com.apollographql.apollo.interceptor.ApolloInterceptorChain
@@ -12,6 +13,9 @@ import kotlinx.coroutines.flow.onEach
/** /**
* Logs every GraphQL operation name, document, variable payload ([Operation] `toString`), * Logs every GraphQL operation name, document, variable payload ([Operation] `toString`),
* and response data or errors to logcat (tag [TAG]). * and response data or errors to logcat (tag [TAG]).
*
* GraphQL [Error]s are logged with [Error.path], [Error.extensions] (e.g. `code`, `stacktrace`),
* and [Error.nonStandardFields], not only message and locations.
*/ */
class ApolloLoggingInterceptor : ApolloInterceptor { class ApolloLoggingInterceptor : ApolloInterceptor {
override fun <D : Operation.Data> intercept( override fun <D : Operation.Data> intercept(
@@ -27,36 +31,145 @@ class ApolloLoggingInterceptor : ApolloInterceptor {
if (response.exception != null) { if (response.exception != null) {
Log.e( Log.e(
TAG, TAG,
"---------- GraphQL response: $operationName FAILED ----------", "---------- GraphQL response: $operationName FAILED (transport / parse) ----------",
response.exception response.exception
) )
logGraphqlErrorsIfAny(operationName, response)
logResponseExtensionsIfAny(operationName, response)
val dataStr = response.data?.toString() ?: "null"
logChunked("$TAG.response.$operationName.data", dataStr)
} else { } else {
Log.d(TAG, "---------- GraphQL response: $operationName ----------") Log.d(TAG, "---------- GraphQL response: $operationName ----------")
val errors = response.errors logGraphqlErrorsIfAny(operationName, response)
if (!errors.isNullOrEmpty()) { logResponseExtensionsIfAny(operationName, response)
errors.forEachIndexed { i, err ->
Log.w(TAG, "errors[$i]: ${err.message} (locations=${err.locations})")
}
} else {
Log.d(TAG, "errors: none")
}
val dataStr = response.data?.toString() ?: "null" val dataStr = response.data?.toString() ?: "null"
logChunked("$TAG.response.$operationName.data", dataStr) logChunked("$TAG.response.$operationName.data", dataStr)
} }
} }
} }
/** Android single-line log limit is ~4k; split large payloads. */ private fun <D : Operation.Data> logResponseExtensionsIfAny(
private fun logChunked(label: String, text: String, chunkSize: Int = 3500) { operationName: String,
response: ApolloResponse<D>
) {
val ext = response.extensions
if (ext.isEmpty()) {
Log.d(TAG, "response.extensions: none")
return
}
logChunked(
"$TAG.response.$operationName.protocolExtensions",
ext.entries.joinToString(prefix = "{", postfix = "}") { (k, v) ->
"\n $k = ${formatExtensionValue(v)}"
}
)
}
private fun <D : Operation.Data> logGraphqlErrorsIfAny(
operationName: String,
response: ApolloResponse<D>
) {
val errors = response.errors
if (errors.isNullOrEmpty()) {
Log.d(TAG, "graphql.errors: none")
return
}
Log.w(TAG, "graphql.errors: count=${errors.size} (partial data may still be present)")
errors.forEachIndexed { index, err ->
val detail = formatGraphqlErrorDetail(err)
logChunked(
"$TAG.response.$operationName.graphqlError[$index]",
detail,
priority = Log.WARN
)
}
}
private fun formatGraphqlErrorDetail(error: Error): String = buildString {
appendLine("message: ${error.message}")
val path = error.path
if (!path.isNullOrEmpty()) {
appendLine("path: ${path.joinToString(prefix = "[", postfix = "]")}")
} else {
appendLine("path: (none)")
}
val locs = error.locations
if (!locs.isNullOrEmpty()) {
appendLine(
"locations: " + locs.joinToString { loc ->
"line=${loc.line}, column=${loc.column}"
}
)
} else {
appendLine("locations: (none)")
}
appendLine("extensions:")
appendIndentedMapOrEmpty(error.extensions, indent = 2)
appendLine("nonStandardFields:")
appendIndentedMapOrEmpty(error.nonStandardFields, indent = 2)
}
private fun StringBuilder.appendIndentedMapOrEmpty(
map: Map<String, Any?>?,
indent: Int
) {
val prefix = " ".repeat(indent)
if (map.isNullOrEmpty()) {
appendLine("$prefix(none)")
return
}
map.forEach { (k, v) ->
append(prefix)
append(k)
append(" = ")
appendLine(formatExtensionValue(v, indent + 2))
}
}
private fun formatExtensionValue(value: Any?, nestedIndent: Int = 0): String = when (value) {
null -> "null"
is Map<*, *> -> {
val prefix = "\n" + " ".repeat(nestedIndent)
value.entries.joinToString(prefix = "{", postfix = "}") { (k, v) ->
"$prefix${k.toString()} = ${formatExtensionValue(v, nestedIndent + 2)}"
}
}
is Iterable<*> -> {
val prefix = "\n" + " ".repeat(nestedIndent)
value.withIndex().joinToString(prefix = "[", postfix = "]") { (i, v) ->
"$prefix[$i] ${formatExtensionValue(v, nestedIndent + 2)}"
}
}
is Array<*> -> formatExtensionValue(value.toList(), nestedIndent)
else -> value.toString()
}
/**
* Android single-line log limit is ~4k; split large payloads.
* @param priority [Log.DEBUG] for normal traffic; [Log.WARN] for GraphQL error bodies.
*/
private fun logChunked(
label: String,
text: String,
chunkSize: Int = 3500,
priority: Int = Log.DEBUG
) {
val logLine: (String) -> Unit = { line ->
when (priority) {
Log.WARN -> Log.w(TAG, line)
Log.ERROR -> Log.e(TAG, line)
else -> Log.d(TAG, line)
}
}
if (text.length <= chunkSize) { if (text.length <= chunkSize) {
Log.d(TAG, "$label: $text") logLine("$label: $text")
return return
} }
var start = 0 var start = 0
var part = 0 var part = 0
while (start < text.length) { while (start < text.length) {
val end = minOf(start + chunkSize, text.length) val end = minOf(start + chunkSize, text.length)
Log.d(TAG, "$label [part $part]: ${text.substring(start, end)}") logLine("$label [part $part]: ${text.substring(start, end)}")
part++ part++
start = end start = end
} }

View File

@@ -1,13 +1,15 @@
package org.db3.airmq.sdk.auth package org.db3.airmq.sdk.auth
import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.ApolloResponse
import com.benasher44.uuid.uuid4
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.db3.airmq.sdk.AuthGoogleNewMutation import org.db3.airmq.sdk.AuthGoogleAppMutation
import org.db3.airmq.sdk.auth.model.AuthProvider import org.db3.airmq.sdk.auth.model.AuthProvider
import org.db3.airmq.sdk.auth.model.User import org.db3.airmq.sdk.auth.model.User
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -20,32 +22,12 @@ import org.junit.Test
class FirebaseAuthServiceTest { class FirebaseAuthServiceTest {
@Test @Test
fun signIn_google_failsWhenFirebaseSignInFails() = runTest { fun signIn_google_failsWhenAuthGoogleAppFails() = runTest {
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed")) val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore() val tokenStore = FakeApiTokenStore()
val localEmailStore = FakeLocalEmailAuthStore() val localEmailStore = FakeLocalEmailAuthStore()
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) mockAuthGoogleAppError(apolloClient, IllegalStateException("backend failed"))
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
assertTrue(result.isFailure)
assertEquals("firebase failed", result.exceptionOrNull()?.message)
assertEquals(null, tokenStore.storedToken)
}
@Test
fun signIn_google_failsWhenBackendExchangeFails() = runTest {
val sessionManager = FakeFirebaseSessionManager(
signInResult = FirebaseSessionUser(
user = User("uid-1", "user@airmq.cc", "User", true),
firebaseAccessToken = "firebase-token"
)
)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore()
val localEmailStore = FakeLocalEmailAuthStore()
mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed"))
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token") val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
@@ -53,17 +35,48 @@ class FirebaseAuthServiceTest {
assertTrue(result.isFailure) assertTrue(result.isFailure)
assertEquals("backend failed", result.exceptionOrNull()?.message) assertEquals("backend failed", result.exceptionOrNull()?.message)
assertEquals(null, tokenStore.storedToken) assertEquals(null, tokenStore.storedToken)
assertEquals(0, sessionManager.signOutCallCount)
}
@Test
fun signIn_google_succeedsWhenAuthGoogleAppReturnsToken() = runTest {
val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore()
val localEmailStore = FakeLocalEmailAuthStore()
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleAppMutation.Data>>()
every { apolloClient.mutation(any<AuthGoogleAppMutation>()) } returns mockCall
val mutationData = AuthGoogleAppMutation.Data(
authGoogleApp = AuthGoogleAppMutation.AuthGoogleApp(
token = "api-jwt",
name = "N",
email = "u@test.dev",
roles = null,
_id = "backend-id",
regDate = null
)
)
val apolloResponse = ApolloResponse.Builder(
AuthGoogleAppMutation(accessToken = "google-id-token"),
uuid4()
).data(mutationData).build()
coEvery { mockCall.execute() } returns apolloResponse
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
assertTrue(result.isSuccess)
assertEquals("api-jwt", tokenStore.storedToken)
assertEquals("backend-id", localEmailStore.getProfile()?.userId)
assertEquals("u@test.dev", localEmailStore.getProfile()?.email)
assertEquals(1, sessionManager.signOutCallCount) assertEquals(1, sessionManager.signOutCallCount)
} }
@Test @Test
fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest { fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest {
val sessionManager = FakeFirebaseSessionManager( val sessionManager = FakeFirebaseSessionManager(
isSignedIn = true, user = User("uid-fb", "fb@test.dev", "FB", true),
signInResult = FirebaseSessionUser( isSignedIn = true
user = User("uid-fb", "fb@test.dev", "FB", true),
firebaseAccessToken = "ft"
)
) )
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token") val tokenStore = FakeApiTokenStore(storedToken = "api-token")
@@ -78,7 +91,7 @@ class FirebaseAuthServiceTest {
@Test @Test
fun isAuthenticated_trueWhenLocalEmailProfileAndApiTokenPresent() = runTest { fun isAuthenticated_trueWhenLocalEmailProfileAndApiTokenPresent() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null) val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token") val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val localEmailStore = FakeLocalEmailAuthStore().apply { val localEmailStore = FakeLocalEmailAuthStore().apply {
@@ -92,10 +105,7 @@ class FirebaseAuthServiceTest {
@Test @Test
fun getUser_returnsNullWhenFirebaseUserButNoApiToken() = runTest { fun getUser_returnsNullWhenFirebaseUserButNoApiToken() = runTest {
val sessionManager = FakeFirebaseSessionManager( val sessionManager = FakeFirebaseSessionManager(
signInResult = FirebaseSessionUser( user = User("uid-fb", "fb@test.dev", "FB", true)
user = User("uid-fb", "fb@test.dev", "FB", true),
firebaseAccessToken = "ft"
)
) )
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = null) val tokenStore = FakeApiTokenStore(storedToken = null)
@@ -108,10 +118,7 @@ class FirebaseAuthServiceTest {
@Test @Test
fun getUser_returnsFirebaseUserWhenApiTokenPresentAndNoLocalProfile() = runTest { fun getUser_returnsFirebaseUserWhenApiTokenPresentAndNoLocalProfile() = runTest {
val sessionManager = FakeFirebaseSessionManager( val sessionManager = FakeFirebaseSessionManager(
signInResult = FirebaseSessionUser( user = User("uid-fb", "fb@test.dev", "FB", true)
user = User("uid-fb", "fb@test.dev", "FB", true),
firebaseAccessToken = "ft"
)
) )
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token") val tokenStore = FakeApiTokenStore(storedToken = "api-token")
@@ -126,7 +133,7 @@ class FirebaseAuthServiceTest {
@Test @Test
fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest { fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null) val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token") val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val localEmailStore = FakeLocalEmailAuthStore().apply { val localEmailStore = FakeLocalEmailAuthStore().apply {
@@ -144,7 +151,7 @@ class FirebaseAuthServiceTest {
@Test @Test
fun signOut_clearsTokenAndLocalProfile() = runTest { fun signOut_clearsTokenAndLocalProfile() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null) val sessionManager = FakeFirebaseSessionManager(user = null, isSignedIn = false)
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token") val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val localEmailStore = FakeLocalEmailAuthStore().apply { val localEmailStore = FakeLocalEmailAuthStore().apply {
@@ -157,30 +164,24 @@ class FirebaseAuthServiceTest {
assertNull(localEmailStore.getProfile()) assertNull(localEmailStore.getProfile())
} }
private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) { private fun mockAuthGoogleAppError(apolloClient: ApolloClient, error: Throwable) {
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>() val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleAppMutation.Data>>()
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall every { apolloClient.mutation(any<AuthGoogleAppMutation>()) } returns mockCall
coEvery { mockCall.execute() } throws error coEvery { mockCall.execute() } throws error
} }
} }
private class FakeFirebaseSessionManager( private class FakeFirebaseSessionManager(
private val signInResult: FirebaseSessionUser? = null, private val user: User? = null,
private val signInError: Throwable? = null,
private val isSignedIn: Boolean = true private val isSignedIn: Boolean = true
) : FirebaseSessionManager { ) : FirebaseSessionManager {
var signOutCallCount = 0 var signOutCallCount = 0
private set private set
override suspend fun getUser(): User? = signInResult?.user override suspend fun getUser(): User? = user
override suspend fun isSignedIn(): Boolean = isSignedIn override suspend fun isSignedIn(): Boolean = isSignedIn
override suspend fun signInWithGoogle(idToken: String): FirebaseSessionUser {
signInError?.let { throw it }
return signInResult ?: error("No signInResult configured")
}
override suspend fun signOut() { override suspend fun signOut() {
signOutCallCount++ signOutCallCount++
} }