Skip to content
Open
5 changes: 4 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,10 @@ kotlin {
implementation("com.github.chrisbanes:PhotoView:2.3.0")

// map and location
implementation("org.maplibre.gl:android-sdk:12.3.1")
implementation("org.maplibre.gl:android-sdk:12.1.0")

// Chrome Custom Tabs for OAuth flow
implementation("androidx.browser:browser:1.9.0")
}
}
iosMain {
Expand Down
7 changes: 7 additions & 0 deletions app/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
<package android:name="com.android.vending"/>
</queries>

<queries>
<!--discovers browsers that support custom tabs-->
<intent>
<action android:name="androidx.browser.customtabs.action.CustomTabsService" />
</intent>
</queries>

<application
android:name="de.westnordost.streetcomplete.StreetCompleteApplication"
android:allowBackup="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ import de.westnordost.streetcomplete.data.quest.QuestAutoSyncer
import de.westnordost.streetcomplete.data.quest.QuestKey
import de.westnordost.streetcomplete.data.quest.QuestType
import de.westnordost.streetcomplete.data.quest.VisibleQuestsSource
import de.westnordost.streetcomplete.data.user.OAuthCallbackHandler
import de.westnordost.streetcomplete.data.user.OAuthLoginCompleter
import de.westnordost.streetcomplete.data.user.UserLoginSource
import de.westnordost.streetcomplete.data.visiblequests.QuestsHiddenSource
import de.westnordost.streetcomplete.databinding.ActivityMainBinding
import de.westnordost.streetcomplete.databinding.EffectQuestPlopBinding
Expand Down Expand Up @@ -110,6 +113,7 @@ import de.westnordost.streetcomplete.util.ktx.truncateTo6Decimals
import de.westnordost.streetcomplete.util.location.FineLocationManager
import de.westnordost.streetcomplete.util.location.LocationAvailabilityReceiver
import de.westnordost.streetcomplete.util.location.LocationRequestFragment
import de.westnordost.streetcomplete.util.logs.Log
import de.westnordost.streetcomplete.util.math.area
import de.westnordost.streetcomplete.util.math.enclosingBoundingBox
import de.westnordost.streetcomplete.util.math.enlargedBy
Expand Down Expand Up @@ -168,6 +172,9 @@ class MainActivity :
private val questsHiddenSource: QuestsHiddenSource by inject()
private val featureDictionary: Lazy<FeatureDictionary> by inject(named("FeatureDictionaryLazy"))
private val soundFx: SoundFx by inject()
private val oAuthCallbackHandler: OAuthCallbackHandler by inject()
private val oAuthLoginCompleter: OAuthLoginCompleter by inject()
private val userLoginSource: UserLoginSource by inject()

private lateinit var locationManager: FineLocationManager

Expand Down Expand Up @@ -302,8 +309,31 @@ class MainActivity :

private fun handleIntent(intent: Intent) {
if (intent.action != Intent.ACTION_VIEW) return
val data = intent.data?.toString() ?: return
viewModel.setUri(data)
val data = intent.data ?: return
val dataString = data.toString()
if (oAuthCallbackHandler.handleUri(dataString)) {
lifecycleScope.launch {
val success = oAuthLoginCompleter.processCallback(dataString)
withContext(Dispatchers.Main) {
if (success) {
val userIntent = Intent(this@MainActivity, de.westnordost.streetcomplete.screens.user.UserActivity::class.java)
startActivity(userIntent)
} else {
// In some flows the user may already be logged in (e.g. external browser finished auth)
val isLoggedIn = userLoginSource.isLoggedIn
if (isLoggedIn) {
val userIntent = Intent(this@MainActivity, de.westnordost.streetcomplete.screens.user.UserActivity::class.java)
startActivity(userIntent)
} else {
toast(R.string.oauth_communication_error, Toast.LENGTH_LONG)
}
}
}
}
return
}

viewModel.setUri(dataString)
}

override fun onConfigurationChanged(newConfig: Configuration) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package de.westnordost.streetcomplete.screens.user.login

import android.content.Context
import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import de.westnordost.streetcomplete.R
import androidx.core.net.toUri

/**
* Utility class to launch URLs in Chrome Custom Tabs for OAuth flows.
*/
object ChromeCustomTabLauncher {

fun launchUrl(context: Context, url: String): Boolean {
return try {
val uri = url.toUri()
val customTabsIntent = CustomTabsIntent.Builder()
.setToolbarColor(ContextCompat.getColor(context, R.color.primary))
.setShowTitle(true)
.build()

customTabsIntent.launchUrl(context, uri)
true
} catch (e: Exception) {
// Fallback to default browser if Custom Tabs fail
try {
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(browserIntent)
true
} catch (e: Exception) {
false
}
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,9 @@ import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.AppBarDefaults
import androidx.compose.material.Button
import androidx.compose.material.ContentAlpha
Expand All @@ -28,18 +23,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.multiplatform.webview.request.RequestInterceptor
import com.multiplatform.webview.request.WebRequest
import com.multiplatform.webview.request.WebRequestInterceptResult
import com.multiplatform.webview.web.LoadingState
import com.multiplatform.webview.web.WebView
import com.multiplatform.webview.web.WebViewNavigator
import com.multiplatform.webview.web.rememberWebViewNavigator
import com.multiplatform.webview.web.rememberWebViewState
import de.westnordost.streetcomplete.ApplicationConstants
import de.westnordost.streetcomplete.R
import de.westnordost.streetcomplete.resources.Res
import de.westnordost.streetcomplete.resources.unsynced_quests_not_logged_in_description
Expand All @@ -49,9 +34,10 @@ import de.westnordost.streetcomplete.screens.user.login.LoginError.RequiredPermi
import de.westnordost.streetcomplete.ui.common.BackIcon
import de.westnordost.streetcomplete.ui.theme.titleLarge
import de.westnordost.streetcomplete.util.ktx.toast
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource

/** Leads user through the OAuth 2 auth flow to login */
/** Leads user through the OAuth 2 auth flow to login using Chrome Custom Tabs */
@Composable
fun LoginScreen(
viewModel: LoginViewModel,
Expand All @@ -62,7 +48,9 @@ fun LoginScreen(
val unsyncedChangesCount by viewModel.unsyncedChangesCount.collectAsState()

LaunchedEffect(launchAuth) {
if (launchAuth) viewModel.startLogin()
if (launchAuth) {
viewModel.startLogin()
}
}

// handle error state: just show message once and return to login state
Expand All @@ -79,11 +67,40 @@ fun LoginScreen(
}
}

// Launch Custom Tab for OAuth when requesting authorization
LaunchedEffect(state) {
if (state is RequestingAuthorization && !viewModel.hasCustomTabLaunched()) {
viewModel.markCustomTabLaunched()
val authUrl = viewModel.authorizationRequestUrl
// Launch OAuth flow in Chrome Custom Tab
ChromeCustomTabLauncher.launchUrl(context, authUrl)
}
}

LaunchedEffect(state) {
if (state is RequestingAuthorization) {
delay(2000)
if (viewModel.loginState.value is RequestingAuthorization) {
viewModel.resetLogin()
}
}
}

Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(stringResource(Res.string.user_login)) },
windowInsets = AppBarDefaults.topAppBarWindowInsets,
navigationIcon = { IconButton(onClick = onClickBack) { BackIcon() } },
navigationIcon = {
IconButton(onClick = {
// If user closes Custom Tab and returns, pressing back resets the loading state
if (state is RequestingAuthorization) {
viewModel.resetLogin()
}
onClickBack()
}) {
BackIcon()
}
},
)

if (state is LoggedOut) {
Expand All @@ -93,62 +110,9 @@ fun LoginScreen(
modifier = Modifier.fillMaxSize()
)
} else if (state is RequestingAuthorization) {
val webViewState = rememberWebViewState(
url = viewModel.authorizationRequestUrl,
additionalHttpHeaders = mapOf(
"Accept-Language" to Locale.current.toLanguageTag()
)
)

val webViewNavigator = rememberWebViewNavigator(
// handle authorization url response
requestInterceptor = object : RequestInterceptor {
override fun onInterceptUrlRequest(
request: WebRequest,
navigator: WebViewNavigator
): WebRequestInterceptResult {
if (viewModel.isAuthorizationResponseUrl(request.url)) {
viewModel.finishAuthorization(request.url)
return WebRequestInterceptResult.Reject
}
return WebRequestInterceptResult.Allow
}
}
)

// handle error response
LaunchedEffect(webViewState.errorsForCurrentRequest) {
val error = webViewState.errorsForCurrentRequest.firstOrNull()
if (error != null) {
viewModel.failAuthorization(
url = webViewState.lastLoadedUrl.toString(),
errorCode = error.code,
description = error.description
)
}
}

Box(Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
))
) {
if (webViewState.loadingState is LoadingState.Loading) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
WebView(
state = webViewState,
modifier = Modifier.fillMaxSize(),
captureBackPresses = true,
navigator = webViewNavigator,
onCreated = {
val settings = webViewState.webSettings
settings.isJavaScriptEnabled = true
settings.customUserAgentString = ApplicationConstants.USER_AGENT
settings.supportZoom = false
} as () -> Unit,
)
// Show the loading state while Custom Tab is handling authorization
Box(Modifier.fillMaxSize()) {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
} else if (state is RetrievingAccessToken || state is LoggedIn) {
Box(Modifier.fillMaxSize()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package de.westnordost.streetcomplete.data.user

import de.westnordost.streetcomplete.data.user.oauth.OAuthAuthorizationParams
import de.westnordost.streetcomplete.util.logs.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update

/**
* When the user completes the OAuth flow and returns to the app via the callback URI,
* this handler processes the authorization response.
*/
class OAuthCallbackHandler {
private val _oAuthCallbackUri = MutableStateFlow<String?>(null)
val oAuthCallbackUri: StateFlow<String?> = _oAuthCallbackUri

// Store the OAuth params so the same codeVerifier can be used during token exchange
private var storedOAuthParams: OAuthAuthorizationParams? = null

fun storeOAuthParams(params: OAuthAuthorizationParams) {
storedOAuthParams = params
}

fun getStoredOAuthParams(): OAuthAuthorizationParams? = storedOAuthParams

// Process a potential OAuth callback URI
fun handleUri(uriString: String): Boolean {
return if (isOAuthCallback(uriString)) {
_oAuthCallbackUri.update { uriString }
true
} else {
false
}
}

// checks if the uri is oauth callback
private fun isOAuthCallback(uriString: String): Boolean {
return uriString.startsWith("$OAUTH2_CALLBACK_SCHEME://$OAUTH2_CALLBACK_HOST")
}

fun consumeCallback(): String? {
return _oAuthCallbackUri.getAndUpdate { null }
}

fun clearStoredParams() {
Log.i(TAG, "Clearing stored OAuth params")
storedOAuthParams = null
}

companion object {
private const val TAG = "OAuthCallbackHandler"
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package de.westnordost.streetcomplete.data.user

import de.westnordost.streetcomplete.data.user.oauth.OAuthApiClient
import de.westnordost.streetcomplete.data.user.oauth.OAuthException
import de.westnordost.streetcomplete.util.logs.Log


// Finishes the OAuth login flow when an authorization callback URL is received.
class OAuthLoginCompleter(
private val oAuthApiClient: OAuthApiClient,
private val userLoginController: UserLoginController,
private val oAuthCallbackHandler: OAuthCallbackHandler
) {

suspend fun processCallback(authorizationResponseUrl: String): Boolean {

val oAuthParams = oAuthCallbackHandler.getStoredOAuthParams() ?: return false

return try {
val tokenResponse = oAuthApiClient.getAccessToken(oAuthParams, authorizationResponseUrl)
if (tokenResponse.grantedScopes?.containsAll(OAUTH2_REQUIRED_SCOPES) == false) {
return false
}

userLoginController.logIn(tokenResponse.accessToken)

// Clear stored params after successful login
oAuthCallbackHandler.clearStoredParams()
true
} catch (e: Exception) {
if (e is OAuthException && e.error == "access_denied") {
Log.w(TAG, "OAuth access denied by user")
} else {
Log.e(TAG, "Error finishing OAuth login: ${e.message}", e)
}
false
}
}

companion object {
private const val TAG = "OAuthLoginCompleter"
}
}

Loading