Loading cardinal-android/app/build.gradle.kts +4 −0 Original line number Diff line number Diff line Loading @@ -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" Loading Loading @@ -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 Loading cardinal-android/app/src/main/AndroidManifest.xml +1 −1 Original line number Diff line number Diff line Loading @@ -69,8 +69,8 @@ <data android:scheme="geo" /> </intent-filter> </activity> </application> </manifest> cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt +72 −12 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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<Int?>(null) Loading Loading @@ -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 Loading Loading @@ -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() Loading @@ -225,7 +235,8 @@ class MainActivity : ComponentActivity() { routeRepository = routeRepository, appPreferenceRepository = appPreferenceRepository, onRequestNotificationPermission = { requestNotificationPermission() }, hasNotificationPermission = hasNotificationPermission hasNotificationPermission = hasNotificationPermission, onStartActivityForResult = authActivityResultLauncher::launch ) } } Loading Loading @@ -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() Loading cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthManager.kt 0 → 100644 +63 −0 Original line number Diff line number Diff line 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<AuthState> = 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 } cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthRepository.kt 0 → 100644 +220 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. */ 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>(AuthState.Unauthenticated) val authState: Flow<AuthState> = _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<TokenResponse>) -> 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) } } Loading
cardinal-android/app/build.gradle.kts +4 −0 Original line number Diff line number Diff line Loading @@ -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" Loading Loading @@ -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 Loading
cardinal-android/app/src/main/AndroidManifest.xml +1 −1 Original line number Diff line number Diff line Loading @@ -69,8 +69,8 @@ <data android:scheme="geo" /> </intent-filter> </activity> </application> </manifest>
cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt +72 −12 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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<Int?>(null) Loading Loading @@ -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 Loading Loading @@ -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() Loading @@ -225,7 +235,8 @@ class MainActivity : ComponentActivity() { routeRepository = routeRepository, appPreferenceRepository = appPreferenceRepository, onRequestNotificationPermission = { requestNotificationPermission() }, hasNotificationPermission = hasNotificationPermission hasNotificationPermission = hasNotificationPermission, onStartActivityForResult = authActivityResultLauncher::launch ) } } Loading Loading @@ -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() Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthManager.kt 0 → 100644 +63 −0 Original line number Diff line number Diff line 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<AuthState> = 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 }
cardinal-android/app/src/main/java/earth/maps/cardinal/auth/AuthRepository.kt 0 → 100644 +220 −0 Original line number Diff line number Diff line /* * 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 <https://www.gnu.org/licenses/>. */ 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>(AuthState.Unauthenticated) val authState: Flow<AuthState> = _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<TokenResponse>) -> 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) } }