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" }