Repository navigation
Fix Android cloud auth callback - #4416
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4416 +/- ##
===========================================
+ Coverage 59.98% 75.63% +15.65%
===========================================
Files 308 41 -267
Lines 32058 1613 -30445
Branches 8784 0 -8784
===========================================
- Hits 19229 1220 -18009
+ Misses 12811 393 -12418
+ Partials 18 0 -18
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
768343e to
be27c33
Compare
✅ Deploy Preview for electric-next ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
92773d8 to
f727e37
Compare
Electric Agents Mobile BuildAndroid preview build for commit
|
| ) | ||
| } | ||
|
|
||
| if (coldStartOAuthCallback) { |
There was a problem hiding this comment.
Claude found this potential problem:
Looks like coldStartOAuthCallback could bounce between / and /oauth/callback indefinitely:
const coldStartOAuthCallback =
!!launchUrl && isCallbackUrl(launchUrl) && pathname !== `/oauth/callback`
if (coldStartOAuthCallback) {
return <Redirect href="/oauth/callback" />
}launchUrl is set once in MobileAppStateProvider (from Linking.getInitialURL()) and never cleared. After the callback route succeeds and <Redirect>s to / (or /onboarding / /server-setup), the layout re-renders with pathname !== '/oauth/callback' and launchUrl still pointing at the original cold-start URL — so the condition becomes true again and bounces back to /oauth/callback. That second mount sees status: 'signed-in' and immediately <Redirect>s away, so it'd potentially loop.
Maybe it works in practice (Expo Router may dedupe a <Redirect> to the path you're already navigating away from, or there's some pathname-stability I'm missing) — but worth either adding an empirical check, or exposing a consumeLaunchUrl() on MobileAppState that the callback route calls after handing off the URL to cloudAuth.handleDeepLink.
7d1a177 to
d16c028
Compare
Persist the pending native OAuth request and add an Expo Router callback route so Android deep links complete sign-in instead of rendering an unmatched route. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Root cause is the well-documented Android 13+ issue where the OS aggressively kills the app process while the Chrome Custom Tab is showing the OAuth page (expo/expo#44284), so on real devices the redirect to electric-agents://oauth/callback is lost: WebBrowser.openAuthSessionAsync returns 'dismiss', Linking events never fire, and the user is dropped back at the welcome screen. Defense in depth — none of these alone reliably fixes every device, but together they cover the cold-start, warm-start, and slow-relaunch cases: * New `plugins/with-android-on-new-intent.js` config plugin injects `override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); setIntent(intent) }` into the generated MainActivity.kt so deep links delivered after the OS relaunches the activity are picked up by expo-linking / expo-web-browser. * `openAuthSessionAsync` now passes `createTask: false` and `showInRecents: true` so the Custom Tab runs inside our task and the OS stops killing our process while the OAuth page is open. * `CloudAuthProvider` mounts a global `Linking.addEventListener` + `Linking.getInitialURL` handler so the redirect URL is consumed even when Expo Router hasn't navigated to `/oauth/callback` yet. * `parseCallbackUrl` uses `Linking.parse()` instead of `new URL()` because Hermes' WHATWG URL parser is unreliable for custom schemes in release builds (returns null for present query params). * `signIn` waits a short grace period for the deep link to arrive via `Linking` after a `dismiss` result instead of immediately rolling state back to `signed-out`. * `/oauth/callback` route now subscribes to cloud-auth state and navigates only after it settles, surfacing parse / state-mismatch errors instead of silently bouncing back to the welcome screen. * `completeCallbackUrl` is now idempotent across concurrent callers (Linking listener + route + openAuthSessionAsync resolution) so whichever wins the race wins cleanly. Co-authored-by: Cursor <cursoragent@cursor.com>
Two regressions snuck into the previous comprehensive fix: * The Expo config plugin imported `@expo/config-plugins`, which pnpm doesn't hoist into agents-mobile's `node_modules`. Locally Expo's own require context made this work; in EAS Build's `expo cli config --json` step the require failed and the entire build was rejected. Switched the import to `expo/config-plugins`, which `expo` re-exports and which IS resolvable since `expo` is a direct dep. * The `/oauth/callback` route navigated with `router.replace` inside an effect. That raced with Expo Router's own intent handling in dev builds — the route would mount, status would flip to `signed-in`, the effect would fire, but the replace landed too late and the user was left stuck on the spinner. Switched to rendering `<Redirect>` directly so navigation happens during the render phase alongside the rest of the tree. Also dropped the `createTask: false` / `showInRecents: true` flags on `openAuthSessionAsync`. They were meant to keep the Custom Tab in our task on Android 13+ release builds, but in dev mode they changed how the redirect URL was delivered (it ended up at `Linking` rather than being captured by the auth session) and contributed to the route- stuck regression. The `onNewIntent` override + global Linking listener already cover the release-build kill case the flags were trying to defend against. Co-authored-by: Cursor <cursoragent@cursor.com>
…setup After cloud sign-in completes the `/oauth/callback` route was unconditionally redirecting to `/`, but for a first-time user signing in mid-onboarding `serverUrl` is still `null` — so `AgentsProvider` isn't mounted, and `SessionListScreen` crashes on `useAgents` for a render or two while the root layout's own redirect chain catches up and bounces them on to `/onboarding`. Two complementary fixes: * `/oauth/callback` now looks at `useMobileAppState()` and redirects to `/onboarding` (resp. `/server-setup`) directly when those steps haven't been completed, so we never land on `/` until it's safe. * New `useAgentsRouteGuard()` helper called at the top of every route whose screen depends on `AgentsProvider` (`/`, `/session`, `/new-session`, `/diagnostics`). It emits a `<Redirect>` to the appropriate setup screen when serverUrl / onboardingDismissed aren't ready, so a transient mount during any redirect chain can't crash. The pattern is local and explicit at each route, so it's obvious why those routes can't render without setup. Restarting the app already worked because by then both the JWT and serverUrl were re-hydrated from AsyncStorage, so the redirect chain converged cleanly without going through `/oauth/callback`. Co-authored-by: Cursor <cursoragent@cursor.com>
Strips the diagnostic instrumentation Kyle added in 5b33b59 now that the cold-start callback + server-setup fixes are confirmed working: - Removes `debugCloudAuth` helper and `EXPO_PUBLIC_DEBUG_CLOUD_AUTH` flag from `cloudAuth.ts`, plus all call sites in `signIn`, `waitForDeepLinkCallback`, `completeCallbackUrl`, and `handleDeepLink`. - Drops the `useStartupLinkDebug` hook in `app/_layout.tsx` (logged render state, Linking events, and AppState changes) along with the now-unused `expo-linking`, `AppState`, and `React` imports. - Removes the two ungated `console.log` calls in `CloudAuthContext.tsx` that would have fired in production builds. - Removes debug-only `useEffect`s and the `shapePathLabel` helper in `cloudAgentServers.ts`. - Removes the render-state logger in `CloudServerPicker.tsx` and the initialization log in `MobileAppState.tsx`. All functional fixes from Kyle's commit are preserved: `launchUrl` capture, cold-start `<Redirect>` to `/oauth/callback`, server-setup cloud sign-in UI, and the `unauthorized` token-missing path. Co-authored-by: Cursor <cursoragent@cursor.com>
26de7f8 to
aa60fce
Compare
commit: |
Warning
This PR is temporarily stacked on #4408 (
samwilli/agents-mobile-ci) only so the agents-mobile EAS CI build runs against this fix.We should rebase this PR back onto
mainand merge it ASAP, then update the CI branch frommaininstead of keeping this fix stacked on the CI setup branch.Summary
Fixes the Android Electric Cloud sign-in flow getting stuck after Google / GitHub auth on real devices (and dev-mode emulators) after the latest comprehensive fix exposed two regressions.
What was breaking
The Chrome Custom Tab redirect (
electric-agents://oauth/callback?...) was being lost on real Android devices — especially Android 13+ release builds where the OS aggressively kills the app process while the browser is open.openAuthSessionAsyncwould returndismisseven though sign-in had actually succeeded,Linking.getInitialURL()returnednull, the redirect was never consumed, and the user was bounced back to the welcome screen. Underlying expo issue: expo/expo#44284.The previous comprehensive fix (
e580cf9ff) addressed those release-build cases but introduced two regressions:@expo/config-plugins, which pnpm doesn't hoist into agents-mobile'snode_modules. Local prebuild worked via expo's own require context, but in EAS Build'sexpo cli config --jsonstep the require failed and the entire Android preview build was rejected with exit code 1./oauth/callbackroute navigated withrouter.replaceinside auseEffect. That raced with Expo Router's own intent handling in dev builds — the route would mount, the cloud-auth state would flip tosigned-in(JWT was even persisted to AsyncStorage), the effect would fire, but the replace landed too late and the user was left stuck on the spinner. Restarting the app showed they were already signed in.How it's fixed now (layered defense)
onNewIntentoverride via a local config plugin (plugins/with-android-on-new-intent.js) injected into the generatedMainActivity.kt. When Android relaunches us via the redirect intent, this forwards it throughsetIntent()soexpo-linking/expo-web-browsersee it viagetIntent()instead of the stale LAUNCHER intent. Plugin now importsexpo/config-plugins(re-exported byexpo, which IS a direct dep and resolvable from anywhere in the package) instead of@expo/config-plugins.Linkinglistener mounted inCloudAuthProvider(in addition to the/oauth/callbackroute) so cold-start redirects are consumed even before Expo Router has had a chance to navigate to the route.Linking.parse()inparseCallbackUrlinstead ofnew URL(), because the WHATWG URL parser in Hermes is unreliable for custom schemes in release builds (silently returnsnullfor present query params).waitForDeepLinkCallbackinsignIn— a short grace period after the browser session returnsdismiss, so a redirect-via-deep-link still completes the flow instead of rolling state straight back tosigned-out.completeCallbackUrlso the three concurrent handlers (browser-session result, global Linking listener, route's own handler) can't trample each other.<Redirect>-based navigation in/oauth/callback(rendered during the render phase) instead ofrouter.replaceinside an effect — bulletproof against the Expo-Router intent-handling race.Also dropped the
createTask: false/showInRecents: trueflags onopenAuthSessionAsync— they were meant to keep the Custom Tab in our task on Android 13+ release builds, but in dev mode they changed how the redirect URL was delivered (it ended up atLinkingrather than being captured by the auth session) and contributed to the route-stuck regression. TheonNewIntentoverride + global Linking listener already cover the release-build kill case.Test plan
pnpm --filter @electric-ax/agents-mobile typecheckpnpm exec expo prebuild --platform android --cleaninjectsonNewIntentintoMainActivity.ktpnpm exec expo config --jsonlists the plugin and exits 0