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:
@@ -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
84
app/google-services.json
Normal 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"
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user