Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 6a72d2fa authored by mitulsheth's avatar mitulsheth
Browse files

feat: Murena Workspace Login Part 1

parent 4083693d
Loading
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -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"
@@ -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)
+56 −0
Original line number Diff line number Diff line
@@ -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()

@@ -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()
+128 −2
Original line number Diff line number Diff line
@@ -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"
@@ -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"
@@ -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),
+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
    }
}
+13 −1
Original line number Diff line number Diff line
@@ -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)
@@ -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(
@@ -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