diff --git a/cardinal-android/app/build.gradle.kts b/cardinal-android/app/build.gradle.kts
index cc5824afa818ae66b447b13b4281a5f4517e0221..3907b008b193679234d7718cd5ece86798e63f66 100644
--- a/cardinal-android/app/build.gradle.kts
+++ b/cardinal-android/app/build.gradle.kts
@@ -41,6 +41,9 @@ android {
versionName = System.getenv("VERSION_NAME") ?: "debug"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ // AppAuth redirect scheme placeholder
+ manifestPlaceholders["appAuthRedirectScheme"] = "earth.maps.cardinal"
}
flavorDimensions += "architecture"
@@ -182,6 +185,7 @@ dependencies {
implementation(libs.ferrostar.composeui)
implementation(libs.okhttp3)
implementation(libs.androidaddressformatter)
+ implementation(libs.appauth)
// TODO: Migrate version to TOML (doesn't work). Likely related issue: https://github.com/gradle/gradle/issues/21267
//noinspection UseTomlInstead
diff --git a/cardinal-android/app/src/main/AndroidManifest.xml b/cardinal-android/app/src/main/AndroidManifest.xml
index 238fdd5e925b78a79274ec2f28c5716575a6c147..b27f19018b402b9225e20184acc168adf0810378 100644
--- a/cardinal-android/app/src/main/AndroidManifest.xml
+++ b/cardinal-android/app/src/main/AndroidManifest.xml
@@ -69,8 +69,8 @@
+
-
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt
index 06dc33d0c037ed41175c48604c3c529ebf6d14c5..90130b66181a8847477a8f1225a5465132a8f02c 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt
@@ -41,6 +41,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.google.gson.Gson
import dagger.hilt.android.AndroidEntryPoint
+import earth.maps.cardinal.auth.AuthRepository
import earth.maps.cardinal.data.AppPreferenceRepository
import earth.maps.cardinal.data.LatLng
import earth.maps.cardinal.data.LocationRepository
@@ -58,6 +59,9 @@ import earth.maps.cardinal.ui.theme.AppTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import net.openid.appauth.AuthorizationException
+import net.openid.appauth.AuthorizationResponse
+import net.openid.appauth.AuthorizationService
import java.lang.Double.parseDouble
import javax.inject.Inject
@@ -85,6 +89,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var savedListRepository: SavedListRepository
+ @Inject
+ lateinit var authRepository: AuthRepository
+
private var localMapServerService: LocalMapServerService? = null
private var bound by mutableStateOf(false)
private var port by mutableStateOf(null)
@@ -154,6 +161,19 @@ class MainActivity : ComponentActivity() {
}
}
+ // Activity result launcher for auth flows (login/registration)
+ private val authActivityResultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ val intent = result.data
+ if (result.resultCode == RESULT_OK && intent != null) {
+ // Handle OAuth callback
+ handleOAuthIntent(intent)
+ } else {
+ Log.d(TAG, "Auth activity canceled or failed")
+ }
+ }
+
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
// We've bound to LocalMapServerService, cast the IBinder and get LocalMapServerService instance
@@ -190,17 +210,7 @@ class MainActivity : ComponentActivity() {
savedListRepository.cleanupUnparentedElements()
}
- intent?.takeIf { it.action == Intent.ACTION_VIEW }?.let { intent ->
- val data: Uri? = intent.data
- if (data != null && data.scheme != null && data.scheme.equals("geo")) {
- handleGeoIntent(data)
- }
-
- // Check for deep link destination
- if (deepLinkDestination == null) {
- deepLinkDestination = intent.getStringExtra(EXTRA_DEEP_LINK_DESTINATION)
- }
- }
+ intent?.let { handleIntent(it) }
setContent {
val contrastLevel by appPreferenceRepository.contrastLevel.collectAsState()
@@ -225,7 +235,8 @@ class MainActivity : ComponentActivity() {
routeRepository = routeRepository,
appPreferenceRepository = appPreferenceRepository,
onRequestNotificationPermission = { requestNotificationPermission() },
- hasNotificationPermission = hasNotificationPermission
+ hasNotificationPermission = hasNotificationPermission,
+ onStartActivityForResult = authActivityResultLauncher::launch
)
}
}
@@ -255,6 +266,55 @@ class MainActivity : ComponentActivity() {
return null
}
+ private fun handleIntent(intent: Intent) {
+ val data: Uri? = intent.data
+ if (data != null && intent.action == Intent.ACTION_VIEW) {
+ if (data.scheme == "geo") {
+ handleGeoIntent(data)
+ } else if (data.scheme?.startsWith("earth.maps.cardinal") == true && data.host == "oauth2redirect") {
+ // Handle OAuth callback
+ handleOAuthIntent(intent)
+ }
+
+ // Check for deep link destination
+ if (deepLinkDestination == null) {
+ deepLinkDestination = intent.getStringExtra(EXTRA_DEEP_LINK_DESTINATION)
+ }
+ }
+ }
+
+ private fun handleOAuthIntent(intent: Intent) {
+ Log.d(TAG, "Handling OAuth callback")
+ val authorizationService = AuthorizationService(this)
+
+ val authResponse = AuthorizationResponse.fromIntent(intent)
+ val authException = AuthorizationException.fromIntent(intent)
+
+ if (authResponse != null) {
+ Log.d(TAG, "OAuth authorized: ${authResponse.authorizationCode}")
+ authRepository.exchangeAuthorizationCode(authResponse) { result ->
+ if (result.isSuccess) {
+ Log.d(TAG, "Token exchange successful")
+ // Tokens are stored in authRepository, state should update automatically
+ } else {
+ Log.e(TAG, "Token exchange failed", result.exceptionOrNull())
+ }
+ }
+ } else if (authException != null) {
+ Log.e(TAG, "OAuth error: ${authException.message}")
+ // Handle error, perhaps show a toast or navigate to error screen
+ } else {
+ Log.w(TAG, "No auth response")
+ }
+
+ authorizationService.dispose()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ handleIntent(intent)
+ }
+
override fun onStart() {
super.onStart()
ferrostarWrapperRepository.androidTtsObserver.start()
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthManager.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthManager.kt
new file mode 100644
index 0000000000000000000000000000000000000000..85381cfb8a6a348948e9b2d744c4b89193a92584
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthManager.kt
@@ -0,0 +1,63 @@
+package earth.maps.cardinal.auth
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+class AuthManager(
+ private val authRepository: AuthRepository,
+ private val scope: CoroutineScope = CoroutineScope(Dispatchers.Default + Job())
+) {
+
+ val authState: StateFlow = authRepository.authState.stateIn(
+ scope = scope,
+ started = SharingStarted.Eagerly,
+ initialValue = AuthState.Unauthenticated
+ )
+
+ init {
+ // Start monitoring and auto-refresh
+ monitorTokenExpiry()
+ }
+
+ private fun monitorTokenExpiry() {
+ scope.launch {
+ while (true) {
+ delay(60_000) // Check every minute
+ when (val currentState = authState.value) {
+ is AuthState.Authenticated -> {
+ // TODO: Check if token is expiring soon (e.g., in less than 5 minutes)
+ // For now, naive refresh
+ authRepository.refreshAccessToken()
+ }
+ else -> {
+ // Do nothing if not authenticated
+ }
+ }
+ }
+ }
+ }
+
+ fun startLogin() {
+ // This could trigger the authorization flow externally
+ // Actual launching is done from ViewModel/UI
+ }
+
+ fun startRegistration() {
+ // Similar
+ }
+
+ fun logout() {
+ authRepository.logout()
+ }
+
+ // Additional methods for common operations
+ fun isAuthenticated(): Boolean = authState.value is AuthState.Authenticated
+
+ fun getUserInfo(): UserInfo? = (authState.value as? AuthState.Authenticated)?.userInfo
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthRepository.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f9dcc67ad8b5ca88ad9f35638260f8877c95982f
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthRepository.kt
@@ -0,0 +1,220 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2025 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.auth
+
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.util.Base64
+import android.util.Log
+import androidx.core.content.edit
+import androidx.core.net.toUri
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import net.openid.appauth.AuthorizationRequest
+import net.openid.appauth.AuthorizationResponse
+import net.openid.appauth.AuthorizationService
+import net.openid.appauth.AuthorizationServiceConfiguration
+import net.openid.appauth.TokenRequest
+import net.openid.appauth.TokenResponse
+
+class AuthRepository(
+ context: Context,
+ private val authService: AuthorizationService
+) {
+ companion object {
+ private const val PREFS_NAME = "auth_prefs"
+ private const val KEY_ACCESS_TOKEN = "access_token"
+ private const val KEY_REFRESH_TOKEN = "refresh_token"
+ private const val KEY_TOKEN_EXPIRY = "token_expiry"
+ private const val KEY_ID_TOKEN = "id_token"
+ private const val TAG = "AuthRepository"
+
+ }
+
+ private val prefs: SharedPreferences =
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ private val serviceConfiguration = AuthorizationServiceConfiguration(
+ KeycloakConfig.AUTHORIZATION_ENDPOINT.toUri(),
+ KeycloakConfig.TOKEN_ENDPOINT.toUri(),
+ KeycloakConfig.REGISTRATION_ENDPOINT.toUri(),
+ KeycloakConfig.END_SESSION_ENDPOINT.toUri()
+ )
+
+ private val _authState = MutableStateFlow(AuthState.Unauthenticated)
+ val authState: Flow = _authState
+
+ init {
+ loadInitialState()
+ }
+
+ private fun loadInitialState() {
+ val accessToken = prefs.getString(KEY_ACCESS_TOKEN, null)
+ val tokenExpiry = prefs.getLong(KEY_TOKEN_EXPIRY, 0L)
+ val idToken = prefs.getString(KEY_ID_TOKEN, null)
+
+ if (accessToken != null && System.currentTimeMillis() < tokenExpiry) {
+ // Use ID token to get user info
+ _authState.update { AuthState.Loading }
+ if (idToken != null) {
+ updateAuthStateFromIdToken(idToken)
+ } else {
+ _authState.update { AuthState.Unauthenticated }
+ }
+ } else {
+ _authState.update { AuthState.Unauthenticated }
+ }
+ }
+
+ private fun decodeIdToken(idToken: String): UserInfo {
+ val parts = idToken.split(".")
+ if (parts.size != 3) throw IllegalArgumentException("Invalid JWT")
+
+ val payload =
+ String(Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))
+ val jsonElement = Json.parseToJsonElement(payload)
+ val jsonObject = jsonElement.jsonObject
+
+ return UserInfo(
+ sub = jsonObject["sub"]?.jsonPrimitive?.content ?: "",
+ email = jsonObject["email"]?.jsonPrimitive?.content,
+ name = jsonObject["name"]?.jsonPrimitive?.content,
+ preferredUsername = jsonObject["preferred_username"]?.jsonPrimitive?.content
+ )
+ }
+
+ private fun updateAuthStateFromIdToken(idToken: String) {
+ try {
+ val userInfo = decodeIdToken(idToken)
+ _authState.update { AuthState.Authenticated(userInfo) }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to decode ID token", e)
+ _authState.update { AuthState.Error("Invalid token") }
+ }
+ }
+
+ fun performAuthorizationRequest(isRegistration: Boolean): Intent? {
+ val requestBuilder = AuthorizationRequest.Builder(
+ serviceConfiguration,
+ KeycloakConfig.CLIENT_ID,
+ "code",
+ KeycloakConfig.REDIRECT_URI.toUri()
+ ).setScopes("openid", "profile", "email")
+
+ if (isRegistration) {
+ // Add parameter for registration
+ requestBuilder.setAdditionalParameters(mapOf("kc_action" to "REGISTER"))
+ }
+
+ val request = requestBuilder.build()
+ return try {
+ authService.getAuthorizationRequestIntent(request)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to perform authorization request", e)
+ null
+ }
+ }
+
+ fun exchangeAuthorizationCode(
+ authResponse: AuthorizationResponse, callback: (Result) -> Unit
+ ) {
+ val tokenRequest = authResponse.createTokenExchangeRequest()
+
+ authService.performTokenRequest(tokenRequest) { response, ex ->
+ if (response != null) {
+ // Store tokens
+ prefs.edit {
+ putString(KEY_ACCESS_TOKEN, response.accessToken).putString(
+ KEY_REFRESH_TOKEN,
+ response.refreshToken
+ ).putLong(
+ KEY_TOKEN_EXPIRY,
+ response.accessTokenExpirationTime ?: 0
+ ).putString(KEY_ID_TOKEN, response.idToken)
+ }
+
+ // Token stored, now decode ID token to update state
+ val idToken = response.idToken
+ if (idToken != null) {
+ updateAuthStateFromIdToken(idToken)
+ } else {
+ _authState.update { AuthState.Error("No ID token in response") }
+ }
+ callback(Result.success(response))
+ } else {
+ _authState.update { AuthState.Error(ex?.message ?: "Token exchange failed") }
+ callback(Result.failure(ex ?: Exception("Unknown error")))
+ }
+ }
+ }
+
+ fun refreshAccessToken() {
+ val refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null)
+ if (refreshToken == null) {
+ _authState.update { AuthState.Unauthenticated }
+ return
+ }
+
+ val tokenRequest = TokenRequest.Builder(serviceConfiguration, KeycloakConfig.CLIENT_ID)
+ .setGrantType("refresh_token").setRefreshToken(refreshToken).build()
+
+ authService.performTokenRequest(tokenRequest) { response, ex ->
+ if (response != null) {
+ // Update stored tokens
+ prefs.edit {
+ putString(KEY_ACCESS_TOKEN, response.accessToken).putString(
+ KEY_REFRESH_TOKEN,
+ response.refreshToken
+ ).putLong(
+ KEY_TOKEN_EXPIRY,
+ response.accessTokenExpirationTime ?: 0
+ ).putString(KEY_ID_TOKEN, response.idToken)
+ }
+
+ // Update state with new user info from ID token if present
+ val newIdToken = response.idToken
+ if (newIdToken != null) {
+ updateAuthStateFromIdToken(newIdToken)
+ } else {
+ // If no new ID token, keep current state or unauth
+ _authState.update { AuthState.Unauthenticated }
+ }
+ } else {
+ logout()
+ }
+ }
+ }
+
+ fun logout() {
+ prefs.edit {
+ remove(KEY_ACCESS_TOKEN).remove(KEY_REFRESH_TOKEN).remove(KEY_TOKEN_EXPIRY)
+ .remove(KEY_ID_TOKEN)
+ }
+ _authState.update { AuthState.Unauthenticated }
+ }
+
+ fun getAccessToken(): String? {
+ return prefs.getString(KEY_ACCESS_TOKEN, null)
+ }
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthState.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthState.kt
new file mode 100644
index 0000000000000000000000000000000000000000..22a1d9d1abe770392c24dec5ac7d2c424b7de0a2
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthState.kt
@@ -0,0 +1,26 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2025 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.auth
+
+sealed class AuthState {
+ data object Unauthenticated : AuthState()
+ data class Authenticated(val userInfo: UserInfo) : AuthState()
+ data object Loading : AuthState()
+ data class Error(val message: String) : AuthState()
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/KeycloakConfig.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/KeycloakConfig.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ed482da5f17b3757a9c1f766d63688e5c3aae262
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/KeycloakConfig.kt
@@ -0,0 +1,30 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2025 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.auth
+
+object KeycloakConfig {
+ const val ISSUER_URL = "https://auth.cardinalmaps.app/realms/cardinal"
+ const val AUTHORIZATION_ENDPOINT = "$ISSUER_URL/protocol/openid-connect/auth"
+ const val TOKEN_ENDPOINT = "$ISSUER_URL/protocol/openid-connect/token"
+ const val REGISTRATION_ENDPOINT = "$ISSUER_URL/clients-registrations/openid-connect"
+ const val USER_INFO_ENDPOINT = "$ISSUER_URL/protocol/openid-connect/userinfo"
+ const val END_SESSION_ENDPOINT = "$ISSUER_URL/protocol/openid-connect/logout"
+ const val CLIENT_ID = "cardinal-maps-android"
+ const val REDIRECT_URI = "earth.maps.cardinal://oauth2redirect"
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/UserInfo.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/UserInfo.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6d6306231e71609e07779e41c3ed5cd4f3060d70
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/auth/UserInfo.kt
@@ -0,0 +1,34 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2025 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.auth
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class UserInfo(
+ @SerialName("sub")
+ val sub: String, // Subject identifier
+ @SerialName("email")
+ val email: String? = null,
+ @SerialName("name")
+ val name: String? = null,
+ @SerialName("preferred_username")
+ val preferredUsername: String? = null
+)
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt
index c2ca12d89f4e857eecc15e4b25948b838582d4b1..da8dcd224d24be1d2cf07e8f6ae5d027485eeca6 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt
@@ -344,7 +344,7 @@ class AppPreferences(private val context: Context) {
return prefs.getString(KEY_PELIAS_API_KEY, null)
}
- // Valhalla API configuration methods
+ // Valhalla API configuration methods
/**
* Saves the Valhalla base URL.
@@ -384,4 +384,77 @@ class AppPreferences(private val context: Context) {
fun loadValhallaApiKey(): String? {
return prefs.getString(KEY_VALHALLA_API_KEY, null)
}
+
+ // User profile preferences (non-sensitive info)
+
+ /**
+ * Saves the user profile email.
+ */
+ fun saveUserEmail(email: String?) {
+ prefs.edit {
+ if (email != null) {
+ putString("user_email", email)
+ } else {
+ remove("user_email")
+ }
+ }
+ }
+
+ /**
+ * Loads the saved user profile email.
+ */
+ fun loadUserEmail(): String? {
+ return prefs.getString("user_email", null)
+ }
+
+ /**
+ * Saves the user profile name.
+ */
+ fun saveUserName(name: String?) {
+ prefs.edit {
+ if (name != null) {
+ putString("user_name", name)
+ } else {
+ remove("user_name")
+ }
+ }
+ }
+
+ /**
+ * Loads the saved user profile name.
+ */
+ fun loadUserName(): String? {
+ return prefs.getString("user_name", null)
+ }
+
+ /**
+ * Saves the user subject identifier.
+ */
+ fun saveUserSub(sub: String?) {
+ prefs.edit {
+ if (sub != null) {
+ putString("user_sub", sub)
+ } else {
+ remove("user_sub")
+ }
+ }
+ }
+
+ /**
+ * Loads the saved user subject identifier.
+ */
+ fun loadUserSub(): String? {
+ return prefs.getString("user_sub", null)
+ }
+
+ /**
+ * Clears all user profile preferences (for logout).
+ */
+ fun clearUserProfile() {
+ prefs.edit {
+ remove("user_email")
+ remove("user_name")
+ remove("user_sub")
+ }
+ }
}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/di/AuthModule.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/AuthModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..dabd78459337cddf2c047450c59ff2965ed41b9e
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/AuthModule.kt
@@ -0,0 +1,64 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2025 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.di
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import earth.maps.cardinal.auth.AuthManager
+import earth.maps.cardinal.auth.AuthRepository
+import net.openid.appauth.AuthorizationService
+import okhttp3.OkHttpClient
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object AuthModule {
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(): OkHttpClient {
+ return OkHttpClient.Builder().build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideAuthorizationService(@ApplicationContext context: Context): AuthorizationService {
+ return AuthorizationService(context)
+ }
+
+ @Provides
+ @Singleton
+ fun provideAuthRepository(
+ @ApplicationContext context: Context,
+ authorizationService: AuthorizationService,
+ okHttpClient: OkHttpClient
+ ): AuthRepository {
+ return AuthRepository(context, authorizationService)
+ }
+
+ @Provides
+ @Singleton
+ fun provideAuthManager(authRepository: AuthRepository): AuthManager {
+ return AuthManager(authRepository)
+ }
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt
index 70fa5f42aedff092db4a60d193da81b12dbafc08..de96da0a5d9240f8451d8656108a22ac0f33d5f1 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/AppContent.kt
@@ -19,6 +19,7 @@
package earth.maps.cardinal.ui.core
import android.annotation.SuppressLint
+import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
@@ -121,6 +122,7 @@ import earth.maps.cardinal.ui.place.PlaceCardScreen
import earth.maps.cardinal.ui.place.PlaceCardViewModel
import earth.maps.cardinal.ui.saved.ManagePlacesScreen
import earth.maps.cardinal.ui.settings.AccessibilitySettingsScreen
+import earth.maps.cardinal.ui.settings.AccountSettingsScreen
import earth.maps.cardinal.ui.settings.AdvancedSettingsScreen
import earth.maps.cardinal.ui.settings.PrivacySettingsScreen
import earth.maps.cardinal.ui.settings.ProfileEditorScreen
@@ -148,6 +150,7 @@ fun AppContent(
hasNotificationPermission: Boolean,
routeRepository: RouteRepository,
appPreferenceRepository: AppPreferenceRepository,
+ onStartActivityForResult: (android.content.Intent) -> Unit,
state: AppContentState = rememberAppContentState(),
) {
@@ -243,7 +246,14 @@ fun AppContent(
Screen.HOME_SEARCH,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- HomeRoute(state, homeViewModel, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
+ HomeRoute(
+ state,
+ homeViewModel,
+ navController,
+ topOfBackStack,
+ appPreferenceRepository,
+ backStackEntry
+ )
}
composable(
@@ -257,21 +267,27 @@ fun AppContent(
Screen.NEARBY_TRANSIT,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- NearbyTransitRoute(state, transitViewModel, navController, topOfBackStack, backStackEntry)
+ NearbyTransitRoute(
+ state, transitViewModel, navController, topOfBackStack, backStackEntry
+ )
}
composable(
Screen.PLACE_CARD,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- PlaceCardRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
+ PlaceCardRoute(
+ state, navController, topOfBackStack, appPreferenceRepository, backStackEntry
+ )
}
composable(
Screen.OFFLINE_AREAS,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- OfflineAreasRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
+ OfflineAreasRoute(
+ state, navController, topOfBackStack, appPreferenceRepository, backStackEntry
+ )
}
composable(
@@ -281,7 +297,7 @@ fun AppContent(
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) {
- SettingsRoute(state, navController)
+ SettingsRoute(state, navController, onStartActivityForResult)
}
composable(
@@ -311,7 +327,17 @@ fun AppContent(
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
) {
- AdvancedSettingsRoute(state, navController)
+ AdvancedSettingsRoute(state)
+ }
+
+ composable(
+ Screen.ACCOUNT,
+ enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
+ exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
+ popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
+ popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) },
+ ) {
+ AccountSettingsRoute(state, navController)
}
composable(
@@ -348,14 +374,27 @@ fun AppContent(
Screen.DIRECTIONS,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- DirectionsRoute(state, mapViewModel, navController, topOfBackStack, appPreferenceRepository, hasLocationPermission, onRequestLocationPermission, hasNotificationPermission, onRequestNotificationPermission, backStackEntry)
+ DirectionsRoute(
+ state,
+ mapViewModel,
+ navController,
+ topOfBackStack,
+ appPreferenceRepository,
+ hasLocationPermission,
+ onRequestLocationPermission,
+ hasNotificationPermission,
+ onRequestNotificationPermission,
+ backStackEntry
+ )
}
composable(
Screen.TRANSIT_ITINERARY_DETAIL,
enterTransition = { slideInVertically(initialOffsetY = { it }) },
exitTransition = { fadeOut(animationSpec = tween(600)) }) { backStackEntry ->
- TransitItineraryDetailRoute(state, navController, topOfBackStack, appPreferenceRepository, backStackEntry)
+ TransitItineraryDetailRoute(
+ state, navController, topOfBackStack, appPreferenceRepository, backStackEntry
+ )
}
composable(Screen.TURN_BY_TURN) { backStackEntry ->
@@ -424,8 +463,7 @@ private fun NearbyPoiRoute(
val bottomSheetState = rememberBottomSheetState(
initialValue = BottomSheetValue.Collapsed
)
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+ val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
CardinalAppScaffold(
scaffoldState = scaffoldState, peekHeight = state.screenHeightDp / 3,
@@ -456,8 +494,7 @@ private fun NearbyTransitRoute(
val bottomSheetState = rememberBottomSheetState(
initialValue = BottomSheetValue.Collapsed
)
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+ val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
CardinalAppScaffold(
scaffoldState = scaffoldState, peekHeight = state.screenHeightDp / 3,
@@ -476,10 +513,18 @@ private fun NearbyTransitRoute(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun SettingsRoute(state: AppContentState, navController: NavHostController) {
+private fun SettingsRoute(
+ state: AppContentState,
+ navController: NavHostController,
+ onStartActivityForResult: (Intent) -> Unit
+) {
state.showToolbar = true
val viewModel = hiltViewModel()
- SettingsScreen(navController = navController, viewModel = viewModel)
+ SettingsScreen(
+ navController = navController,
+ viewModel = viewModel,
+ onStartActivityForResult = onStartActivityForResult
+ )
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -508,12 +553,19 @@ private fun AccessibilitySettingsRoute(state: AppContentState, navController: Na
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun AdvancedSettingsRoute(state: AppContentState, navController: NavHostController) {
+private fun AdvancedSettingsRoute(state: AppContentState) {
state.showToolbar = true
val viewModel: SettingsViewModel = hiltViewModel()
AdvancedSettingsScreen(viewModel = viewModel)
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AccountSettingsRoute(state: AppContentState, navController: NavHostController) {
+ state.showToolbar = true
+ AccountSettingsScreen(navController = navController)
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoutingProfilesRoute(state: AppContentState, navController: NavHostController) {
@@ -523,7 +575,9 @@ private fun RoutingProfilesRoute(state: AppContentState, navController: NavHostC
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun ProfileEditorRoute(state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry) {
+private fun ProfileEditorRoute(
+ state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry
+) {
LaunchedEffect(key1 = Unit) {
state.mapPins.clear()
}
@@ -547,7 +601,9 @@ private fun ProfileEditorRoute(state: AppContentState, navController: NavHostCon
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-private fun ManagePlacesRoute(state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry) {
+private fun ManagePlacesRoute(
+ state: AppContentState, navController: NavHostController, backStackEntry: NavBackStackEntry
+) {
state.showToolbar = true
val listIdRaw = backStackEntry.arguments?.getString("listId")
@@ -611,8 +667,7 @@ private fun PlaceCardRoute(
state.cameraState.animateTo(
CameraPosition(
target = Position(
- latitude = place.latLng.latitude,
- longitude = place.latLng.longitude
+ latitude = place.latLng.latitude, longitude = place.latLng.longitude
), zoom = 15.0, padding = PaddingValues(
start = state.screenWidthDp / 8,
top = state.screenHeightDp / 8,
@@ -667,8 +722,7 @@ private fun OfflineAreasRoute(
val bottomSheetState = rememberBottomSheetState(
initialValue = BottomSheetValue.Collapsed
)
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+ val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
LaunchedEffect(key1 = Unit) {
state.mapPins.clear()
@@ -717,8 +771,7 @@ private fun OfflineAreasRoute(
state.cameraState.animateTo(
boundingBox = BoundingBox(
area.west, area.south, area.east, area.north
- ),
- padding = PaddingValues(
+ ), padding = PaddingValues(
start = state.screenWidthDp / 8,
top = state.screenHeightDp / 8,
end = state.screenWidthDp / 8,
@@ -726,8 +779,7 @@ private fun OfflineAreasRoute(
3f * state.screenHeightDp / 4,
state.peekHeight + state.screenHeightDp / 8
)
- ),
- duration = appPreferenceRepository.animationSpeedDurationValue
+ ), duration = appPreferenceRepository.animationSpeedDurationValue
)
}
state.selectedOfflineArea = area
@@ -752,10 +804,8 @@ private fun DirectionsRoute(
backStackEntry: NavBackStackEntry
) {
state.showToolbar = false
- val bottomSheetState =
- rememberBottomSheetState(initialValue = BottomSheetValue.Collapsed)
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+ val bottomSheetState = rememberBottomSheetState(initialValue = BottomSheetValue.Collapsed)
+ val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
val viewModel: DirectionsViewModel = hiltViewModel()
mapViewModel.locationFlow.collectAsState().value
@@ -763,11 +813,9 @@ private fun DirectionsRoute(
// Handle initial place setup
LaunchedEffect(key1 = Unit) {
val fromPlaceJson = backStackEntry.arguments?.getString("fromPlace")
- val fromPlace =
- fromPlaceJson?.let { Gson().fromJson(Uri.decode(it), Place::class.java) }
+ val fromPlace = fromPlaceJson?.let { Gson().fromJson(Uri.decode(it), Place::class.java) }
val toPlaceJson = backStackEntry.arguments?.getString("toPlace")
- val toPlace =
- toPlaceJson?.let { Gson().fromJson(Uri.decode(it), Place::class.java) }
+ val toPlace = toPlaceJson?.let { Gson().fromJson(Uri.decode(it), Place::class.java) }
if (fromPlace != null) {
viewModel.updateFromPlace(fromPlace)
@@ -780,8 +828,7 @@ private fun DirectionsRoute(
top = state.screenHeightDp / 8,
end = state.screenWidthDp / 8,
bottom = min(
- 3f * state.screenHeightDp / 4,
- state.peekHeight + state.screenHeightDp / 8
+ 3f * state.screenHeightDp / 4, state.peekHeight + state.screenHeightDp / 8
)
)
@@ -845,8 +892,7 @@ private fun TransitItineraryDetailRoute(
val bottomSheetState = rememberBottomSheetState(
initialValue = BottomSheetValue.Collapsed
)
- val scaffoldState =
- rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
+ val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
LaunchedEffect(key1 = Unit) {
state.coroutineScope.launch {
@@ -870,10 +916,9 @@ private fun TransitItineraryDetailRoute(
itinerary.legs.forEach { leg ->
leg.legGeometry?.let { geometry ->
try {
- val positions =
- earth.maps.cardinal.data.PolylineUtils.decodePolyline(
- geometry.points, geometry.precision
- )
+ val positions = PolylineUtils.decodePolyline(
+ geometry.points, geometry.precision
+ )
allPositions.addAll(positions)
} catch (e: Exception) {
// Ignore decoding errors for individual legs
@@ -883,29 +928,26 @@ private fun TransitItineraryDetailRoute(
// If we have route geometry, fit camera to the route
if (allPositions.isNotEmpty()) {
- earth.maps.cardinal.data.PolylineUtils.calculateBoundingBox(allPositions)
- ?.let { boundingBox ->
- state.coroutineScope.launch {
- state.cameraState.animateTo(
- boundingBox = BoundingBox(
- west = boundingBox.west,
- south = boundingBox.south,
- east = boundingBox.east,
- north = boundingBox.north
- ),
- padding = PaddingValues(
- start = state.screenWidthDp / 8,
- top = state.screenHeightDp / 8,
- end = state.screenWidthDp / 8,
- bottom = min(
- 3f * state.screenHeightDp / 4,
- state.peekHeight + state.screenHeightDp / 8
- )
- ),
- duration = appPreferenceRepository.animationSpeedDurationValue
- )
- }
+ PolylineUtils.calculateBoundingBox(allPositions)?.let { boundingBox ->
+ state.coroutineScope.launch {
+ state.cameraState.animateTo(
+ boundingBox = BoundingBox(
+ west = boundingBox.west,
+ south = boundingBox.south,
+ east = boundingBox.east,
+ north = boundingBox.north
+ ), padding = PaddingValues(
+ start = state.screenWidthDp / 8,
+ top = state.screenHeightDp / 8,
+ end = state.screenWidthDp / 8,
+ bottom = min(
+ 3f * state.screenHeightDp / 4,
+ state.peekHeight + state.screenHeightDp / 8
+ )
+ ), duration = appPreferenceRepository.animationSpeedDurationValue
+ )
}
+ }
}
// Clear any existing pins
@@ -1094,60 +1136,52 @@ private fun HomeScreenComposable(
}
},
content = {
- HomeScreen(
- viewModel = viewModel,
- onPlaceSelected = { place ->
- imeController?.hide()
-
- // We are intentionally not collapsing search here, but we do set the bottom
- // sheet state to collapsed to prevent jank on popping back to this screen.
- coroutineScope.launch {
- // This should happen before we navigate away otherwise we get a race condition
- // between setting the anchors and collapsing the sheet. Unfortunately, it
- // doesn't return until the sheet is fully collapsed, so we queue the navigation
- // after this and hope for the best.
- bottomSheetState.collapse()
- }
- coroutineScope.launch {
- NavigationUtils.navigate(navController, Screen.PlaceCard(place))
- }
- },
- onPeekHeightChange = {
- if (topOfBackStack == backStackEntry) {
- onPeekHeightChange(it)
- }
- },
- onSearchFocusChange = {
- if (it) {
- viewModel.expandSearch()
- }
- },
- onResultPinsChange = {
- mapPins.clear()
- mapPins.addAll(it)
- },
- onSearchEvent = {
- viewModel.collapseSearch()
- coroutineScope.launch {
- val boundingBox =
- PolylineUtils.calculateBoundingBox(mapPins.map { it.toPosition() })
- ?: return@launch
- cameraState.animateTo(
- boundingBox = boundingBox.toGeoJsonBoundingBox(),
- padding = PaddingValues(
- start = screenWidthDp / 8,
- top = screenHeightDp / 8,
- end = screenWidthDp / 8,
- bottom = min(
- 3f * screenHeightDp / 4,
- peekHeight + screenHeightDp / 8
- )
- ),
- duration = appPreferenceRepository.animationSpeedDurationValue
- )
- }
+ HomeScreen(viewModel = viewModel, onPlaceSelected = { place ->
+ imeController?.hide()
+
+ // We are intentionally not collapsing search here, but we do set the bottom
+ // sheet state to collapsed to prevent jank on popping back to this screen.
+ coroutineScope.launch {
+ // This should happen before we navigate away otherwise we get a race condition
+ // between setting the anchors and collapsing the sheet. Unfortunately, it
+ // doesn't return until the sheet is fully collapsed, so we queue the navigation
+ // after this and hope for the best.
+ bottomSheetState.collapse()
}
- )
+ coroutineScope.launch {
+ NavigationUtils.navigate(navController, Screen.PlaceCard(place))
+ }
+ }, onPeekHeightChange = {
+ if (topOfBackStack == backStackEntry) {
+ onPeekHeightChange(it)
+ }
+ }, onSearchFocusChange = {
+ if (it) {
+ viewModel.expandSearch()
+ }
+ }, onResultPinsChange = {
+ mapPins.clear()
+ mapPins.addAll(it)
+ }, onSearchEvent = {
+ viewModel.collapseSearch()
+ coroutineScope.launch {
+ val boundingBox =
+ PolylineUtils.calculateBoundingBox(mapPins.map { it.toPosition() })
+ ?: return@launch
+ cameraState.animateTo(
+ boundingBox = boundingBox.toGeoJsonBoundingBox(),
+ padding = PaddingValues(
+ start = screenWidthDp / 8,
+ top = screenHeightDp / 8,
+ end = screenWidthDp / 8,
+ bottom = min(
+ 3f * screenHeightDp / 4, peekHeight + screenHeightDp / 8
+ )
+ ),
+ duration = appPreferenceRepository.animationSpeedDurationValue
+ )
+ }
+ })
})
}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/Screen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/Screen.kt
index ba2f98f721f36d9610ca6fb12c94821a04e8798b..479c448eb026e8c498160c9c5a466e7314e0928a 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/Screen.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/core/Screen.kt
@@ -39,6 +39,7 @@ sealed class Screen(val route: String) {
const val OFFLINE_SETTINGS = "offline_settings"
const val ACCESSIBILITY_SETTINGS = "accessibility_settings"
const val ADVANCED_SETTINGS = "advanced_settings"
+ const val ACCOUNT = "account"
const val ROUTING_PROFILES = "routing_profile_settings"
const val PROFILE_EDITOR = "edit_routing_profile?profileId={profileId}"
const val DIRECTIONS = "directions?fromPlace={fromPlace}&toPlace={toPlace}"
@@ -67,6 +68,8 @@ sealed class Screen(val route: String) {
object AdvancedSettings : Screen(ADVANCED_SETTINGS)
+ object Account : Screen(ACCOUNT)
+
object RoutingProfiles : Screen(ROUTING_PROFILES)
data class ProfileEditor(val profileId: String?) :
@@ -112,6 +115,7 @@ object NavigationUtils {
is Screen.OfflineSettings -> screen.route
is Screen.AccessibilitySettings -> screen.route
is Screen.AdvancedSettings -> screen.route
+ is Screen.Account -> screen.route
is Screen.RoutingProfiles -> screen.route
is Screen.ProfileEditor -> {
val profileId = screen.profileId
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccountSection.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccountSection.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ff32ac1a16fe53921513ae316cf15ca3cf150d7d
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccountSection.kt
@@ -0,0 +1,178 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2025 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.ui.settings
+
+import android.content.Intent
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.DividerDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import earth.maps.cardinal.R.dimen
+import earth.maps.cardinal.R.string
+import earth.maps.cardinal.ui.core.NavigationUtils
+import earth.maps.cardinal.ui.core.Screen
+import kotlinx.coroutines.launch
+
+@Composable
+fun AccountSection(
+ navController: NavController,
+ onStartActivityForResult: (Intent) -> Unit,
+ authViewModel: AuthViewModel = hiltViewModel()
+) {
+ val coroutineScope = rememberCoroutineScope()
+
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 8.dp),
+ thickness = DividerDefaults.Thickness,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+
+ // Account section
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ NavigationUtils.navigate(
+ navController,
+ Screen.Account
+ )
+ }
+ .padding(
+ horizontal = dimensionResource(dimen.padding),
+ vertical = dimensionResource(dimen.padding_minor)
+ )
+ ) {
+ val isAuthenticated by authViewModel.isAuthenticated.collectAsState()
+ val currentUser by authViewModel.currentUser.collectAsState()
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(string.account),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ if (isAuthenticated && currentUser != null) {
+ val displayName =
+ currentUser?.name ?: currentUser?.preferredUsername ?: "Authenticated User"
+ Text(
+ text = displayName,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ currentUser?.email?.let { email ->
+ Text(
+ text = email,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = {
+ authViewModel.logout()
+ },
+ modifier = Modifier.padding(top = 12.dp)
+ ) {
+ Text(
+ text = stringResource(string.sign_out),
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ OutlinedButton(
+ onClick = {
+ coroutineScope.launch {
+ authViewModel.testConnection()
+ }
+ },
+ modifier = Modifier.padding(top = 12.dp)
+ ) {
+ Text(
+ text = "Test Connection"
+ )
+ }
+ }
+ } else {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Button(
+ onClick = {
+ authViewModel.startLogin()?.let { intent ->
+ onStartActivityForResult(intent)
+ }
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Sign In")
+ }
+ OutlinedButton(
+ onClick = {
+ authViewModel.startRegistration()?.let { intent ->
+ onStartActivityForResult(intent)
+ }
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("Create Account")
+ }
+ }
+ }
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(vertical = 8.dp),
+ thickness = DividerDefaults.Thickness,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccountSettingsScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccountSettingsScreen.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2c51185aae45fb4f8456f5e0697c773785a9f9ad
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AccountSettingsScreen.kt
@@ -0,0 +1,197 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2025 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.ui.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavController
+import earth.maps.cardinal.R.dimen
+import earth.maps.cardinal.R.string
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AccountSettingsScreen(
+ navController: NavController,
+ authViewModel: AuthViewModel = hiltViewModel(),
+) {
+ val authState by authViewModel.authState.collectAsState()
+ val currentUser by authViewModel.currentUser.collectAsState()
+ val snackBarHostState = remember { SnackbarHostState() }
+
+ // Placeholder for sync settings - TODO: implement actual sync logic
+ var syncEnabled by remember { mutableStateOf(false) }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackBarHostState) },
+ contentWindowInsets = WindowInsets.safeDrawing,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(string.account),
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ )
+ },
+ content = { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ ) {
+ if (currentUser != null) {
+ // User profile section
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = dimensionResource(dimen.padding),
+ vertical = dimensionResource(dimen.padding_minor))
+ ) {
+ Text(
+ text = stringResource(string.profile),
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ Text(
+ text = "Email: ${currentUser?.email ?: "N/A"}",
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ currentUser?.name?.let { name ->
+ Text(
+ text = "Name: $name",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+
+ Text(
+ text = "User ID: ${currentUser?.sub ?: "N/A"}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+
+ HorizontalDivider()
+
+ // Sync settings section
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = dimensionResource(dimen.padding),
+ vertical = dimensionResource(dimen.padding_minor))
+ ) {
+ Text(
+ text = "Sync Settings",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ text = "Enable cloud sync for your saved places and preferences",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = if (syncEnabled) "Enabled" else "Disabled",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Switch(
+ checked = syncEnabled,
+ onCheckedChange = { syncEnabled = it }
+ )
+ }
+ }
+
+ HorizontalDivider()
+
+ // Actions section
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = dimensionResource(dimen.padding),
+ vertical = dimensionResource(dimen.padding_minor))
+ ) {
+ // Sign out button with red color
+ OutlinedButton(
+ onClick = {
+ authViewModel.logout()
+ navController.popBackStack()
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(string.sign_out),
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ } else {
+ // Should not happen, but fallback
+ Text(
+ text = "Not authenticated",
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+ )
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AuthViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AuthViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7b6c6ded8d64aaf5b5e4389ee3cfb45b6acece2d
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/AuthViewModel.kt
@@ -0,0 +1,126 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2025 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.ui.settings
+
+import android.content.Intent
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import earth.maps.cardinal.auth.AuthManager
+import earth.maps.cardinal.auth.AuthRepository
+import earth.maps.cardinal.auth.AuthState
+import earth.maps.cardinal.auth.UserInfo
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.android.Android
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.request.bearerAuth
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsText
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.Json
+import javax.inject.Inject
+
+@HiltViewModel
+class AuthViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+ private val authManager: AuthManager
+) : ViewModel() {
+
+ val authState: StateFlow = authManager.authState
+
+ val isAuthenticated: StateFlow = authState.map { it is AuthState.Authenticated }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5000),
+ false
+ )
+
+ val currentUser: StateFlow = authState.map {
+ (it as? AuthState.Authenticated)?.userInfo
+ }.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5000),
+ null
+ )
+
+ fun startLogin(): Intent? {
+ return try {
+ authRepository.performAuthorizationRequest(isRegistration = false)
+ } catch (e: Exception) {
+ Log.e(TAG, "couldn't start login flow", e)
+ // Handle error - could emit to error state if needed
+ null
+ }
+ }
+
+ fun startRegistration(): Intent? {
+ return try {
+ authRepository.performAuthorizationRequest(isRegistration = true)
+ } catch (e: Exception) {
+ Log.e(TAG, "couldn't start register flow", e)
+ // Handle error - could emit to error state if needed
+ null
+ }
+ }
+
+ fun logout() {
+ viewModelScope.launch {
+ authManager.logout()
+ }
+ }
+
+ suspend fun testConnection() {
+ val token = authRepository.getAccessToken()
+ if (token == null) {
+ Log.e(TAG, "No access token available for test connection")
+ return
+ }
+
+ val client = HttpClient(Android) {
+ install(ContentNegotiation) {
+ json(Json {
+ prettyPrint = true
+ isLenient = true
+ })
+ }
+ }
+ try {
+ Log.d(TAG, "Token: $token")
+ val response = client.get("https://api.cardinalmaps.app/test") {
+ bearerAuth(token)
+ }
+ val body = response.bodyAsText()
+ Log.i(TAG, "Test connection response: status ${response.status}, body: $body")
+ } catch (e: Exception) {
+ Log.e(TAG, "Test connection failed", e)
+ } finally {
+ client.close()
+ }
+ }
+
+ companion object {
+ const val TAG: String = "AuthViewModel"
+ }
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsScreen.kt
index 0949cb657b69c125d6cdad768c831f629da4d877..19501821b402836cf779564907e0512dd68ab028 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsScreen.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/settings/SettingsScreen.kt
@@ -18,6 +18,7 @@
package earth.maps.cardinal.ui.settings
+import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -96,6 +97,7 @@ fun PreferenceOption(
fun SettingsScreen(
navController: NavController,
viewModel: SettingsViewModel,
+ onStartActivityForResult: (Intent) -> Unit,
) {
Scaffold(
snackbarHost = { SnackbarHost(remember { SnackbarHostState() }) },
@@ -174,10 +176,9 @@ fun SettingsScreen(
}
}
- HorizontalDivider(
- modifier = Modifier.padding(vertical = 8.dp),
- thickness = DividerDefaults.Thickness,
- color = MaterialTheme.colorScheme.outlineVariant
+ AccountSection(
+ navController = navController,
+ onStartActivityForResult = onStartActivityForResult
)
// Routing Profiles Settings Item
diff --git a/cardinal-android/app/src/main/res/values/strings.xml b/cardinal-android/app/src/main/res/values/strings.xml
index b50911f83813d1b0b9b159e9fad21cc248ba6e4a..d430577add4dac38102ddc7be502bf56f12512a8 100644
--- a/cardinal-android/app/src/main/res/values/strings.xml
+++ b/cardinal-android/app/src/main/res/values/strings.xml
@@ -248,4 +248,7 @@
Zoom in
Zoom out
Searching…
+ Account
+ Sign Out
+ Profile
diff --git a/cardinal-android/gradle/libs.versions.toml b/cardinal-android/gradle/libs.versions.toml
index b79a9009be80f494415a246f2d5e7e1c562cd1af..f1502d54d309b7d8ebae8ff9e79142718e931762 100644
--- a/cardinal-android/gradle/libs.versions.toml
+++ b/cardinal-android/gradle/libs.versions.toml
@@ -1,6 +1,7 @@
[versions]
agp = "8.13.0"
androidaddressformatter = "8f279fe"
+appauth = "0.11.1"
desugar_jdk_libs = "2.1.5"
kotlin = "2.2.10"
ksp = "2.2.10-2.0.2"
@@ -33,6 +34,7 @@ detekt = "2.0.0-alpha.0"
androidaddressformatter = { module = "com.github.woheller69:AndroidAddressFormatter", version.ref = "androidaddressformatter" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
+appauth = { module = "net.openid:appauth", version.ref = "appauth" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }