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")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that's the big issue with this PR. This library is not multi platform. I cannot merge a PR that replaces functionality that already works with multiplatform (i.e. with iOS) with an implementation that doesn't.

Looking at the source code, it is also completely written in Java and also depends on a couple of Java and Android stuff ( https://android.googlesource.com/platform/frameworks/support/+/androidx-main/browser/browser/build.gradle#39 ), so it doesn't look like it would be trivial to migrate for them. I don't think it is on their agenda.

In short, we can't use that library.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only multiplatform way to open an URL in the browser via compose that I know is LocalUriHandler. This doesn't however, allow to pass any (launch) parameters to the browser.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect got it !! So if we dont want to use implementation("androidx.browser:browser:1.9.0") ,
we can open it in a browser without custom tabs ,

currently in my approach if chrome custom tabs are available , i m using that else it opens it in browser

so if we remove custom tabs and only open in browser like this :

fun launchUrl(context: Context, url: String): Boolean {
        return try {
            val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
            context.startActivity(browserIntent)
            true
        } catch (e: Exception) {
            false
        }
    }

it'll work ig :

WhatsApp.Video.2026-01-17.at.19.03.36.mp4

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but Context is Android only and hence that will not even compile on platforms other than Android

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you said, the only way would be to use val uriHandler = LocalUriHandler.current right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a nutshell, yes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That I know of, that is.

}
}
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