Sign in on a phone with no console work, in the browser's own session

Mobile OAuth reused the desktop client all along; what stopped it was the
browser. Sending the user out to Safari or Chrome backgrounds the app, iOS
suspends it, and the redirect carrying the code arrives at a socket nobody
is accepting on. The consent page now opens in front of the app instead, in
SFSafariViewController or a Chrome Custom Tab, so the loopback listener
stays live and the existing `installed` client is enough. Verified against
Google's real consent screen on a simulator and an emulator.

A per-platform client is still supported and is now an upgrade rather than a
prerequisite. On iOS it buys ASWebAuthenticationSession, which shares
Safari's session so nobody is asked to sign in to Google twice. Android
needs nothing: Custom Tabs share Chrome's cookies, measured rather than
assumed. iOS session sharing could not be confirmed on the simulator and
wants a real device.

Never an app-owned WebView: Google blocks it, and rightly, since a webview
the app controls can read the password typed into it.

Cancelling is no longer reported as a failure. AuthEvent carries a
`cancelled` flag, set by comparing against the constant every back-out path
returns, and Google's `access_denied` on desktop counts too.

Five frontend bugs found by driving the real UI, not by reading it: the
details card slid under the tab bar leaving its buttons unhittable; the
ghost click after a touch pressed a button in the card that tap had just
opened, opening the editor by itself; the swipe that pages the day was dead
over every read-only block; 84px of macOS traffic-light lane was reserved on
platforms with no traffic lights; and the desktop header ignored the top
safe area on an iPad. A first launch now says what to do next rather than
showing an empty grid, and accounts are named as Google accounts throughout.
This commit is contained in:
pj committed 2026-08-12 18:32:45 +05:30
1 parent 661100dfdc
commit d4c3a304b5
31 files changed
+1319 -114

No files matched your search

@@ -58,6 +58,9 @@ rust {
}
dependencies {
// Chrome Custom Tabs, for the OAuth consent page. Added by hand, so `tauri android init` will
// drop it again along with the bridge in MainActivity.kt that uses it.
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
@@ -5,6 +5,17 @@
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<!-- Android 11 and up hide every other package from an app unless it says which it needs to
see. Without this, CustomTabsClient.getPackageName finds no browser at all, the OAuth
consent page falls back to an ordinary browser Intent, and the loopback listener it is
supposed to redirect to is left in a backgrounded process. Added by hand, so
`tauri android init` will drop it. -->
<queries>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -1,18 +1,30 @@
package studio.margin.calendar
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.activity.enableEdgeToEdge
import androidx.browser.customtabs.CustomTabsClient
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
/**
* Android's WebView works out env(safe-area-inset-*) from the display cutout and from nothing else,
* so the status and navigation bars overlap the page while env() still reads 0 underneath them. A
* targetSdk of 36 makes edge to edge mandatory, so there is no overlap to opt out of: the only way
* out is to measure the bars and tell the page. That is what this does, and the page turns the
* numbers into --safe-top and --safe-bottom (src/safeArea.ts).
* Two things Rust cannot reach on Android without JNI, both published to the page as JavaScript
* bridges and both driven from Rust with webview.eval.
*
* The first is the window insets. Android's WebView works out env(safe-area-inset-*) from the
* display cutout and from nothing else, so the status and navigation bars overlap the page while
* env() still reads 0 underneath them. A targetSdk of 36 makes edge to edge mandatory, so there is
* no overlap to opt out of: the only way out is to measure the bars and tell the page. The page
* turns the numbers into --safe-top and --safe-bottom (src/safeArea.ts).
*
* The second is the Chrome Custom Tab that shows Google's consent page (src-tauri/src/google/
* browser.rs). It has to be a Custom Tab rather than a WebView, because Google refuses to sign
* anyone in through a WebView the app owns, and rather than an ordinary browser Intent, because
* that would put this app in the background where its loopback listener stops accepting.
*/
class MainActivity : TauriActivity() {
// Device pixels. Written on the UI thread by the insets listener and read on the WebView's
@@ -26,6 +38,48 @@ class MainActivity : TauriActivity() {
@JavascriptInterface fun bottom(): Int = bottomPx
}
inner class AuthTab {
/**
* Runs on the WebView's bridge thread rather than the UI thread, which is fine: starting an
* activity touches no view hierarchy. Every exit lands the user on the consent page somehow,
* because a sign-in that opens nothing at all is the one outcome with no way back.
*/
@JavascriptInterface
fun open(url: String) {
val uri = Uri.parse(url)
// Null when no installed browser implements Custom Tabs, which is rare and still possible on
// a stripped image or an old device.
val browser = CustomTabsClient.getPackageName(this@MainActivity, null)
if (browser != null) {
try {
val tab = CustomTabsIntent.Builder().setShowTitle(true).build()
tab.intent.setPackage(browser)
tab.launchUrl(this@MainActivity, uri)
return
} catch (e: Exception) {
Logger.warn("could not open a custom tab: $e")
}
}
// The old behaviour, and a worse one: this hands the user to a separate browser app, which
// backgrounds this process. The listener survives a short trip but the whole point of the
// tab above is not to take one.
startActivity(Intent(Intent.ACTION_VIEW, uri))
}
/**
* A Custom Tab belongs to Chrome and cannot be closed by the app that launched it. What can be
* done is to bring this activity back to the front of the task the tab was launched into, which
* pops the tab off on the way. Same move AppAuth makes, and the background-start restrictions
* do not apply because this activity is already in that task's back stack.
*/
@JavascriptInterface
fun close() {
val intent = Intent(this@MainActivity, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
startActivity(intent)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -35,6 +89,7 @@ class MainActivity : TauriActivity() {
// wry calls this between constructing the WebView and loading the URL, which is the only point
// at which an interface can be added and still be there for the first document.
webView.addJavascriptInterface(SafeArea(), "__androidSafeArea")
webView.addJavascriptInterface(AuthTab(), "__androidAuthTab")
ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets ->
// The cutout is folded in rather than trusted on its own: a landscape cutout down one side