diff --git a/.kilo_workflow/learnings/maestro-concurrent-sessions-same-device.md b/.kilo_workflow/learnings/maestro-concurrent-sessions-same-device.md new file mode 100644 index 0000000000..da2f739a9e --- /dev/null +++ b/.kilo_workflow/learnings/maestro-concurrent-sessions-same-device.md @@ -0,0 +1,14 @@ +# concurrent Maestro sessions against the same device break flows + +Symptom: a Maestro flow fails mid-run with element-not-found or a hierarchy that belongs to the +other flow's screen, while a second `maestro test` targets the same device UDID (observed +2026-07-29 on login-ui-d051: a `logout.sh` run failed while another Maestro session against the +same simulator was active; the failure was self-inflicted, not a product defect). + +Cause: Maestro's per-device driver (XCUITest on iOS, uiautomator on Android) is single-tenant. +Two concurrent sessions fight over the same accessibility connection; taps and captures +interleave. + +Fix: never overlap two `maestro` processes against one device. Before starting a run, check +`ps aux | grep "maestro.*--device"` for the same UDID. If a flow fails inexplicably, first rule +out your own concurrent session before classifying anything as a product defect. diff --git a/.kilo_workflow/learnings/mobile-android-claim-race-at-visibility.md b/.kilo_workflow/learnings/mobile-android-claim-race-at-visibility.md new file mode 100644 index 0000000000..d21d6171e5 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-android-claim-race-at-visibility.md @@ -0,0 +1,13 @@ +# Android emulator claim race on shared adb + +Symptom: `pnpm dev:mobile:android claim emulator-5554` refused: "claimed by +/Users/igor/Projects/.worktrees/pr-review-d957" — for an emulator I had just launched myself. + +Cause: adb serials are host-global. The runbook order (launch → bounded boot wait → claim → +build) leaves a window between adb visibility and claim; a concurrent worktree's polling loop +claimed my fresh emulator at first visibility (claim record bootId matched my instance's +/proc/sys/kernel/random/boot_id exactly, claimedAt within seconds of first visibility). + +Fix: claim AT adb visibility (before waiting for sys.boot_completed). If refused because the +other worktree won the race, do NOT drive the device (never use a device claimed by another +worktree) and do NOT kill it either if your qemu owns it — boot a different AVD/serial instead. diff --git a/.kilo_workflow/learnings/mobile-android-dev-client-cold-start-deep-link.md b/.kilo_workflow/learnings/mobile-android-dev-client-cold-start-deep-link.md new file mode 100644 index 0000000000..efd6a88bd4 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-android-dev-client-cold-start-deep-link.md @@ -0,0 +1,20 @@ +# Android dev-client cold start after force-stop: deep link required, rebundle is slow + +Symptom: after `adb shell am force-stop com.kilocode.kiloapp`, relaunching with +`am start -n com.kilocode.kiloapp/.MainActivity` lands on the Expo dev-client launcher +("Development Build", server URL list) instead of the app; `monkey -p ... LAUNCHER 1` fails +outright. The app then shows a blank white screen with a single-node, zero-text uiautomator dump +for tens of seconds. + +Cause: the dev client needs the metro URL passed via the preflight deep link +(`exp+kilo-app://expo-development-client/?url=`, see +`apps/mobile/e2e/preflight.sh`). After force-stop the JS bundle is re-fetched and re-built by +metro: 25–65s on emulator on this machine (slower under load with two emulators). + +Fix: always relaunch via the deep link (and ensure `adb reverse tcp:$API_PORT` / `tcp:$METRO_PORT` +are set first). A blank white screen + zero-text dump within ~60s of a cold start means LOADING, +not a transparency defect — wait and re-dump before classifying. Contrast with the real iOS +transparency defect: static texts VISIBLE on screen while the interactive subtree is absent from +the a11y tree for 60s+ and only an app relaunch recovers it. Also note Maestro's `settle-app` +assertions in helper flows can time out inside this rebundle window — re-run the flow rather than +classifying the timeout. diff --git a/.kilo_workflow/learnings/mobile-android-edge-to-edge-ime-insets.md b/.kilo_workflow/learnings/mobile-android-edge-to-edge-ime-insets.md new file mode 100644 index 0000000000..da9a6965cd --- /dev/null +++ b/.kilo_workflow/learnings/mobile-android-edge-to-edge-ime-insets.md @@ -0,0 +1,33 @@ +# mobile/android: on API 35 the app window NEVER resizes for the IME — verify resize claims via dumpsys frames, not layout shifts + +Symptom (login-ui-d051 r0/r0b): with `android:windowSoftInputMode="adjustResize"` in the +generated manifest, the Android login screens showed zero layout shift for the email IME +but a uniform 373px shift for the OTP number pad — looking like "the OS resizes for one +IME but not the other". + +Cause: on API 35 the window carries `pfl=EDGE_TO_EDGE_ENFORCED` + `fl=LAYOUT_IN_SCREEN +LAYOUT_INSET_DECOR`, so `sim={adjust=resize}` is nominal only — the OS never shrinks the +window for any IME. The IME arrives purely as a `WindowInsets` source. All layout movement +is app-side: RN's `KeyboardAvoidingView behavior="height"` is inert (the ScrollView stays +full-height because the window frame never changes), and the only thing that moves content +is JS consuming `Keyboard` events — on the login screens that is the OTP form's +`bottomSpacer = keyboardHeight + 16` padding, which grew the form view by exactly 746px +(268dp + 16dp) under the number pad and re-centered the `justify-center` container by +spacer/2 = 373px. `keyboardDidShow` DOES fire with a real height on Android for the number +pad (268dp = IME height above the nav bar); a listener that attaches while the keyboard is +already up observes 0 and wrongly concludes the spacer is inert. + +How to verify (no code changes): + +- `adb shell dumpsys window windows` (the single-subcommand form — full `dumpsys window` + abbreviates per-window entries and omits `Frames:`/`mFullConfiguration`). Compare the + app's `winConfig={ mBounds / mAppBounds }` before/after IME: identical rects = no OS resize. +- `dumpsys window | grep "type=ime frame"` gives the authoritative keyboard top/height + (`InsetsSource id=3 type=ime frame=[0,top][w,h] visible=...`) — better than pixel scans. +- uiautomator container bounds arbitrate the app-side mechanism: full-height ScrollView + + grown form-view height = JS spacer padding; shrunk ScrollView = KAV height. + +Also: on the RN login ScrollView, any `input swipe`/drag blurs the focused field and hides +the IME (`keyboardShouldPersistTaps="handled"` governs taps only) — there is no +swipe-scroll-while-IME-up fallback on Android; and a tap on a keyboard-covered control +lands on the IME window and types into the focused field (app-side no-op, no navigation). diff --git a/.kilo_workflow/learnings/mobile-android-global-render-wedge.md b/.kilo_workflow/learnings/mobile-android-global-render-wedge.md new file mode 100644 index 0000000000..85948b3939 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-android-global-render-wedge.md @@ -0,0 +1,18 @@ +# Android app renders nothing (white) mid-session despite healthy Metro/API + +Symptom: dev client loads the bundle ("Running 'main'" in ReactNativeJS) but mounts zero RN +views — screen stays uniform white (native splash never hidden), uiautomator shows only +FrameLayout/ComposeView containers, no errors anywhere. Onset mid-session: Android login UI +rendered fine at 08:56 and 09:18, then every app start (any emulator, fresh VM, -wipe-data, +pm clear, Metro restart, dev-menu Reload, force-stop + deep link) rendered white from ~09:19 on, +while the iOS app kept rendering the same signed-out login branch through the same Metro/API. + +Not the cause (all eliminated): AVD disk state (-wipe-data did not help), app data (pm clear), +emulator instance (two AVDs), network (toybox nc to reversed 5300/10381 OK), API health (200s), +Metro process (restarted; serves android bundle 200 10.3MB to host curl). + +Suspected: serving-side dev-handshake/bundle state regression on a shared machine with a +concurrent verifier active ("Cannot connect to Expo CLI" seen in ReactNativeJS near a Metro +restart). If it recurs: capture `adb logcat -s ReactNativeJS` for the Expo CLI warning, curl +localhost:/status, and compare against an iOS control app before burning hours on +emulator-level recoveries — none of them work. diff --git a/.kilo_workflow/learnings/mobile-device-auth-ip-limit-shared-sections.md b/.kilo_workflow/learnings/mobile-device-auth-ip-limit-shared-sections.md new file mode 100644 index 0000000000..52cf1a8717 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-device-auth-ip-limit-shared-sections.md @@ -0,0 +1,20 @@ +# mobile: device-auth per-IP pending limit is machine-wide — and the holder may be STUCK rows, not live traffic + +Symptom: `POST /api/device-auth/codes?app=1 500` with +`Error: Too many pending authorization requests from this IP` (`src/lib/device-auth/device-auth.ts`), +app shows the error branch "Failed to start sign in. Please try again." — while the app, backend, +and network are all healthy. + +Cause: the limiter counts PENDING device-auth codes per IP, machine-wide (MAX 5), regardless of +`expires_at`. Two distinct holders can saturate it: live traffic from a concurrent section, or +STUCK rows abandoned by client-side-only `cancel()` (see +`mobile-device-auth-stuck-pending-rows.md`). Observed 2026-07-29 on login-ui-d051: 500s +08:37–10:44+ across two verifier rounds were first attributed to the live pr-review-d957 +section — WRONG; a read-only DB probe showed 5 rows stuck since 04:39–04:46, no live traffic +involved. Waiting NEVER clears stuck rows (no dev cleanup cron). + +Fix: probe the DB first (recipe in `mobile-device-auth-stuck-pending-rows.md`) — 5 pending rows +long past `expires_at` = stuck, reconcile via the real poll endpoint; recent rows = a live +holder, then treat `start()` calls as a scarce budget (<=3 per round) and classify the pending +branch as environment-blocked if 500s persist past one spaced recovery attempt. The error +branch rendering is itself a free branch-swap evidence point (probe it for full alpha). diff --git a/.kilo_workflow/learnings/mobile-device-auth-stuck-pending-rows.md b/.kilo_workflow/learnings/mobile-device-auth-stuck-pending-rows.md new file mode 100644 index 0000000000..fae34e7868 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-device-auth-stuck-pending-rows.md @@ -0,0 +1,26 @@ +# mobile: device-auth pending rows get STUCK after client cancel — probe the DB, reconcile via the real poll endpoint + +Symptom: every `POST /api/device-auth/codes?app=1` 500s with `Too many pending authorization +requests from this IP` and the app shows "Failed to start sign in" — and waiting any amount of +time (hours) never clears it, even after all concurrent sections end. + +Cause: the limiter counts `status='pending'` rows per IP regardless of `expires_at` +(`apps/web/src/lib/device-auth/device-auth.ts`, MAX 5). The app's `cancel()` is CLIENT-SIDE ONLY +(`use-device-auth.ts`: setState idle, no DELETE) and a killed/closed app never polls again, so +every abandoned code sits `pending` forever. Local dev runs no `cleanupExpiredDeviceAuthRequests` +cron. On 2026-07-29 the login-ui-d051 e2 rounds' 500s (attributed in +`mobile-device-auth-ip-limit-shared-sections.md` to the live pr-review-d957 section) were actually +held by 5 rows created 04:39-04:46 and expired by 04:56 — 6h stale; no live traffic involved. + +Cheap probe (no stack, no device, read-only): + docker exec dev-postgres-1 psql -U postgres -d postgres -c \ + "SELECT ip_address,status,count(*),max(expires_at) FROM device_auth_requests GROUP BY 1,2;" +5 pending whose `expires_at` is long past = stuck rows, not a live holder; the consent dialog's +domain ("Kilo Wants to Use to Sign In") names the limiter IP. + +Fix (product's own path, NOT a mock — the limiter still evaluates real DB state for every later +start): once the stuck codes are past expiry, poll each through the real endpoint on your own +stack: `curl http://localhost:/api/device-auth/codes/` → 410 expired; +`pollDeviceAuthRequest` flips the row to `expired`. Never UPDATE the table directly. Fresh codes +(<10min old) return 202 and stay pending — reconcile your own flip codes after expiry so the next +section does not inherit your wedge. diff --git a/.kilo_workflow/learnings/mobile-ios-device-auth-consent-sheet.md b/.kilo_workflow/learnings/mobile-ios-device-auth-consent-sheet.md new file mode 100644 index 0000000000..aec8de9c64 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-ios-device-auth-consent-sheet.md @@ -0,0 +1,18 @@ +# mobile: iOS device-auth start() opens an ASWebAuthenticationSession consent over the pending branch + +Symptom: after tapping `More sign-in options` on the iOS login screen, a system alert +`"Kilo" Wants to Use "" to Sign In` (Cancel/Continue) covers the app, and the +pending-with-code branch is not visible for pixel probes. + +Cause: `use-device-auth.ts` `start()` calls `WebBrowser.openAuthSessionAsync(verificationUrl)` on +iOS (ASWebAuthenticationSession); the consent alert precedes the auth sheet. e1/e2 never hit it +because their starts all 500'd (error branch renders before the browser call). + +Fix: tap `Cancel` index 0 on the consent — `openAuthSessionAsync` resolves as cancelled, NO app +state changes (the hook's own `cancel()` is not called), and the pending-with-code branch +("Your sign-in code:", `Sign in code: X X X X - X X X X`, "Open sign-in page in browser", +"Cancel sign in") stays rendered for probing. Then tap `Cancel sign in` to return to idle. +Probe discriminators: dark-glyph fraction on the big code text (full alpha ~0.19-0.21 with the +all-channels<100 threshold; parked ~50% alpha washes glyphs above 100 -> ~0.0) and on the +Open-in-browser button text (~0.036 full alpha). The muted "Your sign-in code:" heading scores +0.000 even when healthy (gray > 100/channel) — not a discriminator. diff --git a/.kilo_workflow/learnings/mobile-ios-text-size-slider-maestro-swipe.md b/.kilo_workflow/learnings/mobile-ios-text-size-slider-maestro-swipe.md new file mode 100644 index 0000000000..82eb1b83b7 --- /dev/null +++ b/.kilo_workflow/learnings/mobile-ios-text-size-slider-maestro-swipe.md @@ -0,0 +1,14 @@ +# iOS Settings text-size slider: reliable Maestro swipe technique + +Symptom: restoring Settings > Display & Brightness > Text Size from 100% (XXXL) back to the +default via Maestro swipe keeps failing — swipes from the right edge (91%/98% starts) and track +taps do not move the thumb. + +Cause: at 100% the slider thumb sits at roughly 78% of track width, not at the right edge; a swipe +that starts right of the thumb grabs empty track. Taps on the track do not reposition this slider. + +Fix: swipe with unquoted integer percentages (Maestro 2.7.0 rejects quoted values), starting ON +the thumb: `swipe: { start: 78%, 89% ... end: 10%, 89% }` style coordinates (adjust the y to the +slider row), then verify visually with a screenshot — the a11y tree does not expose the slider +value reliably. Same technique drives the slider up to 100% (start at the current thumb position, +e.g. 50% for the L default). diff --git a/.kilo_workflow/learnings/mobile-otp-outbox-race-parallel-phases.md b/.kilo_workflow/learnings/mobile-otp-outbox-race-parallel-phases.md new file mode 100644 index 0000000000..2a7067bd5d --- /dev/null +++ b/.kilo_workflow/learnings/mobile-otp-outbox-race-parallel-phases.md @@ -0,0 +1,14 @@ +# mobile: parallel device phases on one worktree email race the OTP outbox (latest code wins) + +Symptom (login-ui-d051 e2, 2026-07-29): an Android OTP Verify tap reached the backend but got +`POST /api/auth/native/token 401` with the correct-looking 6-digit code. Cause: an iOS Maestro +loop running CONCURRENTLY on the same worktree signed in with the same default email +(`e2e-mobile-@example.com`); its newer `POST /api/auth/native/otp` invalidated the +Android device's outstanding code before the Verify tap. The 401 is correct product behavior +for a stale code, not a tap failure. + +Fix: when two device phases share one worktree email, either (a) finish every OTP verify before +the next request-code on ANY device, (b) read the outbox code immediately before typing it and +confirm the newest outbox file's timestamp postdates your device's request, or (c) pass an +explicit different email to one phase (`login.sh `). Evidence the tap landed: +the 401 line in `pnpm dev:capture nextjs` — a covered/no-op tap produces NO request line. diff --git a/apps/mobile/src/components/login-screen.tsx b/apps/mobile/src/components/login-screen.tsx index a31d825a92..22adb0b24e 100644 --- a/apps/mobile/src/components/login-screen.tsx +++ b/apps/mobile/src/components/login-screen.tsx @@ -1,11 +1,24 @@ import * as Clipboard from 'expo-clipboard'; import { ExternalLink } from 'lucide-react-native'; import { useCallback, useEffect, useState } from 'react'; -import { ActivityIndicator, KeyboardAvoidingView, Platform, ScrollView, View } from 'react-native'; -import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; +import { + ActivityIndicator, + AppState, + Keyboard, + KeyboardAvoidingView, + type KeyboardEvent, + Platform, + ScrollView, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; import logo from '@/../assets/images/logo.png'; +import { + resolveAppAwareKeyboardPadding, + resolveKeyboardPaddingEventsForPlatform, +} from '@/components/kilo-chat/app-aware-keyboard-padding-state'; import { IdleAuth } from '@/components/login/idle-auth'; import { Button } from '@/components/ui/button'; import { Image } from '@/components/ui/image'; @@ -28,12 +41,18 @@ function errorMessage(status: string, fallback: string | undefined) { } } +function keyboardHeightFromEvent(event: KeyboardEvent): number { + return event.endCoordinates.height; +} + export function LoginScreen() { const { signIn } = useAuth(); const { status, token, code, error, verificationUrl, start, cancel, openBrowser } = useDeviceAuth(); const colors = useThemeColors(); + const insets = useSafeAreaInsets(); const [persistError, setPersistError] = useState(undefined); + const [androidKeyboardHeight, setAndroidKeyboardHeight] = useState(0); const persistToken = useCallback( async (tokenValue: string) => { @@ -54,6 +73,56 @@ export function LoginScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps -- persistToken is stable except for signIn identity; only re-run on a newly approved token }, [status, token]); + // Android shell keyboard pad: under API 35+ EDGE_TO_EDGE_ENFORCED the window + // never resizes for the IME, so KeyboardAvoidingView is inert. keyboardDidShow + // still fires with real heights; consume them here (r0b: zero layout shift for + // the email IME when only KAV was present). + useEffect(() => { + if (Platform.OS !== 'android') { + return undefined; + } + + const keyboardEvents = resolveKeyboardPaddingEventsForPlatform(Platform.OS); + if (keyboardEvents === null) { + setAndroidKeyboardHeight(0); + return undefined; + } + + const keyboardShowSubscription = Keyboard.addListener(keyboardEvents.show, event => { + setAndroidKeyboardHeight(current => + resolveAppAwareKeyboardPadding({ + currentPadding: current, + event: { + type: 'keyboard-visible', + keyboardHeight: keyboardHeightFromEvent(event), + }, + }) + ); + }); + const keyboardHideSubscription = Keyboard.addListener(keyboardEvents.hide, () => { + setAndroidKeyboardHeight(current => + resolveAppAwareKeyboardPadding({ + currentPadding: current, + event: { type: 'keyboard-hidden' }, + }) + ); + }); + const appStateSubscription = AppState.addEventListener('change', appState => { + setAndroidKeyboardHeight(current => + resolveAppAwareKeyboardPadding({ + currentPadding: current, + event: { type: 'app-state-change', appState }, + }) + ); + }); + + return () => { + keyboardShowSubscription.remove(); + keyboardHideSubscription.remove(); + appStateSubscription.remove(); + }; + }, []); + if (status === 'approved') { if (persistError) { return ( @@ -79,131 +148,125 @@ export function LoginScreen() { ); } + // RN 0.86 Android (ReactRootView.java) reports endCoordinates.height = + // imeInsets.bottom − barInsets.bottom (excludes the nav bar). endCoordinates.screenY + // is NOT the IME top under adjustResize, so full occlusion is + // endCoordinates.height + useSafeAreaInsets().bottom (= WindowInsets.ime().bottom; + // verified 704px + 63px = 767px on pixel9). Pad only when the keyboard is up so + // the resting layout is untouched. + const androidKeyboardPad = androidKeyboardHeight > 0 ? androidKeyboardHeight + insets.bottom : 0; + return ( - // Defect B / QB-A1: on small Android phones (e.g. kilo_small_phone_api35, - // 720x1280) the IME covers the primary submit button. The window is - // adjustResize but the outer ScrollView's automaticallyAdjustKeyboardInsets - // does not push the form up. Placing the KeyboardAvoidingView at the root - // gives it a window-relative frame (y ~ 0, height ~ screen height), so - // behavior="height" computes a non-zero shrink and resizes the ScrollView so - // the form stays above the IME. + // iOS: automaticallyAdjustKeyboardInsets only made the ScrollView scrollable, + // it never scrolls, and iOS only auto-reveals the focused field — so the + // centered form kept "Send code" under the keyboard on shorter devices + // (verified: iPhone 17 Pro, button centre 568pt vs keyboard window top 566pt, + // taps swallowed by UIRemoteKeyboardWindow). "padding" shrinks the ScrollView + // so the whole form re-centres in the space above the keyboard. // - // iOS needs it too: automaticallyAdjustKeyboardInsets only made the - // ScrollView scrollable, it never scrolls, and iOS only auto-reveals the - // focused field — so the centered form kept "Send code" under the keyboard - // on shorter devices (verified: iPhone 17 Pro, button centre 568pt vs - // keyboard window top 566pt, taps swallowed by UIRemoteKeyboardWindow). - // "padding" shrinks the ScrollView instead, so the whole form re-centres in - // the space above the keyboard; that replaces the inset, stacking both - // pushes the form off the top of the screen. - - + - - - Welcome to Kilo Code - + + + + Welcome to Kilo Code + - - {status === 'idle' && ( - - - - )} - - {status === 'pending' && code && ( - - - Your sign-in code: - - - {code} - - {/* Stack actions full-width so labels never clip side-by-side at max text */} - - + + + - - - - )} - - {status === 'pending' && !code && ( - - - - Starting sign in... - - - - )} - - {(status === 'denied' || status === 'expired' || status === 'error') && ( - - - {errorMessage(status, error)} - - - - )} - - + )} + + {(status === 'denied' || status === 'expired' || status === 'error') && ( + + + {errorMessage(status, error)} + + + + )} + + + ); } diff --git a/apps/mobile/src/components/login/email-otp-form.tsx b/apps/mobile/src/components/login/email-otp-form.tsx index 45fd847dc7..7071f8e213 100644 --- a/apps/mobile/src/components/login/email-otp-form.tsx +++ b/apps/mobile/src/components/login/email-otp-form.tsx @@ -1,30 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; -import { - ActivityIndicator, - AppState, - Keyboard, - type KeyboardEvent, - Platform, - TextInput, - View, -} from 'react-native'; +import { useRef, useState } from 'react'; +import { ActivityIndicator, TextInput, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { - resolveAppAwareKeyboardPadding, - resolveKeyboardPaddingEventsForPlatform, -} from '@/components/kilo-chat/app-aware-keyboard-padding-state'; import { type useNativeAuth } from '@/lib/auth/use-native-auth'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { canSubmitEmailCode } from './email-otp-state'; -const OTP_KEYBOARD_BREATHING_GAP = 16; - -function keyboardHeightFromEvent(event: KeyboardEvent): number { - return event.endCoordinates.height; -} - export function EmailOtpForm({ email, busy, @@ -41,67 +23,11 @@ export function EmailOtpForm({ const colors = useThemeColors(); const codeRef = useRef(''); const [hasCompleteCode, setHasCompleteCode] = useState(false); - const [keyboardHeight, setKeyboardHeight] = useState(0); const authBusy = busy !== undefined; - useEffect(() => { - const keyboardEvents = resolveKeyboardPaddingEventsForPlatform(Platform.OS); - if (keyboardEvents === null) { - setKeyboardHeight(0); - return undefined; - } - - const keyboardShowSubscription = Keyboard.addListener(keyboardEvents.show, event => { - setKeyboardHeight(current => - resolveAppAwareKeyboardPadding({ - currentPadding: current, - event: { - type: 'keyboard-visible', - keyboardHeight: keyboardHeightFromEvent(event), - }, - }) - ); - }); - const keyboardHideSubscription = Keyboard.addListener(keyboardEvents.hide, () => { - setKeyboardHeight(current => - resolveAppAwareKeyboardPadding({ - currentPadding: current, - event: { type: 'keyboard-hidden' }, - }) - ); - }); - const appStateSubscription = AppState.addEventListener('change', appState => { - setKeyboardHeight(current => - resolveAppAwareKeyboardPadding({ - currentPadding: current, - event: { type: 'app-state-change', appState }, - }) - ); - }); - - return () => { - keyboardShowSubscription.remove(); - keyboardHideSubscription.remove(); - appStateSubscription.remove(); - }; - }, []); - - // Only pad while the keyboard is up, so the resting (keyboard-hidden) layout - // is not shifted within the parent's justify-center container. - const bottomSpacer = keyboardHeight > 0 ? keyboardHeight + OTP_KEYBOARD_BREATHING_GAP : 0; - return ( - // Defect A / QB-16: on iOS at Dynamic Type XXXL, the number pad occludes - // the Verify / Resend / Back controls. We add a bottom spacer equal to the - // keyboard height (plus a small breathing gap) inside the scrollable - // content, so the parent ScrollView (with automaticallyAdjustKeyboardInsets - // on iOS, and the window-level behavior="height" KeyboardAvoidingView on - // Android) has enough scrollable room for the user to scroll the controls - // above the number pad. keyboardShouldPersistTaps="handled" on the parent - // lets the user tap the controls mid-scroll. We intentionally do NOT nest a - // KeyboardAvoidingView here — nested behavior="padding" KAVs compute ~0 - // bottom padding and do not help when content already overflows. - + // Keyboard avoidance is owned by the login shell in login-screen.tsx. + Enter the code sent to {email}