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.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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.db3.airmq.sdk.auth.model
|
||||||
|
|
||||||
|
enum class AuthProvider {
|
||||||
|
GOOGLE
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user