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

@@ -1,7 +1,9 @@
package org.db3.airmq.features.login
import android.content.Context
import android.util.Patterns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
@@ -11,14 +13,17 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.db3.airmq.R
import org.db3.airmq.features.login.EmailLoginScreenContract.Action
import org.db3.airmq.features.login.EmailLoginScreenContract.Event
import org.db3.airmq.features.login.EmailLoginScreenContract.State
import org.db3.airmq.sdk.auth.AuthService
@HiltViewModel
class EmailLoginViewModel @Inject constructor(
@ApplicationContext private val appContext: Context
@ApplicationContext private val appContext: Context,
private val authService: AuthService
) : ViewModel() {
private val _uiState = MutableStateFlow(State())
@@ -35,19 +40,45 @@ class EmailLoginViewModel @Inject constructor(
is Event.PasswordChanged -> {
_uiState.value = _uiState.value.copy(password = event.value)
}
Event.SignInClicked -> {
_uiState.value = _uiState.value.copy(isLoading = true)
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.coming_soon))
)
_uiState.value = _uiState.value.copy(isLoading = false)
}
Event.RegisterClicked -> {
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.coming_soon))
)
}
Event.SignInClicked -> submitEmailAuth(register = false)
Event.RegisterClicked -> submitEmailAuth(register = true)
Event.BackClicked -> Unit
}
}
private fun submitEmailAuth(register: Boolean) {
val email = _uiState.value.email.trim()
val password = _uiState.value.password
if (email.isEmpty() || password.isEmpty()) {
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.toast_email_fields_required))
)
return
}
if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) {
_actions.tryEmit(
Action.ShowMessage(appContext.getString(R.string.toast_invalid_email))
)
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
try {
val result = if (register) {
authService.registerWithEmail(email, password, name = "")
} else {
authService.loginWithEmailPassword(email, password)
}
if (result.isSuccess) {
_actions.tryEmit(Action.OpenManage)
} else {
val message = result.exceptionOrNull()?.message
?: appContext.getString(R.string.toast_email_auth_failed)
_actions.tryEmit(Action.ShowMessage(message))
}
} finally {
_uiState.value = _uiState.value.copy(isLoading = false)
}
}
}
}

View File

@@ -25,9 +25,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
@@ -63,6 +67,16 @@ fun ManageScreen(
viewModel: ManageViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, viewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refreshAuthState()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
LaunchedEffect(viewModel) {
viewModel.actions.collectLatest { action ->
when (action) {

View File

@@ -71,7 +71,7 @@ class ManageViewModel @Inject constructor(
private fun initialState(): State = anonymousState()
private fun refreshAuthState() {
fun refreshAuthState() {
viewModelScope.launch {
val session = authService.getUser()
if (session?.isAuthenticated == true) {

View File

@@ -82,6 +82,9 @@
<string name="notification_device_status_title">Ваша прылада адключылася ад сеткі!</string>
<string name="notification_device_status_text">%s цяпер неактыўны. Націсніце на гэта апавяшчэнне, каб адчыніць канфігурацыю прылады</string>
<string name="toast_oauth_failed">Памылка аўтарызацыі Google</string>
<string name="toast_email_fields_required">Увядзіце email і пароль</string>
<string name="toast_invalid_email">Увядзіце карэктны email</string>
<string name="toast_email_auth_failed">Не ўдалося ўвайсці</string>
<string name="snackbar_not_registered">Патрабуецца рэгістрацыя</string>
<string name="text_policy_label">Уваходзячы ў сістэму, вы згаджаецеся з нашымі %1$s и %2$s</string>
<string name="text_what_does_it_mean"><u>Што гэта значыць?</u></string>

View File

@@ -82,6 +82,9 @@
<string name="notification_device_status_title">Ваше устройство отключилось от сети!</string>
<string name="notification_device_status_text">%s теперь неактивен. Нажмите на это уведомление, чтобы открыть конфигурацию устройства</string>
<string name="toast_oauth_failed">Ошибка авторизации Google</string>
<string name="toast_email_fields_required">Введите email и пароль</string>
<string name="toast_invalid_email">Введите корректный email</string>
<string name="toast_email_auth_failed">Не удалось войти</string>
<string name="snackbar_not_registered">Требуется регистрация</string>
<string name="text_policy_label">Входя в систему, вы соглашаетесь с нашими %1$s и %2$s</string>
<string name="text_what_does_it_mean"><u>Что это значит?</u></string>

View File

@@ -142,6 +142,9 @@
<!-- Snackbars and toasts -->
<string name="toast_oauth_failed" tools:ignore="UnusedResources">Google authorisation failed</string>
<string name="toast_email_fields_required">Enter email and password</string>
<string name="toast_invalid_email">Enter a valid email address</string>
<string name="toast_email_auth_failed">Sign-in failed</string>
<string name="toast_copied">Copied</string>
<string name="snackbar_not_registered">Device requires registration</string>

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
}
}