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

Commit 2ce7d9a1 authored by mitulsheth's avatar mitulsheth
Browse files

feat: Murena Workspace Login Part 2 of 1

parent daee7b4f
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -35,6 +35,6 @@ class ExampleInstrumentedTest {
    fun useAppContext() {
        // Context of the app under test.
        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
        assertEquals("earth.maps.cardinal", appContext.packageName)
        assertEquals("foundation.e.map.debug", appContext.packageName)
    }
}
 No newline at end of file
+8 −0
Original line number Diff line number Diff line
@@ -19,6 +19,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.GET_ACCOUNTS" />
    <uses-permission android:name="android.permission.USE_CREDENTIALS" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -50,6 +52,12 @@
            android:name="com.stadiamaps.ferrostar.core.service.FerrostarForegroundService"
            android:foregroundServiceType="location" />

        <activity
            android:name=".data.sync.MurenaAuthTokenActivity"
            android:excludeFromRecents="true"
            android:exported="false"
            android:theme="@style/Theme.CardinalMaps" />

        <activity
            android:name=".MainActivity"
            android:exported="true"
+86 −63
Original line number Diff line number Diff line
@@ -56,6 +56,7 @@ 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.settings.ThemeModePromptBottomSheet
import earth.maps.cardinal.ui.sync.MurenaSyncWelcomeRoot
import earth.maps.cardinal.ui.theme.AppTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -201,34 +202,9 @@ class MainActivity : ComponentActivity() {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        hasNotificationPermission = checkNotificationPermission()
        hasLocationPermission = checkLocationPermission()
        if (hasLocationPermission) {
            locationRepository.startContinuousLocationUpdates(this@MainActivity)
        }

        // Check if we should show the location permission dialog on first startup
        if (!appPreferenceRepository.hasPromptedLocation.value && !hasLocationPermission) {
            showLocationPermissionDialog = true
            isLocationPermissionFlowActive = true
        }

        CoroutineScope(Dispatchers.IO).launch {
            migrationHelper.migratePlacesToSavedPlaces()
            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)
            }
        }
        initializePermissionState()
        runStartupMaintenance()
        handleInitialIntent(intent)

        setContent {
            val contrastLevel by appPreferenceRepository.contrastLevel.collectAsState()
@@ -243,6 +219,10 @@ class MainActivity : ComponentActivity() {
                        !isLocationPermissionFlowActive

            AppTheme(darkTheme = darkTheme, contrastLevel = contrastLevel) {
                val murenaSyncOnboardingComplete by appPreferenceRepository
                    .murenaSyncOnboardingComplete
                    .collectAsState()

                val mapViewModel: MapViewModel = hiltViewModel()

                val navController = rememberNavController()
@@ -254,6 +234,7 @@ class MainActivity : ComponentActivity() {
                        navController.navigate(it)
                    }
                }
                if (murenaSyncOnboardingComplete) {
                    AppContent(
                        navController = navController,
                        mapViewModel = mapViewModel,
@@ -290,6 +271,48 @@ class MainActivity : ComponentActivity() {
                            }
                        )
                    }
                } else {
                    MurenaSyncWelcomeRoot(
                        onContinueToMaps = {
                            appPreferenceRepository.setMurenaSyncOnboardingComplete(true)
                        }
                    )
                }
            }
        }
    }

    private fun initializePermissionState() {
        hasNotificationPermission = checkNotificationPermission()
        hasLocationPermission = checkLocationPermission()
        if (hasLocationPermission) {
            locationRepository.startContinuousLocationUpdates(this@MainActivity)
        }

        // Check if we should show the location permission dialog on first startup
        if (!appPreferenceRepository.hasPromptedLocation.value && !hasLocationPermission) {
            showLocationPermissionDialog = true
            isLocationPermissionFlowActive = true
        }
    }

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

    private fun handleInitialIntent(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")) {
                handleGeoIntent(data)
            }

            // Check for deep link destination
            if (deepLinkDestination == null) {
                deepLinkDestination = intent.getStringExtra(EXTRA_DEEP_LINK_DESTINATION)
            }
        }
    }
+298 −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.sync

import android.accounts.Account
import android.accounts.AccountManager
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.activity.ComponentActivity
import earth.maps.cardinal.domain.sync.MurenaAccount
import earth.maps.cardinal.domain.sync.MurenaAuthToken
import earth.maps.cardinal.domain.sync.MurenaAuthorizationScheme

class MurenaAuthTokenActivity : ComponentActivity() {

    private var account: Account? = null
    private var retryAfterUserAction = false
    private var authTokenTypeIndex = 0
    private val handler = Handler(Looper.getMainLooper())
    private val timeoutRunnable = Runnable {
        Log.w(TAG, "Foreground auth-token request timed out")
        finishWithResult(Activity.RESULT_CANCELED)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        showLoadingUi()
        handler.postDelayed(timeoutRunnable, REQUEST_TIMEOUT_MS)

        val accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME)
        val accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE)
        if (accountName.isNullOrBlank() || accountType.isNullOrBlank()) {
            Log.w(TAG, "Cannot request auth token: missing account extras")
            finishWithResult(Activity.RESULT_CANCELED)
            return
        }

        account = Account(accountName, accountType)
        Log.d(TAG, "Starting foreground auth-token request for ${accountName.redactedAccountName()}")
        requestAuthToken()
    }

    override fun onDestroy() {
        handler.removeCallbacks(timeoutRunnable)
        super.onDestroy()
    }

    @Deprecated("Deprecated in Java")
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == REQUEST_USER_ACTION && retryAfterUserAction) {
            retryAfterUserAction = false
            Log.d(TAG, "Auth-token user action finished resultCode=$resultCode")
            if (resultCode != Activity.RESULT_OK) {
                Log.d(TAG, "Auth-token user action was not approved; trying next token type")
                handler.postDelayed(timeoutRunnable, REQUEST_TIMEOUT_MS)
                requestNextAuthTokenType()
                return
            }
            authTokenFromResult(data)?.let { authToken ->
                Log.d(
                    TAG,
                    "Auth-token user action returned token directly scheme=${authToken.scheme}"
                )
                finishWithToken(authToken.value, authToken.scheme)
                return
            }
            Log.d(TAG, "Retrying auth-token request after user action")
            handler.postDelayed(timeoutRunnable, REQUEST_TIMEOUT_MS)
            requestAuthToken()
        }
    }

    private fun requestAuthToken() {
        val currentAccount = account ?: run {
            finishWithResult(Activity.RESULT_CANCELED)
            return
        }
        val authTokenType = currentAuthTokenType() ?: run {
            Log.w(TAG, "Foreground auth-token request exhausted all token types")
            finishWithResult(Activity.RESULT_CANCELED)
            return
        }
        Log.d(
            TAG,
            "Requesting foreground auth-token type=${authTokenType.value} " +
                "scheme=${authTokenType.scheme}"
        )

        AccountManager.get(this).getAuthToken(
            currentAccount,
            authTokenType.value,
            Bundle.EMPTY,
            this,
            { future ->
                val bundle = runCatching { future.result }
                    .onFailure { throwable ->
                        Log.w(
                            TAG,
                            "Foreground auth-token request failed for tokenType=${authTokenType.value}",
                            throwable
                        )
                    }
                    .getOrNull()

                if (bundle == null) {
                    requestNextAuthTokenType()
                    return@getAuthToken
                }

                val token = bundle.getString(AccountManager.KEY_AUTHTOKEN)
                if (!token.isNullOrBlank()) {
                    Log.d(
                        TAG,
                        "Foreground auth-token request succeeded " +
                            "tokenType=${authTokenType.value} scheme=${authTokenType.scheme}"
                    )
                    finishWithToken(token, authTokenType.scheme)
                    return@getAuthToken
                }

                authTokenIntent(bundle)?.let { intent ->
                    Log.d(TAG, "Foreground auth-token request returned user action intent")
                    retryAfterUserAction = true
                    handler.removeCallbacks(timeoutRunnable)
                    startActivityForResult(intent, REQUEST_USER_ACTION)
                    return@getAuthToken
                }

                Log.w(
                    TAG,
                    "Foreground auth-token request returned no token for " +
                        "tokenType=${authTokenType.value}. " +
                        "code=${bundle.getInt(AccountManager.KEY_ERROR_CODE, 0)} " +
                        "message=${bundle.getString(AccountManager.KEY_ERROR_MESSAGE)}"
                )
                requestNextAuthTokenType()
            },
            Handler(Looper.getMainLooper())
        )
    }

    @Suppress("DEPRECATION")
    private fun authTokenIntent(bundle: Bundle): Intent? {
        return bundle.getParcelable(AccountManager.KEY_INTENT)
    }

    private fun showLoadingUi() {
        val content = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            gravity = Gravity.CENTER
            setBackgroundColor(Color.rgb(16, 17, 22))
            layoutParams = ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT
            )
            setPadding(48, 48, 48, 48)
        }
        content.addView(
            ProgressBar(this).apply {
                isIndeterminate = true
            }
        )
        content.addView(
            TextView(this).apply {
                text = "Checking Murena account access..."
                setTextColor(Color.WHITE)
                textSize = 16f
                gravity = Gravity.CENTER
                layoutParams = LinearLayout.LayoutParams(
                    ViewGroup.LayoutParams.WRAP_CONTENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT
                ).apply {
                    topMargin = 24
                }
            }
        )
        setContentView(content)
    }

    private fun requestNextAuthTokenType() {
        authTokenTypeIndex += 1
        if (authTokenTypeIndex >= AUTH_TOKEN_TYPES.size) {
            Log.w(TAG, "Foreground auth-token request could not get credentials from any token type")
            finishWithResult(Activity.RESULT_CANCELED)
            return
        }

        Log.d(TAG, "Trying next foreground auth-token type=${currentAuthTokenType()?.value}")
        requestAuthToken()
    }

    private fun currentAuthTokenType(): AuthTokenRequest? {
        return AUTH_TOKEN_TYPES.getOrNull(authTokenTypeIndex)
    }

    private fun finishWithToken(token: String, scheme: MurenaAuthorizationScheme) {
        handler.removeCallbacks(timeoutRunnable)
        setResult(
            Activity.RESULT_OK,
            Intent()
                .putExtra(EXTRA_AUTH_TOKEN, token)
                .putExtra(EXTRA_AUTH_SCHEME, scheme.name)
        )
        finish()
    }

    private fun finishWithResult(resultCode: Int) {
        handler.removeCallbacks(timeoutRunnable)
        setResult(resultCode)
        finish()
    }

    companion object {
        private const val TAG = "MurenaSync"
        private const val REQUEST_USER_ACTION = 100
        private const val REQUEST_TIMEOUT_MS = 60_000L
        private const val EXTRA_ACCOUNT_NAME = "earth.maps.cardinal.extra.ACCOUNT_NAME"
        private const val EXTRA_ACCOUNT_TYPE = "earth.maps.cardinal.extra.ACCOUNT_TYPE"
        private const val EXTRA_AUTH_TOKEN = "earth.maps.cardinal.extra.AUTH_TOKEN"
        private const val EXTRA_AUTH_SCHEME = "earth.maps.cardinal.extra.AUTH_SCHEME"

        private val AUTH_TOKEN_TYPES = arrayOf(
            AuthTokenRequest(
                MurenaAccountDataSource.OAUTH_AUTH_TOKEN_TYPE,
                MurenaAuthorizationScheme.BEARER
            ),
            AuthTokenRequest("webdav", MurenaAuthorizationScheme.BASIC),
            AuthTokenRequest(
                MurenaAccountDataSource.PRIMARY_ACCOUNT_TYPE,
                MurenaAuthorizationScheme.BASIC
            ),
            AuthTokenRequest("full_access", MurenaAuthorizationScheme.BASIC)
        )

        fun createIntent(context: Context, account: MurenaAccount): Intent {
            return Intent(context, MurenaAuthTokenActivity::class.java).apply {
                putExtra(EXTRA_ACCOUNT_NAME, account.name)
                putExtra(EXTRA_ACCOUNT_TYPE, account.type)
            }
        }

        fun authTokenFromResult(data: Intent?): MurenaAuthToken? {
            val token = data?.getStringExtra(EXTRA_AUTH_TOKEN)
                ?: data?.getStringExtra(AccountManager.KEY_AUTHTOKEN)
            if (token.isNullOrBlank()) return null

            val scheme = data?.getStringExtra(EXTRA_AUTH_SCHEME)
                ?.let { rawScheme ->
                    runCatching { MurenaAuthorizationScheme.valueOf(rawScheme) }.getOrNull()
                }
                ?: MurenaAuthorizationScheme.BEARER

            return MurenaAuthToken(
                value = token,
                scheme = scheme
            )
        }
    }
}

private data class AuthTokenRequest(
    val value: String,
    val scheme: MurenaAuthorizationScheme
)

private fun String.redactedAccountName(): String {
    val atIndex = indexOf('@')
    if (atIndex <= 1) return "***"
    return "${first()}***${substring(atIndex)}"
}
+4 −0
Original line number Diff line number Diff line
@@ -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 MURENA_SYNC_SETTINGS = "murena_sync_settings"
        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 MurenaSyncSettings : Screen(MURENA_SYNC_SETTINGS)

    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.MurenaSyncSettings -> screen.route
            is Screen.RoutingProfiles -> screen.route
            is Screen.ProfileEditor -> {
                val profileId = screen.profileId
Loading