Loading cardinal-android/app/build.gradle.kts +2 −1 Original line number Diff line number Diff line Loading @@ -67,7 +67,7 @@ android { applicationId = "foundation.e.maps" minSdk = 26 targetSdk = 36 versionCode = System.getenv("VERSION_CODE")?.toIntOrNull() ?: 1 versionCode = System.getenv("VERSION_CODE")?.toIntOrNull() ?: 2 versionName = System.getenv("VERSION_NAME") ?: "debug" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" Loading Loading @@ -239,6 +239,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.room.runtime) implementation(libs.room.ktx) implementation(libs.work.runtime.ktx) ksp(libs.room.compiler) implementation(libs.androidx.navigation.compose) implementation(libs.gson) Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferenceRepository.kt +56 −0 Original line number Diff line number Diff line Loading @@ -79,6 +79,27 @@ class AppPreferenceRepository @Inject constructor( private val _hasPromptedLocation = MutableStateFlow(appPreferences.loadHasPromptedLocation()) val hasPromptedLocation: StateFlow<Boolean> = _hasPromptedLocation.asStateFlow() private val _murenaSyncOnboardingComplete = MutableStateFlow(appPreferences.loadMurenaSyncOnboardingComplete()) val murenaSyncOnboardingComplete: StateFlow<Boolean> = _murenaSyncOnboardingComplete.asStateFlow() private val _murenaSyncEnabled = MutableStateFlow(appPreferences.loadMurenaSyncEnabled()) val murenaSyncEnabled: StateFlow<Boolean> = _murenaSyncEnabled.asStateFlow() private val _murenaAccountName = MutableStateFlow(appPreferences.loadMurenaAccountName()) val murenaAccountName: StateFlow<String?> = _murenaAccountName.asStateFlow() private val _murenaAccountType = MutableStateFlow(appPreferences.loadMurenaAccountType()) val murenaAccountType: StateFlow<String?> = _murenaAccountType.asStateFlow() private val _murenaSyncLastSuccessAt = MutableStateFlow(appPreferences.loadMurenaSyncLastSuccessAt()) val murenaSyncLastSuccessAt: StateFlow<Long> = _murenaSyncLastSuccessAt.asStateFlow() private val _murenaSyncLastError = MutableStateFlow(appPreferences.loadMurenaSyncLastError()) val murenaSyncLastError: StateFlow<String?> = _murenaSyncLastError.asStateFlow() private val _hasPromptedThemeMode = MutableStateFlow(appPreferences.loadHasPromptedThemeMode()) val hasPromptedThemeMode: StateFlow<Boolean> = _hasPromptedThemeMode.asStateFlow() Loading Loading @@ -244,6 +265,41 @@ class AppPreferenceRepository @Inject constructor( appPreferences.saveHasPromptedThemeMode(hasPrompted) } fun setMurenaSyncOnboardingComplete(completed: Boolean) { _murenaSyncOnboardingComplete.value = completed appPreferences.saveMurenaSyncOnboardingComplete(completed) } fun setMurenaSyncEnabled(enabled: Boolean) { _murenaSyncEnabled.value = enabled appPreferences.saveMurenaSyncEnabled(enabled) } fun setMurenaAccount(name: String?, type: String?) { _murenaAccountName.value = name _murenaAccountType.value = type appPreferences.saveMurenaAccount(name, type) } fun getMurenaSyncRemoteEtag(): String? { return appPreferences.loadMurenaSyncRemoteEtag() } fun setMurenaSyncRemoteEtag(etag: String?) { appPreferences.saveMurenaSyncRemoteEtag(etag) } fun setMurenaSyncSucceeded(timestamp: Long) { _murenaSyncLastSuccessAt.value = timestamp _murenaSyncLastError.value = null appPreferences.saveMurenaSyncLastSuccessAt(timestamp) } fun setMurenaSyncFailed(error: String?) { _murenaSyncLastError.value = error appPreferences.saveMurenaSyncLastError(error) } private fun loadApiConfigurations() { // Load Pelias configuration val peliasBaseUrl = appPreferences.loadPeliasBaseUrl() Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt +128 −2 Original line number Diff line number Diff line Loading @@ -28,9 +28,14 @@ import java.util.Locale /** * Helper class to save and load app preferences using SharedPreferences. */ class AppPreferences(private val context: Context) { class AppPreferences internal constructor( private val context: Context, private val murenaAccountLinkageStore: MurenaAccountLinkageStore = KeystoreMurenaAccountLinkageStore(context.applicationContext) ) { private val prefs: SharedPreferences = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) this.context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) companion object { private const val KEY_CONTRAST_LEVEL = "contrast_level" Loading @@ -51,6 +56,16 @@ class AppPreferences(private val context: Context) { private const val KEY_THEME_MODE = "theme_mode" private const val KEY_HAS_PROMPTED_THEME_MODE = "has_prompted_theme_mode" private const val KEY_MURENA_SYNC_ONBOARDING_COMPLETE = "murena_sync_onboarding_complete" private const val KEY_MURENA_SYNC_ENABLED = "murena_sync_enabled" private const val KEY_MURENA_ACCOUNT_NAME = "murena_account_name" private const val KEY_MURENA_ACCOUNT_TYPE = "murena_account_type" private const val KEY_MURENA_SYNC_LAST_SUCCESS_AT = "murena_sync_last_success_at" private const val KEY_MURENA_SYNC_LAST_ERROR = "murena_sync_last_error" private const val KEY_MURENA_SYNC_REMOTE_ETAG = "murena_sync_remote_etag" private const val MURENA_WORKSPACE_ACCOUNT_TYPE = "e.foundation.webdav.eelo" // API configuration keys private const val KEY_PELIAS_BASE_URL = "pelias_base_url" private const val KEY_PELIAS_API_KEY = "pelias_api_key" Loading Loading @@ -340,6 +355,117 @@ class AppPreferences(private val context: Context) { return prefs.getBoolean(KEY_HAS_PROMPTED_THEME_MODE, false) } fun saveMurenaSyncOnboardingComplete(completed: Boolean) { prefs.edit { putBoolean(KEY_MURENA_SYNC_ONBOARDING_COMPLETE, completed) } } fun loadMurenaSyncOnboardingComplete(): Boolean { return prefs.getBoolean(KEY_MURENA_SYNC_ONBOARDING_COMPLETE, false) } fun saveMurenaSyncEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_MURENA_SYNC_ENABLED, enabled) } } fun loadMurenaSyncEnabled(): Boolean { return prefs.getBoolean(KEY_MURENA_SYNC_ENABLED, false) } fun saveMurenaAccount(name: String?, type: String?) { if (name.isNullOrBlank() || type.isNullOrBlank()) { murenaAccountLinkageStore.saveAccountName(null) murenaAccountLinkageStore.saveAccountType(null) } else { murenaAccountLinkageStore.saveAccountName(name) murenaAccountLinkageStore.saveAccountType(type) } prefs.edit { remove(KEY_MURENA_ACCOUNT_NAME) remove(KEY_MURENA_ACCOUNT_TYPE) } } fun loadMurenaAccountName(): String? { val encryptedAccountName = murenaAccountLinkageStore.loadAccountName() if (!encryptedAccountName.isNullOrBlank()) { clearLegacyMurenaAccount() return encryptedAccountName } val legacyAccountName = prefs.getString(KEY_MURENA_ACCOUNT_NAME, null) ?.takeIf { it.isNotBlank() } ?: return null murenaAccountLinkageStore.saveAccountName(legacyAccountName) val legacyType = prefs.getString(KEY_MURENA_ACCOUNT_TYPE, null) ?.takeIf { it.isNotBlank() } if (legacyType != null) { murenaAccountLinkageStore.saveAccountType(legacyType) } clearLegacyMurenaAccount() return legacyAccountName } fun loadMurenaAccountType(): String? { if (loadMurenaAccountName().isNullOrBlank()) return null val persistedType = murenaAccountLinkageStore.loadAccountType() if (!persistedType.isNullOrBlank()) return persistedType return MURENA_WORKSPACE_ACCOUNT_TYPE } private fun clearLegacyMurenaAccount() { prefs.edit { remove(KEY_MURENA_ACCOUNT_NAME) remove(KEY_MURENA_ACCOUNT_TYPE) } } fun saveMurenaSyncLastSuccessAt(timestamp: Long) { prefs.edit { putLong(KEY_MURENA_SYNC_LAST_SUCCESS_AT, timestamp) remove(KEY_MURENA_SYNC_LAST_ERROR) } } fun loadMurenaSyncLastSuccessAt(): Long { return prefs.getLong(KEY_MURENA_SYNC_LAST_SUCCESS_AT, 0L) } fun saveMurenaSyncLastError(error: String?) { prefs.edit { if (error.isNullOrBlank()) { remove(KEY_MURENA_SYNC_LAST_ERROR) } else { putString(KEY_MURENA_SYNC_LAST_ERROR, error) } } } fun loadMurenaSyncLastError(): String? { return prefs.getString(KEY_MURENA_SYNC_LAST_ERROR, null) } fun saveMurenaSyncRemoteEtag(etag: String?) { prefs.edit { if (etag.isNullOrBlank()) { remove(KEY_MURENA_SYNC_REMOTE_ETAG) } else { putString(KEY_MURENA_SYNC_REMOTE_ETAG, etag) } } } fun loadMurenaSyncRemoteEtag(): String? { return prefs.getString(KEY_MURENA_SYNC_REMOTE_ETAG, null) } /** * Gets the default distance unit based on the system locale. * Returns imperial for countries that use imperial system (US, Liberia, Myanmar), Loading cardinal-android/app/src/main/java/earth/maps/cardinal/data/MurenaAccountLinkageStore.kt 0 → 100644 +169 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2026 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.data import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import android.util.Log import androidx.core.content.edit import java.nio.ByteBuffer import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec internal interface MurenaAccountLinkageStore { fun saveAccountName(accountName: String?) fun loadAccountName(): String? fun saveAccountType(accountType: String?) fun loadAccountType(): String? } internal class KeystoreMurenaAccountLinkageStore(context: Context) : MurenaAccountLinkageStore { private val appContext = context.applicationContext private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) override fun saveAccountName(accountName: String?) { if (accountName.isNullOrBlank()) { prefs.edit { remove(KEY_ACCOUNT_NAME) } return } runCatching { encrypt(accountName) }.onSuccess { encryptedAccountName -> prefs.edit { putString(KEY_ACCOUNT_NAME, encryptedAccountName) } }.onFailure { exception -> Log.e(TAG, "Unable to persist encrypted Murena account linkage", exception) prefs.edit { remove(KEY_ACCOUNT_NAME) } } } override fun loadAccountName(): String? { val encryptedAccountName = prefs.getString(KEY_ACCOUNT_NAME, null) ?: return null return runCatching { decrypt(encryptedAccountName).takeIf { it.isNotBlank() } }.onFailure { exception -> Log.w(TAG, "Unable to read encrypted Murena account linkage", exception) prefs.edit { remove(KEY_ACCOUNT_NAME) } }.getOrNull() } override fun saveAccountType(accountType: String?) { if (accountType.isNullOrBlank()) { prefs.edit { remove(KEY_ACCOUNT_TYPE) } return } runCatching { encrypt(accountType) }.onSuccess { encryptedAccountType -> prefs.edit { putString(KEY_ACCOUNT_TYPE, encryptedAccountType) } }.onFailure { exception -> Log.e(TAG, "Unable to persist encrypted Murena account type", exception) prefs.edit { remove(KEY_ACCOUNT_TYPE) } } } override fun loadAccountType(): String? { val encryptedAccountType = prefs.getString(KEY_ACCOUNT_TYPE, null) ?: return null return runCatching { decrypt(encryptedAccountType).takeIf { it.isNotBlank() } }.onFailure { exception -> Log.w(TAG, "Unable to read encrypted Murena account type", exception) prefs.edit { remove(KEY_ACCOUNT_TYPE) } }.getOrNull() } private fun encrypt(value: String): String { val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) val encryptedBytes = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) val iv = cipher.iv val payload = ByteBuffer.allocate(IV_LENGTH_PREFIX_BYTES + iv.size + encryptedBytes.size) .put(iv.size.toByte()) .put(iv) .put(encryptedBytes) .array() return Base64.encodeToString(payload, Base64.NO_WRAP) } private fun decrypt(value: String): String { val payload = Base64.decode(value, Base64.NO_WRAP) require(payload.size > IV_LENGTH_PREFIX_BYTES) { "Encrypted Murena account payload is empty" } val ivSize = payload[0].toInt() and BYTE_MASK val encryptedStartIndex = IV_LENGTH_PREFIX_BYTES + ivSize require(ivSize > 0 && payload.size > encryptedStartIndex) { "Encrypted Murena account payload is malformed" } val iv = payload.copyOfRange(IV_LENGTH_PREFIX_BYTES, encryptedStartIndex) val encryptedBytes = payload.copyOfRange(encryptedStartIndex, payload.size) val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init( Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) ) return String(cipher.doFinal(encryptedBytes), Charsets.UTF_8) } private fun getOrCreateSecretKey(): SecretKey { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry if (existingKey != null) return existingKey.secretKey val keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE ) val keySpec = KeyGenParameterSpec.Builder( KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(KEY_SIZE_BITS) .setRandomizedEncryptionRequired(true) .build() keyGenerator.init(keySpec) return keyGenerator.generateKey() } private companion object { private const val TAG = "MurenaAccountLinkage" private const val PREFS_NAME = "murena_account_linkage" private const val KEY_ACCOUNT_NAME = "account_name" private const val KEY_ACCOUNT_TYPE = "account_type" private const val KEY_ALIAS = "earth.maps.cardinal.murena_account_linkage" private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val KEY_SIZE_BITS = 256 private const val GCM_TAG_LENGTH_BITS = 128 private const val IV_LENGTH_PREFIX_BYTES = 1 private const val BYTE_MASK = 0xFF } } cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/AppDatabase.kt +13 −1 Original line number Diff line number Diff line Loading @@ -29,7 +29,7 @@ import earth.maps.cardinal.data.DownloadStatusConverter @Database( entities = [OfflineArea::class, RoutingProfile::class, DownloadedTile::class, SavedList::class, SavedPlace::class, ListItem::class, RecentSearch::class], version = 13, version = 14, exportSchema = false ) @TypeConverters(TileTypeConverter::class, DownloadStatusConverter::class, ItemTypeConverter::class) Loading Loading @@ -232,6 +232,17 @@ abstract class AppDatabase : RoomDatabase() { } } private val MIGRATION_13_14 = object : Migration(13, 14) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( "ALTER TABLE list_items ADD COLUMN placementUpdatedAt INTEGER NOT NULL DEFAULT 0" ) db.execSQL( "UPDATE list_items SET placementUpdatedAt = addedAt WHERE placementUpdatedAt = 0" ) } } fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( Loading @@ -248,6 +259,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, ).build() INSTANCE = instance instance Loading Loading
cardinal-android/app/build.gradle.kts +2 −1 Original line number Diff line number Diff line Loading @@ -67,7 +67,7 @@ android { applicationId = "foundation.e.maps" minSdk = 26 targetSdk = 36 versionCode = System.getenv("VERSION_CODE")?.toIntOrNull() ?: 1 versionCode = System.getenv("VERSION_CODE")?.toIntOrNull() ?: 2 versionName = System.getenv("VERSION_NAME") ?: "debug" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" Loading Loading @@ -239,6 +239,7 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.room.runtime) implementation(libs.room.ktx) implementation(libs.work.runtime.ktx) ksp(libs.room.compiler) implementation(libs.androidx.navigation.compose) implementation(libs.gson) Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferenceRepository.kt +56 −0 Original line number Diff line number Diff line Loading @@ -79,6 +79,27 @@ class AppPreferenceRepository @Inject constructor( private val _hasPromptedLocation = MutableStateFlow(appPreferences.loadHasPromptedLocation()) val hasPromptedLocation: StateFlow<Boolean> = _hasPromptedLocation.asStateFlow() private val _murenaSyncOnboardingComplete = MutableStateFlow(appPreferences.loadMurenaSyncOnboardingComplete()) val murenaSyncOnboardingComplete: StateFlow<Boolean> = _murenaSyncOnboardingComplete.asStateFlow() private val _murenaSyncEnabled = MutableStateFlow(appPreferences.loadMurenaSyncEnabled()) val murenaSyncEnabled: StateFlow<Boolean> = _murenaSyncEnabled.asStateFlow() private val _murenaAccountName = MutableStateFlow(appPreferences.loadMurenaAccountName()) val murenaAccountName: StateFlow<String?> = _murenaAccountName.asStateFlow() private val _murenaAccountType = MutableStateFlow(appPreferences.loadMurenaAccountType()) val murenaAccountType: StateFlow<String?> = _murenaAccountType.asStateFlow() private val _murenaSyncLastSuccessAt = MutableStateFlow(appPreferences.loadMurenaSyncLastSuccessAt()) val murenaSyncLastSuccessAt: StateFlow<Long> = _murenaSyncLastSuccessAt.asStateFlow() private val _murenaSyncLastError = MutableStateFlow(appPreferences.loadMurenaSyncLastError()) val murenaSyncLastError: StateFlow<String?> = _murenaSyncLastError.asStateFlow() private val _hasPromptedThemeMode = MutableStateFlow(appPreferences.loadHasPromptedThemeMode()) val hasPromptedThemeMode: StateFlow<Boolean> = _hasPromptedThemeMode.asStateFlow() Loading Loading @@ -244,6 +265,41 @@ class AppPreferenceRepository @Inject constructor( appPreferences.saveHasPromptedThemeMode(hasPrompted) } fun setMurenaSyncOnboardingComplete(completed: Boolean) { _murenaSyncOnboardingComplete.value = completed appPreferences.saveMurenaSyncOnboardingComplete(completed) } fun setMurenaSyncEnabled(enabled: Boolean) { _murenaSyncEnabled.value = enabled appPreferences.saveMurenaSyncEnabled(enabled) } fun setMurenaAccount(name: String?, type: String?) { _murenaAccountName.value = name _murenaAccountType.value = type appPreferences.saveMurenaAccount(name, type) } fun getMurenaSyncRemoteEtag(): String? { return appPreferences.loadMurenaSyncRemoteEtag() } fun setMurenaSyncRemoteEtag(etag: String?) { appPreferences.saveMurenaSyncRemoteEtag(etag) } fun setMurenaSyncSucceeded(timestamp: Long) { _murenaSyncLastSuccessAt.value = timestamp _murenaSyncLastError.value = null appPreferences.saveMurenaSyncLastSuccessAt(timestamp) } fun setMurenaSyncFailed(error: String?) { _murenaSyncLastError.value = error appPreferences.saveMurenaSyncLastError(error) } private fun loadApiConfigurations() { // Load Pelias configuration val peliasBaseUrl = appPreferences.loadPeliasBaseUrl() Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/AppPreferences.kt +128 −2 Original line number Diff line number Diff line Loading @@ -28,9 +28,14 @@ import java.util.Locale /** * Helper class to save and load app preferences using SharedPreferences. */ class AppPreferences(private val context: Context) { class AppPreferences internal constructor( private val context: Context, private val murenaAccountLinkageStore: MurenaAccountLinkageStore = KeystoreMurenaAccountLinkageStore(context.applicationContext) ) { private val prefs: SharedPreferences = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) this.context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) companion object { private const val KEY_CONTRAST_LEVEL = "contrast_level" Loading @@ -51,6 +56,16 @@ class AppPreferences(private val context: Context) { private const val KEY_THEME_MODE = "theme_mode" private const val KEY_HAS_PROMPTED_THEME_MODE = "has_prompted_theme_mode" private const val KEY_MURENA_SYNC_ONBOARDING_COMPLETE = "murena_sync_onboarding_complete" private const val KEY_MURENA_SYNC_ENABLED = "murena_sync_enabled" private const val KEY_MURENA_ACCOUNT_NAME = "murena_account_name" private const val KEY_MURENA_ACCOUNT_TYPE = "murena_account_type" private const val KEY_MURENA_SYNC_LAST_SUCCESS_AT = "murena_sync_last_success_at" private const val KEY_MURENA_SYNC_LAST_ERROR = "murena_sync_last_error" private const val KEY_MURENA_SYNC_REMOTE_ETAG = "murena_sync_remote_etag" private const val MURENA_WORKSPACE_ACCOUNT_TYPE = "e.foundation.webdav.eelo" // API configuration keys private const val KEY_PELIAS_BASE_URL = "pelias_base_url" private const val KEY_PELIAS_API_KEY = "pelias_api_key" Loading Loading @@ -340,6 +355,117 @@ class AppPreferences(private val context: Context) { return prefs.getBoolean(KEY_HAS_PROMPTED_THEME_MODE, false) } fun saveMurenaSyncOnboardingComplete(completed: Boolean) { prefs.edit { putBoolean(KEY_MURENA_SYNC_ONBOARDING_COMPLETE, completed) } } fun loadMurenaSyncOnboardingComplete(): Boolean { return prefs.getBoolean(KEY_MURENA_SYNC_ONBOARDING_COMPLETE, false) } fun saveMurenaSyncEnabled(enabled: Boolean) { prefs.edit { putBoolean(KEY_MURENA_SYNC_ENABLED, enabled) } } fun loadMurenaSyncEnabled(): Boolean { return prefs.getBoolean(KEY_MURENA_SYNC_ENABLED, false) } fun saveMurenaAccount(name: String?, type: String?) { if (name.isNullOrBlank() || type.isNullOrBlank()) { murenaAccountLinkageStore.saveAccountName(null) murenaAccountLinkageStore.saveAccountType(null) } else { murenaAccountLinkageStore.saveAccountName(name) murenaAccountLinkageStore.saveAccountType(type) } prefs.edit { remove(KEY_MURENA_ACCOUNT_NAME) remove(KEY_MURENA_ACCOUNT_TYPE) } } fun loadMurenaAccountName(): String? { val encryptedAccountName = murenaAccountLinkageStore.loadAccountName() if (!encryptedAccountName.isNullOrBlank()) { clearLegacyMurenaAccount() return encryptedAccountName } val legacyAccountName = prefs.getString(KEY_MURENA_ACCOUNT_NAME, null) ?.takeIf { it.isNotBlank() } ?: return null murenaAccountLinkageStore.saveAccountName(legacyAccountName) val legacyType = prefs.getString(KEY_MURENA_ACCOUNT_TYPE, null) ?.takeIf { it.isNotBlank() } if (legacyType != null) { murenaAccountLinkageStore.saveAccountType(legacyType) } clearLegacyMurenaAccount() return legacyAccountName } fun loadMurenaAccountType(): String? { if (loadMurenaAccountName().isNullOrBlank()) return null val persistedType = murenaAccountLinkageStore.loadAccountType() if (!persistedType.isNullOrBlank()) return persistedType return MURENA_WORKSPACE_ACCOUNT_TYPE } private fun clearLegacyMurenaAccount() { prefs.edit { remove(KEY_MURENA_ACCOUNT_NAME) remove(KEY_MURENA_ACCOUNT_TYPE) } } fun saveMurenaSyncLastSuccessAt(timestamp: Long) { prefs.edit { putLong(KEY_MURENA_SYNC_LAST_SUCCESS_AT, timestamp) remove(KEY_MURENA_SYNC_LAST_ERROR) } } fun loadMurenaSyncLastSuccessAt(): Long { return prefs.getLong(KEY_MURENA_SYNC_LAST_SUCCESS_AT, 0L) } fun saveMurenaSyncLastError(error: String?) { prefs.edit { if (error.isNullOrBlank()) { remove(KEY_MURENA_SYNC_LAST_ERROR) } else { putString(KEY_MURENA_SYNC_LAST_ERROR, error) } } } fun loadMurenaSyncLastError(): String? { return prefs.getString(KEY_MURENA_SYNC_LAST_ERROR, null) } fun saveMurenaSyncRemoteEtag(etag: String?) { prefs.edit { if (etag.isNullOrBlank()) { remove(KEY_MURENA_SYNC_REMOTE_ETAG) } else { putString(KEY_MURENA_SYNC_REMOTE_ETAG, etag) } } } fun loadMurenaSyncRemoteEtag(): String? { return prefs.getString(KEY_MURENA_SYNC_REMOTE_ETAG, null) } /** * Gets the default distance unit based on the system locale. * Returns imperial for countries that use imperial system (US, Liberia, Myanmar), Loading
cardinal-android/app/src/main/java/earth/maps/cardinal/data/MurenaAccountLinkageStore.kt 0 → 100644 +169 −0 Original line number Diff line number Diff line /* * Cardinal Maps * Copyright (C) 2026 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.data import android.content.Context import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 import android.util.Log import androidx.core.content.edit import java.nio.ByteBuffer import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec internal interface MurenaAccountLinkageStore { fun saveAccountName(accountName: String?) fun loadAccountName(): String? fun saveAccountType(accountType: String?) fun loadAccountType(): String? } internal class KeystoreMurenaAccountLinkageStore(context: Context) : MurenaAccountLinkageStore { private val appContext = context.applicationContext private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) override fun saveAccountName(accountName: String?) { if (accountName.isNullOrBlank()) { prefs.edit { remove(KEY_ACCOUNT_NAME) } return } runCatching { encrypt(accountName) }.onSuccess { encryptedAccountName -> prefs.edit { putString(KEY_ACCOUNT_NAME, encryptedAccountName) } }.onFailure { exception -> Log.e(TAG, "Unable to persist encrypted Murena account linkage", exception) prefs.edit { remove(KEY_ACCOUNT_NAME) } } } override fun loadAccountName(): String? { val encryptedAccountName = prefs.getString(KEY_ACCOUNT_NAME, null) ?: return null return runCatching { decrypt(encryptedAccountName).takeIf { it.isNotBlank() } }.onFailure { exception -> Log.w(TAG, "Unable to read encrypted Murena account linkage", exception) prefs.edit { remove(KEY_ACCOUNT_NAME) } }.getOrNull() } override fun saveAccountType(accountType: String?) { if (accountType.isNullOrBlank()) { prefs.edit { remove(KEY_ACCOUNT_TYPE) } return } runCatching { encrypt(accountType) }.onSuccess { encryptedAccountType -> prefs.edit { putString(KEY_ACCOUNT_TYPE, encryptedAccountType) } }.onFailure { exception -> Log.e(TAG, "Unable to persist encrypted Murena account type", exception) prefs.edit { remove(KEY_ACCOUNT_TYPE) } } } override fun loadAccountType(): String? { val encryptedAccountType = prefs.getString(KEY_ACCOUNT_TYPE, null) ?: return null return runCatching { decrypt(encryptedAccountType).takeIf { it.isNotBlank() } }.onFailure { exception -> Log.w(TAG, "Unable to read encrypted Murena account type", exception) prefs.edit { remove(KEY_ACCOUNT_TYPE) } }.getOrNull() } private fun encrypt(value: String): String { val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) val encryptedBytes = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) val iv = cipher.iv val payload = ByteBuffer.allocate(IV_LENGTH_PREFIX_BYTES + iv.size + encryptedBytes.size) .put(iv.size.toByte()) .put(iv) .put(encryptedBytes) .array() return Base64.encodeToString(payload, Base64.NO_WRAP) } private fun decrypt(value: String): String { val payload = Base64.decode(value, Base64.NO_WRAP) require(payload.size > IV_LENGTH_PREFIX_BYTES) { "Encrypted Murena account payload is empty" } val ivSize = payload[0].toInt() and BYTE_MASK val encryptedStartIndex = IV_LENGTH_PREFIX_BYTES + ivSize require(ivSize > 0 && payload.size > encryptedStartIndex) { "Encrypted Murena account payload is malformed" } val iv = payload.copyOfRange(IV_LENGTH_PREFIX_BYTES, encryptedStartIndex) val encryptedBytes = payload.copyOfRange(encryptedStartIndex, payload.size) val cipher = Cipher.getInstance(TRANSFORMATION) cipher.init( Cipher.DECRYPT_MODE, getOrCreateSecretKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) ) return String(cipher.doFinal(encryptedBytes), Charsets.UTF_8) } private fun getOrCreateSecretKey(): SecretKey { val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry if (existingKey != null) return existingKey.secretKey val keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE ) val keySpec = KeyGenParameterSpec.Builder( KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setKeySize(KEY_SIZE_BITS) .setRandomizedEncryptionRequired(true) .build() keyGenerator.init(keySpec) return keyGenerator.generateKey() } private companion object { private const val TAG = "MurenaAccountLinkage" private const val PREFS_NAME = "murena_account_linkage" private const val KEY_ACCOUNT_NAME = "account_name" private const val KEY_ACCOUNT_TYPE = "account_type" private const val KEY_ALIAS = "earth.maps.cardinal.murena_account_linkage" private const val ANDROID_KEYSTORE = "AndroidKeyStore" private const val TRANSFORMATION = "AES/GCM/NoPadding" private const val KEY_SIZE_BITS = 256 private const val GCM_TAG_LENGTH_BITS = 128 private const val IV_LENGTH_PREFIX_BYTES = 1 private const val BYTE_MASK = 0xFF } }
cardinal-android/app/src/main/java/earth/maps/cardinal/data/room/AppDatabase.kt +13 −1 Original line number Diff line number Diff line Loading @@ -29,7 +29,7 @@ import earth.maps.cardinal.data.DownloadStatusConverter @Database( entities = [OfflineArea::class, RoutingProfile::class, DownloadedTile::class, SavedList::class, SavedPlace::class, ListItem::class, RecentSearch::class], version = 13, version = 14, exportSchema = false ) @TypeConverters(TileTypeConverter::class, DownloadStatusConverter::class, ItemTypeConverter::class) Loading Loading @@ -232,6 +232,17 @@ abstract class AppDatabase : RoomDatabase() { } } private val MIGRATION_13_14 = object : Migration(13, 14) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( "ALTER TABLE list_items ADD COLUMN placementUpdatedAt INTEGER NOT NULL DEFAULT 0" ) db.execSQL( "UPDATE list_items SET placementUpdatedAt = addedAt WHERE placementUpdatedAt = 0" ) } } fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( Loading @@ -248,6 +259,7 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, ).build() INSTANCE = instance instance Loading