Skip to content

Fix Android cloud auth callback - #4416

Merged
kevin-dp merged 10 commits into
mainfrom
samwilli/android-cloud-signin-callback
May 27, 2026
Merged

kevin-dp merged 10 commits into
mainfrom
samwilli/android-cloud-signin-callback

Conversation

@samwillis

@samwillis samwillis commented May 26, 2026 •

Copy link
Copy Markdown
Contributor

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 main and merge it ASAP, then update the CI branch from main instead 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. openAuthSessionAsync would return dismiss even though sign-in had actually succeeded, Linking.getInitialURL() returned null, 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:

  • The Expo config plugin imported @expo/config-plugins, which pnpm doesn't hoist into agents-mobile's node_modules. Local prebuild worked via expo's own require context, but in EAS Build's expo cli config --json step the require failed and the entire Android preview build was rejected with exit code 1.
  • The /oauth/callback route navigated with router.replace inside a useEffect. That raced with Expo Router's own intent handling in dev builds — the route would mount, the cloud-auth state would flip to signed-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)

  1. Native onNewIntent override via a local config plugin (plugins/with-android-on-new-intent.js) injected into the generated MainActivity.kt. When Android relaunches us via the redirect intent, this forwards it through setIntent() so expo-linking / expo-web-browser see it via getIntent() instead of the stale LAUNCHER intent. Plugin now imports expo/config-plugins (re-exported by expo, which IS a direct dep and resolvable from anywhere in the package) instead of @expo/config-plugins.
  2. Global Linking listener mounted in CloudAuthProvider (in addition to the /oauth/callback route) so cold-start redirects are consumed even before Expo Router has had a chance to navigate to the route.
  3. Linking.parse() in parseCallbackUrl instead of new URL(), because the WHATWG URL parser in Hermes is unreliable for custom schemes in release builds (silently returns null for present query params).
  4. waitForDeepLinkCallback in signIn — a short grace period after the browser session returns dismiss, so a redirect-via-deep-link still completes the flow instead of rolling state straight back to signed-out.
  5. Idempotent completeCallbackUrl so the three concurrent handlers (browser-session result, global Linking listener, route's own handler) can't trample each other.
  6. <Redirect>-based navigation in /oauth/callback (rendered during the render phase) instead of router.replace inside an effect — bulletproof against the Expo-Router intent-handling race.

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.

Test plan

  • pnpm --filter @electric-ax/agents-mobile typecheck
  • pnpm exec expo prebuild --platform android --clean injects onNewIntent into MainActivity.kt
  • pnpm exec expo config --json lists the plugin and exits 0
  • EAS Android preview build succeeds (CI)
  • Real-device sign-in completes (Google + GitHub) on Android 13+
  • Emulator sign-in completes via dev client

@codecov

codecov Bot commented May 26, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.63%. Comparing base (c1834f3) to head (aa60fce).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

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     
Flag Coverage Δ
electric-telemetry 68.93% <ø> (?)
elixir 75.63% <ø> (?)
elixir-client 79.32% <ø> (?)
packages/agents ?
packages/agents-mcp ?
packages/agents-runtime ?
packages/agents-server ?
packages/agents-server-ui ?
packages/electric-ax ?
packages/experimental ?
packages/react-hooks ?
packages/start ?
packages/typescript-client ?
packages/y-electric ?
typescript ?
unit-tests 75.63% <ø> (+15.65%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@samwillis
samwillis force-pushed the samwilli/android-cloud-signin-callback branch from 768343e to be27c33 Compare May 26, 2026 16:10
@samwillis
samwillis changed the base branch from main to samwilli/agents-mobile-ci May 26, 2026 16:10
@netlify

netlify Bot commented May 26, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for electric-next ready!

Name Link
🔨 Latest commit aa60fce
🔍 Latest deploy log https://app.netlify.com/projects/electric-next/deploys/6a16dec1d2ed0d0007c02357
😎 Deploy Preview https://deploy-preview-4416--electric-next.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@samwillis
samwillis force-pushed the samwilli/android-cloud-signin-callback branch 2 times, most recently from 92773d8 to f727e37 Compare May 26, 2026 16:16
@github-actions

github-actions Bot commented May 26, 2026 •

Copy link
Copy Markdown
Contributor

Electric Agents Mobile Build

Android preview build for commit aa60fce.

Platform Profile Status Build
Android preview Passed EAS build

Workflow run

@samwillis
samwillis marked this pull request as ready for review May 27, 2026 06:43
)
}

if (coldStartOAuthCallback) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@kevin-dp
kevin-dp force-pushed the samwilli/agents-mobile-ci branch from 7d1a177 to d16c028 Compare May 27, 2026 09:30
Base automatically changed from samwilli/agents-mobile-ci to main May 27, 2026 09:37
samwillis and others added 10 commits May 27, 2026 14:05
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>
@kevin-dp
kevin-dp force-pushed the samwilli/android-cloud-signin-callback branch from 26de7f8 to aa60fce Compare May 27, 2026 12:08
@pkg-pr-new

pkg-pr-new Bot commented May 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@electric-sql/react@4416
npm i https://pkg.pr.new/@electric-sql/client@4416
npm i https://pkg.pr.new/@electric-sql/y-electric@4416

commit: aa60fce

@kevin-dp
kevin-dp merged commit 3a7cafd into main May 27, 2026
37 checks passed
@kevin-dp
kevin-dp deleted the samwilli/android-cloud-signin-callback branch May 27, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants