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

Commit 65185bc6 authored by mitulsheth's avatar mitulsheth
Browse files

feat: Murena Workspace Login and SSO

parent 4083693d
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -219,6 +219,7 @@ dependencies {
    implementation(libs.logging.interceptor)
    implementation(libs.androidaddressformatter)
    implementation(libs.eos.telemetry)
    implementation(libs.android.single.sign.on)

    // TODO: Migrate version to TOML (doesn't work). Likely related issue: https://github.com/gradle/gradle/issues/21267
    //noinspection UseTomlInstead
+6 −0
Original line number Diff line number Diff line
@@ -24,7 +24,9 @@
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
    <uses-permission android:name="android.permission.GET_ACCOUNTS" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />

    <application
        android:name=".CardinalMapsApplication"
@@ -73,6 +75,10 @@
    </application>

    <queries>
        <package android:name="foundation.e.accountmanager" />
        <package android:name="com.nextcloud.client" />
        <package android:name="com.nextcloud.android.beta" />

        <intent>
            <action android:name="android.intent.action.TTS_SERVICE" />
        </intent>
+126 −57
Original line number Diff line number Diff line
@@ -32,6 +32,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -40,12 +41,14 @@ import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.nextcloud.android.sso.AccountImporter
import com.google.gson.Gson
import dagger.hilt.android.AndroidEntryPoint
import earth.maps.cardinal.data.AppPreferenceRepository
import earth.maps.cardinal.data.LatLng
import earth.maps.cardinal.data.LocationRepository
import earth.maps.cardinal.data.Place
import earth.maps.cardinal.data.ThemeMode
import earth.maps.cardinal.data.room.MigrationHelper
import earth.maps.cardinal.data.room.SavedListRepository
import earth.maps.cardinal.routing.FerrostarWrapperRepository
@@ -55,6 +58,7 @@ import earth.maps.cardinal.tileserver.PermissionRequest
import earth.maps.cardinal.tileserver.PermissionRequestManager
import earth.maps.cardinal.ui.core.AppContent
import earth.maps.cardinal.ui.core.MapViewModel
import earth.maps.cardinal.ui.murena.MurenaLoginRoot
import earth.maps.cardinal.ui.settings.ThemeModePromptBottomSheet
import earth.maps.cardinal.ui.theme.AppTheme
import kotlinx.coroutines.CoroutineScope
@@ -95,6 +99,9 @@ class MainActivity : ComponentActivity() {
    private var deepLinkDestination by mutableStateOf<String?>(null)
    private var showLocationPermissionDialog by mutableStateOf(false)
    private var isLocationPermissionFlowActive by mutableStateOf(false)
    private var isMurenaSsoReady by mutableStateOf(false)
    private var murenaSsoActivityResultHandler: ((Int, Int, Intent?) -> Boolean)? = null
    private var murenaSsoPermissionResultHandler: ((Int, IntArray) -> Unit)? = null

    companion object {
        private const val LOCATION_PERMISSION_REQUEST_CODE = 1001
@@ -197,10 +204,28 @@ class MainActivity : ComponentActivity() {
        }
    }

    fun setMurenaSsoActivityResultHandler(handler: ((Int, Int, Intent?) -> Boolean)?) {
        murenaSsoActivityResultHandler = handler
    }

    fun setMurenaSsoPermissionResultHandler(handler: ((Int, IntArray) -> Unit)?) {
        murenaSsoPermissionResultHandler = handler
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        initializePermissionState()
        runStartupMaintenance()
        handleLaunchIntent(intent)

        setContent {
            MainActivityContent()
        }
    }

    private fun initializePermissionState() {
        hasNotificationPermission = checkNotificationPermission()
        hasLocationPermission = checkLocationPermission()
        if (hasLocationPermission) {
@@ -212,12 +237,16 @@ class MainActivity : ComponentActivity() {
            showLocationPermissionDialog = true
            isLocationPermissionFlowActive = true
        }
    }

    private fun runStartupMaintenance() {
        CoroutineScope(Dispatchers.IO).launch {
            migrationHelper.migratePlacesToSavedPlaces()
            savedListRepository.cleanupUnparentedElements()
        }
    }

    private fun handleLaunchIntent(intent: Intent?) {
        intent?.takeIf { it.action == Intent.ACTION_VIEW }?.let { intent ->
            val data: Uri? = intent.data
            if (data != null && data.scheme != null && data.scheme.equals("geo")) {
@@ -229,8 +258,10 @@ class MainActivity : ComponentActivity() {
                deepLinkDestination = intent.getStringExtra(EXTRA_DEEP_LINK_DESTINATION)
            }
        }
    }

        setContent {
    @Composable
    private fun MainActivityContent() {
        val contrastLevel by appPreferenceRepository.contrastLevel.collectAsState()
        val themeMode by appPreferenceRepository.themeMode.collectAsState()
        val hasPromptedThemeMode by appPreferenceRepository.hasPromptedThemeMode.collectAsState()
@@ -243,17 +274,38 @@ class MainActivity : ComponentActivity() {
                !isLocationPermissionFlowActive

        AppTheme(darkTheme = darkTheme, contrastLevel = contrastLevel) {
                val mapViewModel: MapViewModel = hiltViewModel()
            if (isMurenaSsoReady) {
                MapContent(
                    darkTheme = darkTheme,
                    shouldShowThemePrompt = shouldShowThemePrompt,
                    themeMode = themeMode
                )
            } else {
                MurenaLoginRoot(
                    onContinueToMap = {
                        isMurenaSsoReady = true
                    }
                )
            }
        }
    }

    @Composable
    private fun MapContent(
        darkTheme: Boolean,
        shouldShowThemePrompt: Boolean,
        themeMode: ThemeMode
    ) {
        val mapViewModel: MapViewModel = hiltViewModel()
        val navController = rememberNavController()

        LaunchedEffect(key1 = deepLinkDestination) {
            deepLinkDestination?.let {
                Log.d(TAG, "Deep link: $it")

                navController.navigate(it)
            }
        }

        AppContent(
            navController = navController,
            mapViewModel = mapViewModel,
@@ -291,8 +343,6 @@ class MainActivity : ComponentActivity() {
            )
        }
    }
        }
    }

    private fun handleGeoIntent(data: Uri) {
        parseGeoIntent(data)?.let { place ->
@@ -353,4 +403,23 @@ class MainActivity : ComponentActivity() {
            bound = false
        }
    }

    @Deprecated("Deprecated in Java")
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (murenaSsoActivityResultHandler?.invoke(requestCode, resultCode, data) == true) {
            return
        }
    }

    @Deprecated("Deprecated in Java")
    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        AccountImporter.onRequestPermissionsResult(requestCode, permissions, grantResults, this)
        murenaSsoPermissionResultHandler?.invoke(requestCode, grantResults)
    }
}
+48 −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.murena

import earth.maps.cardinal.domain.murena.MurenaDeviceAccount
import earth.maps.cardinal.domain.murena.MurenaLoginRepository
import earth.maps.cardinal.domain.murena.MurenaSsoAccount
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class DefaultMurenaLoginRepository @Inject constructor(
    private val accountDataSource: MurenaLoginAccountDataSource,
    private val ssoAccountDataSource: MurenaSsoAccountDataSource
) : MurenaLoginRepository {

    override suspend fun getCurrentSsoAccount(): MurenaSsoAccount? {
        return ssoAccountDataSource.getCurrentAccount()
    }

    override suspend fun getDeviceAccount(): MurenaDeviceAccount? {
        return accountDataSource.getDeviceAccounts().firstOrNull()
    }

    override suspend fun importDeviceAccount(account: MurenaDeviceAccount): MurenaSsoAccount? {
        return ssoAccountDataSource.importAccount(account)
    }

    override suspend fun setCurrentSsoAccount(account: MurenaSsoAccount) {
        ssoAccountDataSource.setCurrentAccount(account)
    }
}
+142 −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.murena

import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import android.os.Build
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import earth.maps.cardinal.domain.murena.MurenaAccountConstants
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class MurenaAccountProvider @Inject constructor(
    @param:ApplicationContext private val context: Context
) {
    private val accountManager: AccountManager
        get() = AccountManager.get(context)

    fun getMurenaAccounts(): List<Account> {
        return queryVisibleMurenaAccounts()
    }

    fun getPrimaryMurenaAccount(): Account? {
        return getMurenaAccounts().firstOrNull()
    }

    fun hasMurenaAccount(): Boolean {
        return getPrimaryMurenaAccount() != null
    }

    private fun queryVisibleMurenaAccounts(): List<Account> {
        val accountType = MurenaAccountConstants.MURENA_ACCOUNT_TYPE
        val accountsByType = queryAccounts("getAccountsByType") {
            accountManager.getAccountsByType(accountType).toList()
        }
        if (accountsByType.isNotEmpty()) {
            return accountsByType
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val accountsForPackage = queryAccounts("getAccountsByTypeForPackage") {
                accountManager.getAccountsByTypeForPackage(accountType, context.packageName).toList()
            }
            if (accountsForPackage.isNotEmpty()) {
                return accountsForPackage
            }

            val visibilityAccounts = queryAccountsFromVisibilityMap(accountType)
            if (visibilityAccounts.isNotEmpty()) {
                return visibilityAccounts
            }
        }

        return emptyList()
    }

    private fun queryAccounts(
        source: String,
        block: () -> List<Account>
    ): List<Account> {
        return try {
            block().also { accounts ->
                Log.d(
                    TAG,
                    "Murena account query source=$source count=${accounts.size} " +
                        "names=${accounts.redactedNamesForLog()}"
                )
            }
        } catch (exception: SecurityException) {
            Log.w(TAG, "Missing permission for Murena account query source=$source", exception)
            emptyList()
        } catch (exception: RuntimeException) {
            Log.w(TAG, "Murena account query failed source=$source", exception)
            emptyList()
        }
    }

    private fun queryAccountsFromVisibilityMap(accountType: String): List<Account> {
        return try {
            accountManager.getAccountsAndVisibilityForPackage(context.packageName, accountType)
                .filterValues { visibility -> visibility.isVisibleToPackage() }
                .keys
                .toList()
                .also { accounts ->
                    Log.d(
                        TAG,
                        "Murena account query source=getAccountsAndVisibilityForPackage " +
                            "visibleCount=${accounts.size} names=${accounts.redactedNamesForLog()}"
                    )
                }
        } catch (exception: SecurityException) {
            Log.w(TAG, "Missing permission for Murena account visibility map", exception)
            emptyList()
        } catch (exception: RuntimeException) {
            Log.w(TAG, "Murena account visibility map query failed", exception)
            emptyList()
        }
    }

    private fun Int.isVisibleToPackage(): Boolean {
        return this == AccountManager.VISIBILITY_VISIBLE ||
            this == AccountManager.VISIBILITY_USER_MANAGED_VISIBLE
    }

    private fun List<Account>.redactedNamesForLog(): String {
        return joinToString(prefix = "[", postfix = "]") { account ->
            account.name.redactedForLog()
        }
    }

    private fun String.redactedForLog(): String {
        val atIndex = indexOf('@')
        return if (atIndex > 0) {
            "${first()}***@***"
        } else {
            "${take(1)}***"
        }
    }

    private companion object {
        private const val TAG = "MurenaLogin"
    }
}
Loading