feat(auth): email sign-in and register via GraphQL

Add loginLocal and register Apollo mutations, LocalEmailAuthStore for profile snapshot when not using Firebase, extend AuthService/FirebaseAuthService with session handoff vs Google, wire EmailLoginViewModel, refresh Manage on resume, and expand unit tests.

Made-with: Cursor
This commit is contained in:
2026-04-06 19:30:31 +02:00
parent df4e6f9c56
commit 9869ad2476
16 changed files with 319 additions and 26 deletions

View File

@@ -0,0 +1,11 @@
mutation LoginLocal($input: LocalAuthInput!) {
loginLocal(input: $input) {
token
name
email
roles
_id
regDate
emailVerified
}
}

View File

@@ -0,0 +1,11 @@
mutation Register($input: RegisterInput!) {
register(input: $input) {
token
name
email
roles
_id
regDate
emailVerified
}
}

View File

@@ -0,0 +1,5 @@
package org.db3.airmq.sdk.auth
internal object AirMqAuthPreferences {
const val FILE_NAME = "airmq_auth"
}

View File

@@ -7,5 +7,7 @@ interface AuthService {
suspend fun getUser(): User?
suspend fun isAuthenticated(): Boolean
suspend fun signIn(provider: AuthProvider, token: String): Result<User>
suspend fun loginWithEmailPassword(email: String, password: String): Result<User>
suspend fun registerWithEmail(email: String, password: String, name: String): Result<User>
suspend fun signOut(): Result<Unit>
}

View File

@@ -1,25 +1,40 @@
package org.db3.airmq.sdk.auth
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Optional
import javax.inject.Inject
import javax.inject.Singleton
import org.db3.airmq.sdk.AuthGoogleNewMutation
import org.db3.airmq.sdk.LoginLocalMutation
import org.db3.airmq.sdk.RegisterMutation
import org.db3.airmq.sdk.auth.model.AuthProvider
import org.db3.airmq.sdk.auth.model.User
import org.db3.airmq.sdk.type.LocalAuthInput
import org.db3.airmq.sdk.type.RegisterInput
@Singleton
class FirebaseAuthService @Inject constructor(
private val firebaseSessionManager: FirebaseSessionManager,
private val apolloClient: ApolloClient,
private val apiTokenStore: ApiTokenStore
private val apiTokenStore: ApiTokenStore,
private val localEmailAuthStore: LocalEmailAuthStore
) : AuthService {
override suspend fun getUser(): User? = firebaseSessionManager.getUser()
override suspend fun getUser(): User? {
firebaseSessionManager.getUser()?.let { return it }
if (apiTokenStore.getToken().isNullOrBlank()) return null
val profile = localEmailAuthStore.getProfile() ?: return null
return User(
userId = profile.userId,
email = profile.email,
displayName = profile.displayName,
isAuthenticated = true
)
}
override suspend fun isAuthenticated(): Boolean {
val hasFirebaseUser = firebaseSessionManager.isSignedIn()
val hasApiToken = !apiTokenStore.getToken().isNullOrBlank()
return hasFirebaseUser && hasApiToken
if (apiTokenStore.getToken().isNullOrBlank()) return false
return getUser() != null
}
override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching {
@@ -29,18 +44,76 @@ class FirebaseAuthService @Inject constructor(
}
}
override suspend fun loginWithEmailPassword(email: String, password: String): Result<User> = runCatching {
val response = apolloClient
.mutation(LoginLocalMutation(LocalAuthInput(email = email, password = password)))
.execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { gqlError ->
throw IllegalStateException(gqlError.message)
}
val auth = response.data?.loginLocal
?: error("Login response missing data.")
commitEmailAuthSession(auth.token, auth._id, auth.email, auth.name)
}
override suspend fun registerWithEmail(email: String, password: String, name: String): Result<User> = runCatching {
val input = RegisterInput(
email = email,
password = password,
name = Optional.present(name)
)
val response = apolloClient
.mutation(RegisterMutation(input))
.execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { gqlError ->
throw IllegalStateException(gqlError.message)
}
val auth = response.data?.register
?: error("Register response missing data.")
commitEmailAuthSession(auth.token, auth._id, auth.email, auth.name)
}
override suspend fun signOut(): Result<Unit> = runCatching {
firebaseSessionManager.signOut()
apiTokenStore.clearToken().getOrThrow()
localEmailAuthStore.clearProfile().getOrThrow()
}
private suspend fun signInWithGoogle(token: String): User {
localEmailAuthStore.clearProfile().getOrThrow()
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
apiTokenStore.saveToken(apiToken).getOrThrow()
return firebaseSessionUser.user
}
private suspend fun commitEmailAuthSession(
token: String?,
userId: String?,
email: String?,
displayName: String?
): User {
val resolvedToken = token?.takeIf { it.isNotBlank() }
?: error("Backend auth succeeded without API token.")
val resolvedUserId = userId?.takeIf { it.isNotBlank() }
?: error("Backend auth succeeded without user id.")
firebaseSessionManager.signOut()
apiTokenStore.saveToken(resolvedToken).getOrThrow()
localEmailAuthStore.saveProfile(
userId = resolvedUserId,
email = email,
displayName = displayName
).getOrThrow()
return User(
userId = resolvedUserId,
email = email,
displayName = displayName,
isAuthenticated = true
)
}
private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String {
val response = apolloClient
.mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken))

View File

@@ -0,0 +1,13 @@
package org.db3.airmq.sdk.auth
data class LocalEmailAuthProfile(
val userId: String,
val email: String?,
val displayName: String?
)
interface LocalEmailAuthStore {
fun saveProfile(userId: String, email: String?, displayName: String?): Result<Unit>
fun getProfile(): LocalEmailAuthProfile?
fun clearProfile(): Result<Unit>
}

View File

@@ -11,7 +11,7 @@ class SharedPreferencesApiTokenStore @Inject constructor(
) : ApiTokenStore {
private val sharedPreferences =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
context.getSharedPreferences(AirMqAuthPreferences.FILE_NAME, Context.MODE_PRIVATE)
override fun getToken(): String? = sharedPreferences.getString(KEY_API_TOKEN, null)
@@ -25,7 +25,6 @@ class SharedPreferencesApiTokenStore @Inject constructor(
}
private companion object {
private const val PREFERENCES_NAME = "airmq_auth"
private const val KEY_API_TOKEN = "api_token"
}
}

View File

@@ -0,0 +1,48 @@
package org.db3.airmq.sdk.auth
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SharedPreferencesLocalEmailAuthStore @Inject constructor(
@ApplicationContext context: Context
) : LocalEmailAuthStore {
private val sharedPreferences =
context.getSharedPreferences(AirMqAuthPreferences.FILE_NAME, Context.MODE_PRIVATE)
override fun saveProfile(userId: String, email: String?, displayName: String?): Result<Unit> = runCatching {
require(userId.isNotBlank()) { "User id cannot be blank." }
sharedPreferences.edit()
.putString(KEY_LOCAL_EMAIL_USER_ID, userId)
.putString(KEY_LOCAL_EMAIL_ADDRESS, email)
.putString(KEY_LOCAL_EMAIL_DISPLAY_NAME, displayName)
.apply()
}
override fun getProfile(): LocalEmailAuthProfile? {
val userId = sharedPreferences.getString(KEY_LOCAL_EMAIL_USER_ID, null)
?.takeIf { it.isNotBlank() } ?: return null
return LocalEmailAuthProfile(
userId = userId,
email = sharedPreferences.getString(KEY_LOCAL_EMAIL_ADDRESS, null),
displayName = sharedPreferences.getString(KEY_LOCAL_EMAIL_DISPLAY_NAME, null)
)
}
override fun clearProfile(): Result<Unit> = runCatching {
sharedPreferences.edit()
.remove(KEY_LOCAL_EMAIL_USER_ID)
.remove(KEY_LOCAL_EMAIL_ADDRESS)
.remove(KEY_LOCAL_EMAIL_DISPLAY_NAME)
.apply()
}
private companion object {
private const val KEY_LOCAL_EMAIL_USER_ID = "local_email_user_id"
private const val KEY_LOCAL_EMAIL_ADDRESS = "local_email_address"
private const val KEY_LOCAL_EMAIL_DISPLAY_NAME = "local_email_display_name"
}
}

View File

@@ -13,7 +13,9 @@ import org.db3.airmq.sdk.auth.AuthService
import org.db3.airmq.sdk.auth.FirebaseAuthService
import org.db3.airmq.sdk.auth.FirebaseSessionManager
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl
import org.db3.airmq.sdk.auth.LocalEmailAuthStore
import org.db3.airmq.sdk.auth.SharedPreferencesApiTokenStore
import org.db3.airmq.sdk.auth.SharedPreferencesLocalEmailAuthStore
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl
import org.db3.airmq.sdk.map.MapServiceImpl
@@ -54,6 +56,10 @@ abstract class SDKBindModule {
@Singleton
abstract fun bindApiTokenStore(impl: SharedPreferencesApiTokenStore): ApiTokenStore
@Binds
@Singleton
abstract fun bindLocalEmailAuthStore(impl: SharedPreferencesLocalEmailAuthStore): LocalEmailAuthStore
@Binds
@Singleton
abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager

View File

@@ -10,6 +10,8 @@ import org.db3.airmq.sdk.auth.model.AuthProvider
import org.db3.airmq.sdk.auth.model.User
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -20,7 +22,8 @@ class FirebaseAuthServiceTest {
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed"))
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore()
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
val localEmailStore = FakeLocalEmailAuthStore()
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
@@ -39,8 +42,9 @@ class FirebaseAuthServiceTest {
)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore()
val localEmailStore = FakeLocalEmailAuthStore()
mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed"))
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
@@ -50,11 +54,18 @@ class FirebaseAuthServiceTest {
}
@Test
fun isAuthenticated_trueOnlyWhenFirebaseAndApiTokenPresent() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = true)
fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest {
val sessionManager = FakeFirebaseSessionManager(
isSignedIn = true,
signInResult = FirebaseSessionUser(
user = User("uid-fb", "fb@test.dev", "FB", true),
firebaseAccessToken = "ft"
)
)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
val localEmailStore = FakeLocalEmailAuthStore()
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
assertTrue(service.isAuthenticated())
@@ -62,6 +73,52 @@ class FirebaseAuthServiceTest {
assertFalse(service.isAuthenticated())
}
@Test
fun isAuthenticated_trueWhenLocalEmailProfileAndApiTokenPresent() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val localEmailStore = FakeLocalEmailAuthStore().apply {
saveProfile("backend-id", "e@mail.test", "Name").getOrThrow()
}
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
assertTrue(service.isAuthenticated())
}
@Test
fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val localEmailStore = FakeLocalEmailAuthStore().apply {
saveProfile("bid", "local@test.dev", "Local User").getOrThrow()
}
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
val user = service.getUser()
assertNotNull(user)
assertEquals("bid", user!!.userId)
assertEquals("local@test.dev", user.email)
assertEquals("Local User", user.displayName)
assertTrue(user.isAuthenticated)
}
@Test
fun signOut_clearsTokenAndLocalProfile() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token")
val localEmailStore = FakeLocalEmailAuthStore().apply {
saveProfile("id", "a@b.c", null).getOrThrow()
}
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
assertTrue(service.signOut().isSuccess)
assertNull(tokenStore.storedToken)
assertNull(localEmailStore.getProfile())
}
private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) {
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall
@@ -103,3 +160,17 @@ private class FakeApiTokenStore(
return Result.success(Unit)
}
}
private class FakeLocalEmailAuthStore(
private var profile: LocalEmailAuthProfile? = null
) : LocalEmailAuthStore {
override fun saveProfile(userId: String, email: String?, displayName: String?): Result<Unit> = runCatching {
profile = LocalEmailAuthProfile(userId, email, displayName)
}
override fun getProfile(): LocalEmailAuthProfile? = profile
override fun clearProfile(): Result<Unit> = runCatching {
profile = null
}
}