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

84
app/google-services.json Normal file
View File

@@ -0,0 +1,84 @@
{
"project_info": {
"project_number": "223884730019",
"project_id": "db3-airmq-debug",
"storage_bucket": "db3-airmq-debug.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:223884730019:android:ebe25591cd4c41c2562916",
"android_client_info": {
"package_name": "org.db3.airmq"
}
},
"oauth_client": [
{
"client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCcgdvdp8xec2dKUqlQHl9peQAyOWRVga4"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:223884730019:android:1345a64a13459de7562916",
"android_client_info": {
"package_name": "org.db3.airmq.debug"
}
},
"oauth_client": [
{
"client_id": "223884730019-0cobqn6haoga9n5o60lqcke9uglqihea.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "org.db3.airmq.debug",
"certificate_hash": "38547bbc27af278e820c78fc012227a40f64505f"
}
},
{
"client_id": "223884730019-p44o1v2t3g6v0kmopsa65f0umf60ksif.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "org.db3.airmq.debug",
"certificate_hash": "195356c67cabf18c9ab0a8ccfcec45c3346b6b6c"
}
},
{
"client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyCcgdvdp8xec2dKUqlQHl9peQAyOWRVga4"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "223884730019-0ddlndpmfesrt1vk5vdsqgeudkmoanls.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

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,39 +48,44 @@ class ManageViewModel @Inject constructor(
}
}
private fun initialState(): State {
val mode = if (forceAnonymous) {
UserMode.ANONYMOUS
} else {
UserMode.AUTHORIZED
}
return when (mode) {
UserMode.ANONYMOUS -> 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(
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 initialState(): State = anonymousState()
private fun refreshAuthState() {
viewModelScope.launch {
val session = authService.getCurrentSession()
_uiState.value = if (session?.isAuthenticated == true) {
authorizedState(session)
} else {
anonymousState()
}
}
}
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="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>