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

View File

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

View File

@@ -1,6 +1,18 @@
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 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.layout.Arrangement
import androidx.compose.foundation.BorderStroke
@@ -53,6 +65,10 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
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 org.db3.airmq.R
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 LegacyLoginGradientEnd = Color(0xFF5CE4BB)
private val LegacyFacebookBlue = Color(0xFF3B5998)
private const val LOGIN_TAG = "LoginScreen"
@Composable
fun LoginScreen(
@@ -77,6 +94,15 @@ fun LoginScreen(
viewModel.actions.collectLatest { action ->
when (action) {
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.OpenPrivacyPolicy -> {
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)
@Composable
private fun LoginScreenPreview() {

View File

@@ -8,6 +8,7 @@ object LoginScreenContract {
sealed interface Action {
data object OpenManage : Action
data object LaunchGoogleSignIn : Action
data object ShowContinueAnonymousDialog : Action
data object OpenPrivacyPolicy : Action
data object OpenTermsAndConditions : Action
@@ -16,6 +17,8 @@ object LoginScreenContract {
sealed interface 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 ContinueAnonymousClicked : Event
data object ContinueAnonymousConfirmed : Event

View File

@@ -2,9 +2,11 @@ package org.db3.airmq.features.login
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
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.Event
import org.db3.airmq.features.login.LoginScreenContract.State
import org.db3.airmq.sdk.auth.AuthService
import org.db3.airmq.sdk.auth.model.AuthProvider
@HiltViewModel
class LoginViewModel @Inject constructor(
@ApplicationContext private val appContext: Context
@ApplicationContext private val appContext: Context,
private val authService: AuthService
) : ViewModel() {
private val _uiState = MutableStateFlow(State())
@@ -30,7 +35,19 @@ class LoginViewModel @Inject constructor(
fun onEvent(event: Event) {
when (event) {
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 -> {
_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 androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
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.State
import org.db3.airmq.features.manage.ManageScreenContract.UserMode
import org.db3.airmq.sdk.auth.AuthService
import org.db3.airmq.sdk.auth.model.User
@HiltViewModel
class ManageViewModel @Inject constructor(
@ApplicationContext private val appContext: Context
@ApplicationContext private val appContext: Context,
private val authService: AuthService
) : ViewModel() {
// Temporary migration stub: keep screen in anonymous mode.
private val forceAnonymous = true
private val _uiState = MutableStateFlow(initialState())
val uiState: StateFlow<State> = _uiState.asStateFlow()
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
val actions: SharedFlow<Action> = _actions.asSharedFlow()
init {
refreshAuthState()
}
fun onEvent(event: Event) {
when (event) {
Event.SettingsClicked -> _actions.tryEmit(Action.OpenSettings)
@@ -42,23 +48,30 @@ class ManageViewModel @Inject constructor(
}
}
private fun initialState(): State {
val mode = if (forceAnonymous) {
UserMode.ANONYMOUS
private fun initialState(): State = anonymousState()
private fun refreshAuthState() {
viewModelScope.launch {
val session = authService.getCurrentSession()
_uiState.value = if (session?.isAuthenticated == true) {
authorizedState(session)
} else {
UserMode.AUTHORIZED
anonymousState()
}
return when (mode) {
UserMode.ANONYMOUS -> State(
}
}
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)
)
UserMode.AUTHORIZED -> State(
private fun authorizedState(user: User): State = State(
userMode = UserMode.AUTHORIZED,
userName = appContext.getString(R.string.mock_user_name),
userEmail = appContext.getString(R.string.mock_user_email),
userName = user.displayName ?: appContext.getString(R.string.text_anonymous_user),
userEmail = user.email ?: "",
devicesLabel = appContext.getString(R.string.text_your_devices),
devices = listOf(
DeviceItem(
@@ -76,5 +89,3 @@ class ManageViewModel @Inject constructor(
)
)
}
}
}

View File

@@ -142,7 +142,6 @@
<string name="toast_copied">Copied</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="fb_login_protocol_scheme" translatable="false">fb356744979027664</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.hilt.android) 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"
navigationCompose = "2.9.3"
firebaseBom = "34.4.0"
firebaseCrashlyticsPlugin = "3.0.6"
mapsCompose = "6.12.1"
osmdroid = "6.1.20"
apollo = "5.0.0-alpha.4"
@@ -19,6 +20,8 @@ hiltNavigationCompose = "1.2.0"
okhttpLogging = "4.12.0"
ksp = "2.0.21-1.0.27"
google-services = "4.4.4"
androidxCredentials = "1.5.0"
googleid = "1.1.1"
[libraries]
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-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
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" }
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-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-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]
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" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
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.ksp)
alias(libs.plugins.apollo)
alias(libs.plugins.google.services)
}
android {
@@ -50,6 +49,7 @@ dependencies {
// Firebase
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.auth)
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.messaging)

View File

@@ -1,10 +1,11 @@
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 {
suspend fun getCurrentSession(): Auth?
suspend fun getCurrentSession(): User?
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>
}

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
data class Auth(
data class User(
val userId: String,
val email: String?,
val displayName: String?,

View File

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