diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt
index 47a07ce..beddcbd 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt
+++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt
@@ -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)
+ }
+ }
+ }
}
diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt
index 0b3c1b6..d3aac9d 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt
+++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt
@@ -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) {
diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt
index 1095c6c..72d8ed9 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt
+++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt
@@ -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) {
diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml
index 10b9ce4..6cf8598 100644
--- a/app/src/main/res/values-be-rBY/strings.xml
+++ b/app/src/main/res/values-be-rBY/strings.xml
@@ -82,6 +82,9 @@
Ваша прылада адключылася ад сеткі!
%s цяпер неактыўны. Націсніце на гэта апавяшчэнне, каб адчыніць канфігурацыю прылады
Памылка аўтарызацыі Google
+ Увядзіце email і пароль
+ Увядзіце карэктны email
+ Не ўдалося ўвайсці
Патрабуецца рэгістрацыя
Уваходзячы ў сістэму, вы згаджаецеся з нашымі %1$s и %2$s
Што гэта значыць?
diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml
index dd6df1e..670c848 100644
--- a/app/src/main/res/values-ru-rRU/strings.xml
+++ b/app/src/main/res/values-ru-rRU/strings.xml
@@ -82,6 +82,9 @@
Ваше устройство отключилось от сети!
%s теперь неактивен. Нажмите на это уведомление, чтобы открыть конфигурацию устройства
Ошибка авторизации Google
+ Введите email и пароль
+ Введите корректный email
+ Не удалось войти
Требуется регистрация
Входя в систему, вы соглашаетесь с нашими %1$s и %2$s
Что это значит?
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e524e3e..1efbfbf 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -142,6 +142,9 @@
Google authorisation failed
+ Enter email and password
+ Enter a valid email address
+ Sign-in failed
Copied
Device requires registration
diff --git a/sdk/src/main/graphql/LoginLocal.graphql b/sdk/src/main/graphql/LoginLocal.graphql
new file mode 100644
index 0000000..48da0b7
--- /dev/null
+++ b/sdk/src/main/graphql/LoginLocal.graphql
@@ -0,0 +1,11 @@
+mutation LoginLocal($input: LocalAuthInput!) {
+ loginLocal(input: $input) {
+ token
+ name
+ email
+ roles
+ _id
+ regDate
+ emailVerified
+ }
+}
diff --git a/sdk/src/main/graphql/Register.graphql b/sdk/src/main/graphql/Register.graphql
new file mode 100644
index 0000000..05f7421
--- /dev/null
+++ b/sdk/src/main/graphql/Register.graphql
@@ -0,0 +1,11 @@
+mutation Register($input: RegisterInput!) {
+ register(input: $input) {
+ token
+ name
+ email
+ roles
+ _id
+ regDate
+ emailVerified
+ }
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AirMqAuthPreferences.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AirMqAuthPreferences.kt
new file mode 100644
index 0000000..dd7b4eb
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AirMqAuthPreferences.kt
@@ -0,0 +1,5 @@
+package org.db3.airmq.sdk.auth
+
+internal object AirMqAuthPreferences {
+ const val FILE_NAME = "airmq_auth"
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthService.kt
index df1368f..70ed1a7 100644
--- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthService.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthService.kt
@@ -7,5 +7,7 @@ interface AuthService {
suspend fun getUser(): User?
suspend fun isAuthenticated(): Boolean
suspend fun signIn(provider: AuthProvider, token: String): Result
+ suspend fun loginWithEmailPassword(email: String, password: String): Result
+ suspend fun registerWithEmail(email: String, password: String, name: String): Result
suspend fun signOut(): Result
}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt
index 1370275..eece682 100644
--- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt
@@ -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 = runCatching {
@@ -29,18 +44,76 @@ class FirebaseAuthService @Inject constructor(
}
}
+ override suspend fun loginWithEmailPassword(email: String, password: String): Result = 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 = 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 = 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))
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/LocalEmailAuthStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/LocalEmailAuthStore.kt
new file mode 100644
index 0000000..14425f2
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/LocalEmailAuthStore.kt
@@ -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
+ fun getProfile(): LocalEmailAuthProfile?
+ fun clearProfile(): Result
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt
index 00c0a93..645fd2d 100644
--- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt
@@ -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"
}
}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesLocalEmailAuthStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesLocalEmailAuthStore.kt
new file mode 100644
index 0000000..4ec01c4
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesLocalEmailAuthStore.kt
@@ -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 = 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 = 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"
+ }
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
index 4b58934..25eecf9 100644
--- a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
@@ -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
diff --git a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt
index 3952fcf..2f22068 100644
--- a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt
+++ b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt
@@ -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()
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()
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()
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()
+ 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()
+ 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()
+ 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>()
every { apolloClient.mutation(any()) } 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 = runCatching {
+ profile = LocalEmailAuthProfile(userId, email, displayName)
+ }
+
+ override fun getProfile(): LocalEmailAuthProfile? = profile
+
+ override fun clearProfile(): Result = runCatching {
+ profile = null
+ }
+}