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:
@@ -1,7 +1,9 @@
|
|||||||
package org.db3.airmq.features.login
|
package org.db3.airmq.features.login
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Patterns
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -11,14 +13,17 @@ import kotlinx.coroutines.flow.SharedFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.db3.airmq.R
|
import org.db3.airmq.R
|
||||||
import org.db3.airmq.features.login.EmailLoginScreenContract.Action
|
import org.db3.airmq.features.login.EmailLoginScreenContract.Action
|
||||||
import org.db3.airmq.features.login.EmailLoginScreenContract.Event
|
import org.db3.airmq.features.login.EmailLoginScreenContract.Event
|
||||||
import org.db3.airmq.features.login.EmailLoginScreenContract.State
|
import org.db3.airmq.features.login.EmailLoginScreenContract.State
|
||||||
|
import org.db3.airmq.sdk.auth.AuthService
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class EmailLoginViewModel @Inject constructor(
|
class EmailLoginViewModel @Inject constructor(
|
||||||
@ApplicationContext private val appContext: Context
|
@ApplicationContext private val appContext: Context,
|
||||||
|
private val authService: AuthService
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(State())
|
private val _uiState = MutableStateFlow(State())
|
||||||
@@ -35,19 +40,45 @@ class EmailLoginViewModel @Inject constructor(
|
|||||||
is Event.PasswordChanged -> {
|
is Event.PasswordChanged -> {
|
||||||
_uiState.value = _uiState.value.copy(password = event.value)
|
_uiState.value = _uiState.value.copy(password = event.value)
|
||||||
}
|
}
|
||||||
Event.SignInClicked -> {
|
Event.SignInClicked -> submitEmailAuth(register = false)
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
Event.RegisterClicked -> submitEmailAuth(register = 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.BackClicked -> Unit
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
@@ -63,6 +67,16 @@ fun ManageScreen(
|
|||||||
viewModel: ManageViewModel = hiltViewModel()
|
viewModel: ManageViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
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) {
|
LaunchedEffect(viewModel) {
|
||||||
viewModel.actions.collectLatest { action ->
|
viewModel.actions.collectLatest { action ->
|
||||||
when (action) {
|
when (action) {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class ManageViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun initialState(): State = anonymousState()
|
private fun initialState(): State = anonymousState()
|
||||||
|
|
||||||
private fun refreshAuthState() {
|
fun refreshAuthState() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val session = authService.getUser()
|
val session = authService.getUser()
|
||||||
if (session?.isAuthenticated == true) {
|
if (session?.isAuthenticated == true) {
|
||||||
|
|||||||
@@ -82,6 +82,9 @@
|
|||||||
<string name="notification_device_status_title">Ваша прылада адключылася ад сеткі!</string>
|
<string name="notification_device_status_title">Ваша прылада адключылася ад сеткі!</string>
|
||||||
<string name="notification_device_status_text">%s цяпер неактыўны. Націсніце на гэта апавяшчэнне, каб адчыніць канфігурацыю прылады</string>
|
<string name="notification_device_status_text">%s цяпер неактыўны. Націсніце на гэта апавяшчэнне, каб адчыніць канфігурацыю прылады</string>
|
||||||
<string name="toast_oauth_failed">Памылка аўтарызацыі Google</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="snackbar_not_registered">Патрабуецца рэгістрацыя</string>
|
||||||
<string name="text_policy_label">Уваходзячы ў сістэму, вы згаджаецеся з нашымі %1$s и %2$s</string>
|
<string name="text_policy_label">Уваходзячы ў сістэму, вы згаджаецеся з нашымі %1$s и %2$s</string>
|
||||||
<string name="text_what_does_it_mean"><u>Што гэта значыць?</u></string>
|
<string name="text_what_does_it_mean"><u>Што гэта значыць?</u></string>
|
||||||
|
|||||||
@@ -82,6 +82,9 @@
|
|||||||
<string name="notification_device_status_title">Ваше устройство отключилось от сети!</string>
|
<string name="notification_device_status_title">Ваше устройство отключилось от сети!</string>
|
||||||
<string name="notification_device_status_text">%s теперь неактивен. Нажмите на это уведомление, чтобы открыть конфигурацию устройства</string>
|
<string name="notification_device_status_text">%s теперь неактивен. Нажмите на это уведомление, чтобы открыть конфигурацию устройства</string>
|
||||||
<string name="toast_oauth_failed">Ошибка авторизации Google</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="snackbar_not_registered">Требуется регистрация</string>
|
||||||
<string name="text_policy_label">Входя в систему, вы соглашаетесь с нашими %1$s и %2$s</string>
|
<string name="text_policy_label">Входя в систему, вы соглашаетесь с нашими %1$s и %2$s</string>
|
||||||
<string name="text_what_does_it_mean"><u>Что это значит?</u></string>
|
<string name="text_what_does_it_mean"><u>Что это значит?</u></string>
|
||||||
|
|||||||
@@ -142,6 +142,9 @@
|
|||||||
|
|
||||||
<!-- Snackbars and toasts -->
|
<!-- Snackbars and toasts -->
|
||||||
<string name="toast_oauth_failed" tools:ignore="UnusedResources">Google authorisation failed</string>
|
<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="toast_copied">Copied</string>
|
||||||
<string name="snackbar_not_registered">Device requires registration</string>
|
<string name="snackbar_not_registered">Device requires registration</string>
|
||||||
|
|
||||||
|
|||||||
11
sdk/src/main/graphql/LoginLocal.graphql
Normal file
11
sdk/src/main/graphql/LoginLocal.graphql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
mutation LoginLocal($input: LocalAuthInput!) {
|
||||||
|
loginLocal(input: $input) {
|
||||||
|
token
|
||||||
|
name
|
||||||
|
email
|
||||||
|
roles
|
||||||
|
_id
|
||||||
|
regDate
|
||||||
|
emailVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
11
sdk/src/main/graphql/Register.graphql
Normal file
11
sdk/src/main/graphql/Register.graphql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
mutation Register($input: RegisterInput!) {
|
||||||
|
register(input: $input) {
|
||||||
|
token
|
||||||
|
name
|
||||||
|
email
|
||||||
|
roles
|
||||||
|
_id
|
||||||
|
regDate
|
||||||
|
emailVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.db3.airmq.sdk.auth
|
||||||
|
|
||||||
|
internal object AirMqAuthPreferences {
|
||||||
|
const val FILE_NAME = "airmq_auth"
|
||||||
|
}
|
||||||
@@ -7,5 +7,7 @@ interface AuthService {
|
|||||||
suspend fun getUser(): User?
|
suspend fun getUser(): User?
|
||||||
suspend fun isAuthenticated(): Boolean
|
suspend fun isAuthenticated(): Boolean
|
||||||
suspend fun signIn(provider: AuthProvider, token: String): Result<User>
|
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>
|
suspend fun signOut(): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
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.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.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.AuthProvider
|
||||||
import org.db3.airmq.sdk.auth.model.User
|
import org.db3.airmq.sdk.auth.model.User
|
||||||
|
import org.db3.airmq.sdk.type.LocalAuthInput
|
||||||
|
import org.db3.airmq.sdk.type.RegisterInput
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class FirebaseAuthService @Inject constructor(
|
class FirebaseAuthService @Inject constructor(
|
||||||
private val firebaseSessionManager: FirebaseSessionManager,
|
private val firebaseSessionManager: FirebaseSessionManager,
|
||||||
private val apolloClient: ApolloClient,
|
private val apolloClient: ApolloClient,
|
||||||
private val apiTokenStore: ApiTokenStore
|
private val apiTokenStore: ApiTokenStore,
|
||||||
|
private val localEmailAuthStore: LocalEmailAuthStore
|
||||||
) : AuthService {
|
) : 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 {
|
override suspend fun isAuthenticated(): Boolean {
|
||||||
val hasFirebaseUser = firebaseSessionManager.isSignedIn()
|
if (apiTokenStore.getToken().isNullOrBlank()) return false
|
||||||
val hasApiToken = !apiTokenStore.getToken().isNullOrBlank()
|
return getUser() != null
|
||||||
return hasFirebaseUser && hasApiToken
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching {
|
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 {
|
override suspend fun signOut(): Result<Unit> = runCatching {
|
||||||
firebaseSessionManager.signOut()
|
firebaseSessionManager.signOut()
|
||||||
apiTokenStore.clearToken().getOrThrow()
|
apiTokenStore.clearToken().getOrThrow()
|
||||||
|
localEmailAuthStore.clearProfile().getOrThrow()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun signInWithGoogle(token: String): User {
|
private suspend fun signInWithGoogle(token: String): User {
|
||||||
|
localEmailAuthStore.clearProfile().getOrThrow()
|
||||||
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
|
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
|
||||||
val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
|
val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
|
||||||
apiTokenStore.saveToken(apiToken).getOrThrow()
|
apiTokenStore.saveToken(apiToken).getOrThrow()
|
||||||
return firebaseSessionUser.user
|
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 {
|
private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String {
|
||||||
val response = apolloClient
|
val response = apolloClient
|
||||||
.mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken))
|
.mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ class SharedPreferencesApiTokenStore @Inject constructor(
|
|||||||
) : ApiTokenStore {
|
) : ApiTokenStore {
|
||||||
|
|
||||||
private val sharedPreferences =
|
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)
|
override fun getToken(): String? = sharedPreferences.getString(KEY_API_TOKEN, null)
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ class SharedPreferencesApiTokenStore @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
private const val PREFERENCES_NAME = "airmq_auth"
|
|
||||||
private const val KEY_API_TOKEN = "api_token"
|
private const val KEY_API_TOKEN = "api_token"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import org.db3.airmq.sdk.auth.AuthService
|
|||||||
import org.db3.airmq.sdk.auth.FirebaseAuthService
|
import org.db3.airmq.sdk.auth.FirebaseAuthService
|
||||||
import org.db3.airmq.sdk.auth.FirebaseSessionManager
|
import org.db3.airmq.sdk.auth.FirebaseSessionManager
|
||||||
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl
|
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.SharedPreferencesApiTokenStore
|
||||||
|
import org.db3.airmq.sdk.auth.SharedPreferencesLocalEmailAuthStore
|
||||||
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
|
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
|
||||||
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl
|
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl
|
||||||
import org.db3.airmq.sdk.map.MapServiceImpl
|
import org.db3.airmq.sdk.map.MapServiceImpl
|
||||||
@@ -54,6 +56,10 @@ abstract class SDKBindModule {
|
|||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindApiTokenStore(impl: SharedPreferencesApiTokenStore): ApiTokenStore
|
abstract fun bindApiTokenStore(impl: SharedPreferencesApiTokenStore): ApiTokenStore
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindLocalEmailAuthStore(impl: SharedPreferencesLocalEmailAuthStore): LocalEmailAuthStore
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager
|
abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -20,7 +22,8 @@ class FirebaseAuthServiceTest {
|
|||||||
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed"))
|
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed"))
|
||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore()
|
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")
|
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||||
|
|
||||||
@@ -39,8 +42,9 @@ class FirebaseAuthServiceTest {
|
|||||||
)
|
)
|
||||||
val apolloClient = mockk<ApolloClient>()
|
val apolloClient = mockk<ApolloClient>()
|
||||||
val tokenStore = FakeApiTokenStore()
|
val tokenStore = FakeApiTokenStore()
|
||||||
|
val localEmailStore = FakeLocalEmailAuthStore()
|
||||||
mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed"))
|
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")
|
val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
|
||||||
|
|
||||||
@@ -50,11 +54,18 @@ class FirebaseAuthServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun isAuthenticated_trueOnlyWhenFirebaseAndApiTokenPresent() = runTest {
|
fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest {
|
||||||
val sessionManager = FakeFirebaseSessionManager(isSignedIn = true)
|
val sessionManager = FakeFirebaseSessionManager(
|
||||||
|
isSignedIn = true,
|
||||||
|
signInResult = FirebaseSessionUser(
|
||||||
|
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")
|
||||||
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore)
|
val localEmailStore = FakeLocalEmailAuthStore()
|
||||||
|
val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore)
|
||||||
|
|
||||||
assertTrue(service.isAuthenticated())
|
assertTrue(service.isAuthenticated())
|
||||||
|
|
||||||
@@ -62,6 +73,52 @@ class FirebaseAuthServiceTest {
|
|||||||
assertFalse(service.isAuthenticated())
|
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) {
|
private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) {
|
||||||
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
|
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
|
||||||
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall
|
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall
|
||||||
@@ -103,3 +160,17 @@ private class FakeApiTokenStore(
|
|||||||
return Result.success(Unit)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user