Implement Firebase Google sign-in with enum-based auth contracts.

Refactor AuthService to use AuthProvider and User, add Firebase-backed auth wiring for login/manage flows, and fix app-level Google services configuration so Credential Manager sign-in works reliably.

Made-with: Cursor
This commit is contained in:
2026-03-01 21:15:09 +01:00
parent 90792c601c
commit 91a9521f3e
15 changed files with 269 additions and 48 deletions

View File

@@ -5,6 +5,8 @@ plugins {
alias(libs.plugins.hilt.android) alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.google.services)
alias(libs.plugins.firebase.crashlytics)
} }
fun gitCommitCount(): String { fun gitCommitCount(): String {
@@ -81,6 +83,9 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.credentials)
implementation(libs.androidx.credentials.play.services.auth)
implementation(libs.googleid)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
// Tests // Tests

View File

@@ -12,7 +12,12 @@
"package_name": "org.db3.airmq" "package_name": "org.db3.airmq"
} }
}, },
"oauth_client": [], "oauth_client": [
{
"client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [ "api_key": [
{ {
"current_key": "AIzaSyCcgdvdp8xec2dKUqlQHl9peQAyOWRVga4" "current_key": "AIzaSyCcgdvdp8xec2dKUqlQHl9peQAyOWRVga4"
@@ -22,7 +27,7 @@
"appinvite_service": { "appinvite_service": {
"other_platform_oauth_client": [ "other_platform_oauth_client": [
{ {
"client_id": "223884730019-1hkbacov3tem4snsih7vs218lt9ppvde.apps.googleusercontent.com", "client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
"client_type": 3 "client_type": 3
} }
] ]
@@ -52,6 +57,10 @@
"package_name": "org.db3.airmq.debug", "package_name": "org.db3.airmq.debug",
"certificate_hash": "195356c67cabf18c9ab0a8ccfcec45c3346b6b6c" "certificate_hash": "195356c67cabf18c9ab0a8ccfcec45c3346b6b6c"
} }
},
{
"client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
"client_type": 3
} }
], ],
"api_key": [ "api_key": [
@@ -63,7 +72,7 @@
"appinvite_service": { "appinvite_service": {
"other_platform_oauth_client": [ "other_platform_oauth_client": [
{ {
"client_id": "223884730019-1hkbacov3tem4snsih7vs218lt9ppvde.apps.googleusercontent.com", "client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
"client_type": 3 "client_type": 3
} }
] ]

View File

@@ -1,6 +1,18 @@
package org.db3.airmq.features.login package org.db3.airmq.features.login
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialInterruptedException
import androidx.credentials.exceptions.GetCredentialProviderConfigurationException
import androidx.credentials.exceptions.NoCredentialException
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
@@ -53,6 +65,10 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.google.android.libraries.identity.googleid.GetGoogleIdOption
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import org.db3.airmq.R import org.db3.airmq.R
import org.db3.airmq.features.login.LoginScreenContract.Action import org.db3.airmq.features.login.LoginScreenContract.Action
@@ -63,6 +79,7 @@ import org.db3.airmq.ui.theme.AirMQTheme
private val LegacyLoginGradientStart = Color(0xFF449CF5) private val LegacyLoginGradientStart = Color(0xFF449CF5)
private val LegacyLoginGradientEnd = Color(0xFF5CE4BB) private val LegacyLoginGradientEnd = Color(0xFF5CE4BB)
private val LegacyFacebookBlue = Color(0xFF3B5998) private val LegacyFacebookBlue = Color(0xFF3B5998)
private const val LOGIN_TAG = "LoginScreen"
@Composable @Composable
fun LoginScreen( fun LoginScreen(
@@ -77,6 +94,15 @@ fun LoginScreen(
viewModel.actions.collectLatest { action -> viewModel.actions.collectLatest { action ->
when (action) { when (action) {
Action.OpenManage -> onLogInToManage() Action.OpenManage -> onLogInToManage()
Action.LaunchGoogleSignIn -> {
val result = launchGoogleSignIn(context)
result.fold(
onSuccess = { viewModel.onEvent(Event.GoogleTokenReceived(it)) },
onFailure = { error ->
viewModel.onEvent(Event.GoogleSignInFailed(error.message))
}
)
}
Action.ShowContinueAnonymousDialog -> showContinueAnonymousDialog = true Action.ShowContinueAnonymousDialog -> showContinueAnonymousDialog = true
Action.OpenPrivacyPolicy -> { Action.OpenPrivacyPolicy -> {
Toast.makeText(context, context.getString(R.string.coming_soon), Toast.LENGTH_SHORT).show() Toast.makeText(context, context.getString(R.string.coming_soon), Toast.LENGTH_SHORT).show()
@@ -362,6 +388,59 @@ private fun PrivacyAndTermsFooter(onEvent: (Event) -> Unit) {
) )
} }
private suspend fun launchGoogleSignIn(context: Context): Result<String> = runCatching {
val activity = context.findActivity()
val request = GetCredentialRequest.Builder()
.addCredentialOption(
GetSignInWithGoogleOption.Builder(context.getString(R.string.default_web_client_id))
.build()
)
.build()
val response = CredentialManager.create(context).getCredential(
context = activity,
request = request
)
val credential = response.credential
if (credential is CustomCredential &&
credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL
) {
GoogleIdTokenCredential.createFrom(credential.data).idToken
} else {
error("Unsupported credential type for Google sign-in.")
}
}.recoverCatching {
when (it) {
is GetCredentialException -> {
logGoogleSignInError(it)
throw IllegalStateException(context.getString(R.string.toast_oauth_failed), it)
}
is GoogleIdTokenParsingException -> {
Log.e(LOGIN_TAG, "Google ID token parsing failed", it)
throw IllegalStateException(context.getString(R.string.toast_oauth_failed), it)
}
else -> throw it
}
}
private fun logGoogleSignInError(exception: GetCredentialException) {
val message = when (exception) {
is GetCredentialCancellationException -> "Google sign-in cancelled by user"
is NoCredentialException -> "No Google credential available on device"
is GetCredentialProviderConfigurationException -> "Credential provider is not configured correctly"
is GetCredentialInterruptedException -> "Credential flow interrupted; try again"
else -> "CredentialManager returned an unknown sign-in error"
}
Log.e(LOGIN_TAG, message, exception)
}
private tailrec fun Context.findActivity(): Activity {
return when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> error("Unable to find Activity context for Google sign-in.")
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun LoginScreenPreview() { private fun LoginScreenPreview() {

View File

@@ -8,6 +8,7 @@ object LoginScreenContract {
sealed interface Action { sealed interface Action {
data object OpenManage : Action data object OpenManage : Action
data object LaunchGoogleSignIn : Action
data object ShowContinueAnonymousDialog : Action data object ShowContinueAnonymousDialog : Action
data object OpenPrivacyPolicy : Action data object OpenPrivacyPolicy : Action
data object OpenTermsAndConditions : Action data object OpenTermsAndConditions : Action
@@ -16,6 +17,8 @@ object LoginScreenContract {
sealed interface Event { sealed interface Event {
data object GoogleClicked : Event data object GoogleClicked : Event
data class GoogleTokenReceived(val idToken: String) : Event
data class GoogleSignInFailed(val message: String? = null) : Event
data object FacebookClicked : Event data object FacebookClicked : Event
data object ContinueAnonymousClicked : Event data object ContinueAnonymousClicked : Event
data object ContinueAnonymousConfirmed : Event data object ContinueAnonymousConfirmed : Event

View File

@@ -2,9 +2,11 @@ package org.db3.airmq.features.login
import android.content.Context import android.content.Context
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
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -15,10 +17,13 @@ import org.db3.airmq.R
import org.db3.airmq.features.login.LoginScreenContract.Action import org.db3.airmq.features.login.LoginScreenContract.Action
import org.db3.airmq.features.login.LoginScreenContract.Event import org.db3.airmq.features.login.LoginScreenContract.Event
import org.db3.airmq.features.login.LoginScreenContract.State import org.db3.airmq.features.login.LoginScreenContract.State
import org.db3.airmq.sdk.auth.AuthService
import org.db3.airmq.sdk.auth.model.AuthProvider
@HiltViewModel @HiltViewModel
class LoginViewModel @Inject constructor( class LoginViewModel @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())
@@ -30,7 +35,19 @@ class LoginViewModel @Inject constructor(
fun onEvent(event: Event) { fun onEvent(event: Event) {
when (event) { when (event) {
Event.GoogleClicked -> { Event.GoogleClicked -> {
_actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon))) _uiState.value = _uiState.value.copy(isLoading = true)
_actions.tryEmit(Action.LaunchGoogleSignIn)
}
is Event.GoogleTokenReceived -> {
signInWithGoogle(event.idToken)
}
is Event.GoogleSignInFailed -> {
_uiState.value = _uiState.value.copy(isLoading = false)
_actions.tryEmit(
Action.ShowMessage(
event.message ?: appContext.getString(R.string.toast_oauth_failed)
)
)
} }
Event.FacebookClicked -> { Event.FacebookClicked -> {
_actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon))) _actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon)))
@@ -50,4 +67,16 @@ class LoginViewModel @Inject constructor(
} }
} }
} }
private fun signInWithGoogle(idToken: String) {
viewModelScope.launch {
val signInResult = authService.signIn(provider = AuthProvider.GOOGLE, token = idToken)
_uiState.value = _uiState.value.copy(isLoading = false)
if (signInResult.isSuccess) {
_actions.tryEmit(Action.OpenManage)
} else {
_actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.toast_oauth_failed)))
}
}
}
} }

View File

@@ -2,9 +2,11 @@ package org.db3.airmq.features.manage
import android.content.Context import android.content.Context
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
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -17,21 +19,25 @@ import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
import org.db3.airmq.features.manage.ManageScreenContract.Event import org.db3.airmq.features.manage.ManageScreenContract.Event
import org.db3.airmq.features.manage.ManageScreenContract.State import org.db3.airmq.features.manage.ManageScreenContract.State
import org.db3.airmq.features.manage.ManageScreenContract.UserMode import org.db3.airmq.features.manage.ManageScreenContract.UserMode
import org.db3.airmq.sdk.auth.AuthService
import org.db3.airmq.sdk.auth.model.User
@HiltViewModel @HiltViewModel
class ManageViewModel @Inject constructor( class ManageViewModel @Inject constructor(
@ApplicationContext private val appContext: Context @ApplicationContext private val appContext: Context,
private val authService: AuthService
) : ViewModel() { ) : ViewModel() {
// Temporary migration stub: keep screen in anonymous mode.
private val forceAnonymous = true
private val _uiState = MutableStateFlow(initialState()) private val _uiState = MutableStateFlow(initialState())
val uiState: StateFlow<State> = _uiState.asStateFlow() val uiState: StateFlow<State> = _uiState.asStateFlow()
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1) private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
val actions: SharedFlow<Action> = _actions.asSharedFlow() val actions: SharedFlow<Action> = _actions.asSharedFlow()
init {
refreshAuthState()
}
fun onEvent(event: Event) { fun onEvent(event: Event) {
when (event) { when (event) {
Event.SettingsClicked -> _actions.tryEmit(Action.OpenSettings) Event.SettingsClicked -> _actions.tryEmit(Action.OpenSettings)
@@ -42,39 +48,44 @@ class ManageViewModel @Inject constructor(
} }
} }
private fun initialState(): State { private fun initialState(): State = anonymousState()
val mode = if (forceAnonymous) {
UserMode.ANONYMOUS private fun refreshAuthState() {
} else { viewModelScope.launch {
UserMode.AUTHORIZED val session = authService.getCurrentSession()
} _uiState.value = if (session?.isAuthenticated == true) {
return when (mode) { authorizedState(session)
UserMode.ANONYMOUS -> State( } else {
userMode = UserMode.ANONYMOUS, anonymousState()
userName = appContext.getString(R.string.text_anonymous_user), }
userEmail = appContext.getString(R.string.text_please_sign_in),
devicesLabel = appContext.getString(R.string.text_sign_in_small)
)
UserMode.AUTHORIZED -> State(
userMode = UserMode.AUTHORIZED,
userName = appContext.getString(R.string.mock_user_name),
userEmail = appContext.getString(R.string.mock_user_email),
devicesLabel = appContext.getString(R.string.text_your_devices),
devices = listOf(
DeviceItem(
id = "device-1",
name = appContext.getString(R.string.mock_device_name_42),
status = appContext.getString(R.string.map_status_online),
hasLocation = true
),
DeviceItem(
id = "device-2",
name = appContext.getString(R.string.mock_device_name_17),
status = appContext.getString(R.string.map_status_offline),
hasLocation = false
)
)
)
} }
} }
private fun anonymousState(): State = State(
userMode = UserMode.ANONYMOUS,
userName = appContext.getString(R.string.text_anonymous_user),
userEmail = appContext.getString(R.string.text_please_sign_in),
devicesLabel = appContext.getString(R.string.text_sign_in_small)
)
private fun authorizedState(user: User): State = State(
userMode = UserMode.AUTHORIZED,
userName = user.displayName ?: appContext.getString(R.string.text_anonymous_user),
userEmail = user.email ?: "",
devicesLabel = appContext.getString(R.string.text_your_devices),
devices = listOf(
DeviceItem(
id = "device-1",
name = appContext.getString(R.string.mock_device_name_42),
status = appContext.getString(R.string.map_status_online),
hasLocation = true
),
DeviceItem(
id = "device-2",
name = appContext.getString(R.string.mock_device_name_17),
status = appContext.getString(R.string.map_status_offline),
hasLocation = false
)
)
)
} }

View File

@@ -142,7 +142,6 @@
<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>
<string name="oauth_token" translatable="false" tools:ignore="Typos">21688843933-n04t4s1h7rjad12tdj1pc9j6kajgh2ka.apps.googleusercontent.com</string>
<string name="facebook_app_id" translatable="false">356744979027664</string> <string name="facebook_app_id" translatable="false">356744979027664</string>
<string name="fb_login_protocol_scheme" translatable="false">fb356744979027664</string> <string name="fb_login_protocol_scheme" translatable="false">fb356744979027664</string>
<string name="text_air_quality">Air quality</string> <string name="text_air_quality">Air quality</string>

View File

@@ -8,4 +8,5 @@ plugins {
alias(libs.plugins.apollo) apply false alias(libs.plugins.apollo) apply false
alias(libs.plugins.hilt.android) apply false alias(libs.plugins.hilt.android) apply false
alias(libs.plugins.google.services) apply false alias(libs.plugins.google.services) apply false
alias(libs.plugins.firebase.crashlytics) apply false
} }

View File

@@ -11,6 +11,7 @@ kotlin = "2.0.21"
composeBom = "2024.09.00" composeBom = "2024.09.00"
navigationCompose = "2.9.3" navigationCompose = "2.9.3"
firebaseBom = "34.4.0" firebaseBom = "34.4.0"
firebaseCrashlyticsPlugin = "3.0.6"
mapsCompose = "6.12.1" mapsCompose = "6.12.1"
osmdroid = "6.1.20" osmdroid = "6.1.20"
apollo = "5.0.0-alpha.4" apollo = "5.0.0-alpha.4"
@@ -19,6 +20,8 @@ hiltNavigationCompose = "1.2.0"
okhttpLogging = "4.12.0" okhttpLogging = "4.12.0"
ksp = "2.0.21-1.0.27" ksp = "2.0.21-1.0.27"
google-services = "4.4.4" google-services = "4.4.4"
androidxCredentials = "1.5.0"
googleid = "1.1.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -43,11 +46,15 @@ firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.r
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
firebase-auth = { group = "com.google.firebase", name = "firebase-auth" }
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo" } apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "androidxCredentials" }
androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidxCredentials" }
googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -58,4 +65,5 @@ apollo = { id = "com.apollographql.apollo", version.ref = "apollo" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
google-services = { id = "com.google.gms.google-services", version.ref = "google-services" } google-services = { id = "com.google.gms.google-services", version.ref = "google-services" }
firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin"}

View File

@@ -2,7 +2,6 @@ plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.apollo) alias(libs.plugins.apollo)
alias(libs.plugins.google.services)
} }
android { android {
@@ -50,6 +49,7 @@ dependencies {
// Firebase // Firebase
implementation(platform(libs.firebase.bom)) implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics) implementation(libs.firebase.analytics)
implementation(libs.firebase.auth)
implementation(libs.firebase.crashlytics) implementation(libs.firebase.crashlytics)
implementation(libs.firebase.messaging) implementation(libs.firebase.messaging)

View File

@@ -1,10 +1,11 @@
package org.db3.airmq.sdk.auth package org.db3.airmq.sdk.auth
import org.db3.airmq.sdk.auth.model.Auth import org.db3.airmq.sdk.auth.model.AuthProvider
import org.db3.airmq.sdk.auth.model.User
interface AuthService { interface AuthService {
suspend fun getCurrentSession(): Auth? suspend fun getCurrentSession(): User?
suspend fun isAuthenticated(): Boolean suspend fun isAuthenticated(): Boolean
suspend fun signIn(provider: String, token: String? = null): Result<Auth> suspend fun signIn(provider: AuthProvider, token: String): Result<User>
suspend fun signOut(): Result<Unit> suspend fun signOut(): Result<Unit>
} }

View File

@@ -0,0 +1,60 @@
package org.db3.airmq.sdk.auth
import com.google.android.gms.tasks.Task
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseUser
import com.google.firebase.auth.GoogleAuthProvider
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.suspendCancellableCoroutine
import org.db3.airmq.sdk.auth.model.AuthProvider
import org.db3.airmq.sdk.auth.model.User
@Singleton
class FirebaseAuthService @Inject constructor(
private val firebaseAuth: FirebaseAuth
) : AuthService {
override suspend fun getCurrentSession(): User? = firebaseAuth.currentUser?.toUser()
override suspend fun isAuthenticated(): Boolean = firebaseAuth.currentUser != null
override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching {
require(token.isNotBlank()) { "ID token is required for Google sign-in." }
when (provider) {
AuthProvider.GOOGLE -> signInWithGoogle(token)
}
}
override suspend fun signOut(): Result<Unit> = runCatching {
firebaseAuth.signOut()
}
private suspend fun signInWithGoogle(token: String): User {
val credential = GoogleAuthProvider.getCredential(token, null)
val authResult = firebaseAuth.signInWithCredential(credential).awaitResult()
val firebaseUser = authResult.user ?: error("Google sign-in completed without a Firebase user.")
return firebaseUser.toUser()
}
private fun FirebaseUser.toUser(): User = User(
userId = uid,
email = email,
displayName = displayName,
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

@@ -0,0 +1,5 @@
package org.db3.airmq.sdk.auth.model
enum class AuthProvider {
GOOGLE
}

View File

@@ -1,6 +1,6 @@
package org.db3.airmq.sdk.auth.model package org.db3.airmq.sdk.auth.model
data class Auth( data class User(
val userId: String, val userId: String,
val email: String?, val email: String?,
val displayName: String?, val displayName: String?,

View File

@@ -1,12 +1,15 @@
package org.db3.airmq.sdk.di package org.db3.airmq.sdk.di
import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.ApolloClient
import com.google.firebase.auth.FirebaseAuth
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import org.db3.airmq.sdk.auth.AuthService
import org.db3.airmq.sdk.auth.FirebaseAuthService
import org.db3.airmq.sdk.map.MapServiceImpl import org.db3.airmq.sdk.map.MapServiceImpl
import org.db3.airmq.sdk.map.MapService import org.db3.airmq.sdk.map.MapService
import org.db3.airmq.sdk.settings.SettingsService import org.db3.airmq.sdk.settings.SettingsService
@@ -26,11 +29,19 @@ object SDKModule {
.addInterceptor(ApolloLoggingInterceptor()) .addInterceptor(ApolloLoggingInterceptor())
.build() .build()
} }
@Provides
@Singleton
fun provideFirebaseAuth(): FirebaseAuth = FirebaseAuth.getInstance()
} }
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class SDKBindModule { abstract class SDKBindModule {
@Binds
@Singleton
abstract fun bindAuthService(impl: FirebaseAuthService): AuthService
@Binds @Binds
@Singleton @Singleton
abstract fun bindMapService(impl: MapServiceImpl): MapService abstract fun bindMapService(impl: MapServiceImpl): MapService