mirror of
https://github.com/priyanshujain/margin-calendar.git
synced 2026-10-02 11:07:04 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user