Skip to content

Instantly share code, notes, and snippets.

@fractaledmind
Last active June 8, 2026 11:07
Show Gist options
  • Select an option

  • Save fractaledmind/434e8987a285957cc5bcb326981d39a0 to your computer and use it in GitHub Desktop.

Select an option

Save fractaledmind/434e8987a285957cc5bcb326981d39a0 to your computer and use it in GitHub Desktop.
OTA Updates Simplification — Implementation Plan (mobile-rn / Expo Updates)

OTA Updates Simplification — Implementation Plan

Owner: Stephen Margheim Scope: mobile-rn only. Replace the custom OTA orchestration service with a near-defaults Expo Updates setup, and finish the user-visible OTA control surface started in #1891 (merged) + #1927 (open). No backend, no infra changes. Linear: CORE-2252 Motivation: A production OTA published on 2026-06-07 failed to apply on a TestFlight build despite multiple restarts and a ~2-minute background gap. Root cause is bespoke logic that no other Expo team uses; see § Background. The fix also closes the loop on user-visible OTA state — making it trivial for a user (or support staff) to see what version they're on and force an update.


Overview

This plan removes ~145 lines of custom OTA orchestration in favour of Expo's documented useUpdates() hook plus a thin wrapper for the one piece Expo doesn't give us out of the box (critical-update gating). The current setup was built to solve a real problem — "don't reload mid-OTP-paste" — but the chosen mechanism (a 2-minute background timer before applying) is undocumented, untestable from the user side, and silently broken for the kill-and-reopen case.

Goals

  1. Apply OTAs reliably without user-visible silent failures.
  2. Replace the 2-minute timer heuristic with explicit user agency.
  3. Surface OTA state to the user — they should always be able to see what bundle they're on and trivially trigger a check + update. (Already half-shipped in #1891 + #1927.)
  4. Preserve the critical-update overlay path (regulatory/security holdback story).
  5. Cut the surface area: drop the bespoke status machine, drop the parallel Zustand store, drop the manual checkForUpdate orchestration.
  6. Stay close to documented Expo behaviour so future Expo SDK upgrades don't surprise us.

Non-goals

  • Changing the runtime-version policy this PR. The static-prod / fingerprint-staging split is called out as follow-up work in § Follow-ups.
  • Changing CI's runtime-bump automation.
  • Changing how OTAs are published, branched, or channeled.

Background

The incident that prompted this

On 2026-06-07 11:51 AM, OTA 94d687f1 (commit 8832dc8, branch release-v3.30, runtime 46) was published to both the production and production-v3.30 channels. Stephen, on TestFlight build 3.30.0 (1382) running runtime 46 update 019e9772 from 2026-06-05, was unable to receive it after:

  • Killing and reopening the app ~5 times
  • Backgrounding for ~2 minutes
  • Checking the Expo dashboard (which showed only 2 downloads cluster-wide)

Diagnosed cause: the custom OTA service only applies a downloaded update when the app transitions background → foreground with ≥ 120,000 ms between the two. Kill-and-reopen produces a fresh process with backgroundedAt = null, so the gate is never satisfied. A background gap of "about 2 minutes" sits right on the threshold and likely missed by milliseconds.

Why this matters beyond one incident

The same failure mode silently affects every user. There's no UI, log, or analytics event that fires when the gate denies a reload. We don't know how many users are perpetually one OTA behind because of this.

Related in-flight work

This plan composes with two PRs already opened against the same Linear ticket (CORE-2252):

  • #1891 — Show runtime + OTA version in Account footer (merged). Adds getRuntimeVersion, getOTAUpdateId, isEmbeddedLaunch, getOTACreatedAt wrappers in deviceInfoService, and renders the RUNTIME: 46 · 019e9772 · 2026-06-05 line under the existing app version in ContactUsFooter. Read-only diagnostics — gives users + support staff the data needed to answer "what bundle am I running?". This is the line visible in the TestFlight screenshot from the 2026-06-07 incident.

  • #1927 — Add OTAUpdateButton for manual OTA checks (open). Renders a Tertiary button below the version rows that triggers a check/download cycle without waiting for the next foreground transition. Adds lastCheckOutcome: 'none' | 'available' | 'error' to otaStore so the button can report "up to date" / "ready to apply" / "failed". Built on top of the current service architecture — needs rebasing onto the new useUpdates()-based design (see § Migration).

Together they answer the two requirements that came out of today's incident debrief:

"It should be easy for a user to know whether they are on the most-up-to-date OTA version or not, and it should be easy for them to get up-to-date manually."

The simplification plan in this doc completes that story by making the manual-update path the canonical way to apply non-critical updates ahead of the next cold start — replacing the broken 2-minute background gate.

Research summary

Across Expo's official docs, the UpdatesAPIDemo repo, blog posts from companies running production Expo apps, and GitHub/Discord discussion, the 2-minute background gate is not a pattern used by anyone else. The canonical patterns are:

  • Apply silently on next cold start (Expo default behaviour).
  • Apply immediately on launch behind a brief "updating" overlay.
  • Apply on the next AppState: active transition (with no time gate).
  • Show a non-blocking "Update ready, tap to restart" banner.

References — see § References.


Current state

File inventory

File Lines Role
mobile-rn/app/services/ota_updates/service.ts 146 Custom check → download → gated-apply state machine
mobile-rn/app/services/ota_updates/types.ts 1 OTAUpdateStatus enum (idle | checking | downloading | ready | error)
mobile-rn/app/services/ota_updates/index.ts 2 Barrel re-export
mobile-rn/app/stores/otaStore.ts 43 Zustand store mirroring service state
mobile-rn/app/utils/getCriticalIndex.ts 18 Read criticalIndex from manifest extras
mobile-rn/app/components/CriticalOTAOverlay.tsx 104 Blocking overlay for critical updates
mobile-rn/config/initializers/ota.ts 10 Boot-time initializeOTA() call
mobile-rn/app/routes/_layout.tsx (excerpt) Mounts <CriticalOTAOverlay>; reads store
mobile-rn/tests/services/ota_updates/service.test.ts 242 Service unit tests

Total OTA-specific surface: ~566 lines (excluding tests).

Current behaviour (precise)

  1. App cold start → config/initializers/ota.ts runs initializeOTA() once.
  2. service.start() subscribes to onForeground / onBackground (delegated to appLifecycleTracker), then synchronously runs check().
  3. check():
    • Bails if __DEV__, already checking, or Updates.isEmergencyLaunch.
    • Status → checking.
    • await Updates.checkForUpdateAsync(). If !update.isAvailable → status idle.
    • Computes critical = availableCriticalIndex > currentCriticalIndex. Sets store flag.
    • Status → downloading. await Updates.fetchUpdateAsync(). Status → ready.
    • On any error: silently swallowed; status → idle. (No analytics, no logs.)
  4. On background → records backgroundedAt = Date.now().
  5. On foreground:
    • If isUpdateReady() && backgroundDurationMs ≥ 120_000Updates.reloadAsync().
    • Else if isUpdateReady() → no-op (silent skip — this is the gap).
    • Else → run check() again.
  6. <CriticalOTAOverlay> renders when isCritical && status ∈ {checking, downloading, ready}. User taps "Restart" → applyDownloadedUpdate()Updates.reloadAsync().

app.config.js:104-108

runtimeVersion:
  APP_ENV === 'production'
    ? '46' // Production: static version, auto-bumped by CI when native code changes
    : { policy: 'fingerprint' },
updates: {
  url: 'https://u.expo.dev/4cf9cec0-523d-4591-bc5b-6269dd64ee66',
  fallbackToCacheTimeout: 0,
  checkAutomatically: 'NEVER', // Manual check via useOTAUpdates hook
},

extra.criticalIndex: 2 is also defined in app.config.js, exposed to OTAs via manifest extras.

What works and what doesn't

Behaviour Status
Critical-update overlay (security/forced updates) ✅ Works as intended
Non-critical OTA delivery on kill-and-reopen ❌ Never applies (process restart bypasses the time gate; also nothing on disk if fetch didn't complete before kill)
Non-critical OTA delivery on background ≥ 2 min ⚠️ Works if download completed and gap is strictly > 120 s
Non-critical OTA delivery on background < 2 min ❌ Silently skipped
Failure visibility (analytics / logs) ❌ Catch block swallows all errors
Mid-session OTP-paste reload protection ✅ Works (this was the original motivation)

Proposed state

Configuration changes (app.config.js)

Switch to Expo defaults:

updates: {
  url: 'https://u.expo.dev/4cf9cec0-523d-4591-bc5b-6269dd64ee66',
  // Removed: fallbackToCacheTimeout (0 is the default)
  // Removed: checkAutomatically (ON_LOAD is the default — see Expo Updates SDK docs)
},

This means: on every cold start, Expo's native module checks the server in the background, downloads any available update, and the new bundle becomes active on the next cold start. This is the path that 95% of Expo apps use (Expo Download Updates docs).

New service shape

Replace service.ts with a small hook driven by Expo's useUpdates(), plus a thin requestOTACheck() function for the manual button to share an in-flight guard with the foreground re-check. Sketch:

// mobile-rn/app/services/ota_updates/useOTAUpdates.ts
import { useEffect } from 'react';
import {
  useUpdates,
  checkForUpdateAsync,
  fetchUpdateAsync,
} from 'expo-updates';

import { useOTAStore } from '~/stores/otaStore';
import { getCriticalIndex } from '~/utils/getCriticalIndex';
import { onForeground } from '~/services/appLifecycleTracker';

let inFlight: Promise<void> | null = null;

/** Shared by foreground re-check and the manual button. Idempotent. */
export function requestOTACheck(): Promise<void> {
  if (__DEV__) return Promise.resolve();
  if (inFlight) return inFlight;

  const store = useOTAStore.getState();
  inFlight = (async () => {
    try {
      const res = await checkForUpdateAsync();
      if (!res.isAvailable) {
        store.setLastCheckOutcome('none');
        return;
      }
      await fetchUpdateAsync();
      store.setLastCheckOutcome('available');
    } catch (err) {
      store.setLastCheckOutcome('error');
      reportToSentry('ota_check_failed', err);
    } finally {
      inFlight = null;
    }
  })();
  return inFlight;
}

export function useOTAUpdates(): void {
  const { currentlyRunning, availableUpdate, downloadError, checkError } = useUpdates();

  // Drive the critical flag from manifest extras whenever an update is available.
  useEffect(() => {
    if (!availableUpdate) {
      useOTAStore.getState().setIsCritical(false);
      return;
    }
    const currentIdx = getCriticalIndex(currentlyRunning.manifest) ?? 0;
    const nextIdx = getCriticalIndex(availableUpdate.manifest) ?? 0;
    useOTAStore.getState().setIsCritical(nextIdx > currentIdx);
  }, [availableUpdate, currentlyRunning]);

  // Re-check when the app comes back to the foreground after a real background gap.
  useEffect(() => onForeground(() => void requestOTACheck()), []);

  // Observability — surface previously-silent failures.
  useEffect(() => {
    if (checkError) reportToSentry('ota_check_failed', checkError);
    if (downloadError) reportToSentry('ota_download_failed', downloadError);
  }, [checkError, downloadError]);
}
// mobile-rn/app/stores/otaStore.ts (simplified)
export interface OTAState {
  isCritical: boolean;
  lastCheckOutcome: 'none' | 'available' | 'error' | null;
}

Notes:

  • useUpdates() from expo-updates@29 exposes isUpdateAvailable, isUpdatePending, availableUpdate, downloadError, checkError, currentlyRunning reactively. This replaces our hand-rolled OTAUpdateStatus enum entirely.
  • isUpdatePending = true means a new bundle is downloaded and will load on next reload — the manual button uses this to switch its label from "Check for updates" to "Restart to apply".
  • The first check is now done automatically by expo-updates on cold start (because checkAutomatically defaults to ON_LOAD). The hook only adds the foreground re-check + the manual entry point.
  • requestOTACheck is the single funnel for non-cold-start checks. Both the foreground listener and OTAUpdateButton call it, sharing an inFlight guard — same pattern PR #1927 implements on the current service.
  • The Zustand store reduces to { isCritical, lastCheckOutcome }. The idle | checking | downloading | ready | error enum is gone — replaced by useUpdates() reactive fields for transient state and lastCheckOutcome for the post-check summary the button needs.

Apply behaviour

Recommendation: Default + Critical + Manual. Three independent paths, each with a clear trigger:

1. Default path (non-critical OTAs). Expo's standard behaviour:

  • On every cold start, expo-updates checks the server in the background (because checkAutomatically: 'ON_LOAD' is restored), downloads any new bundle, and applies it on the next cold start.
  • On AppState: active (foreground after real background), the new hook also calls checkForUpdateAsync + fetchUpdateAsync so backgrounded apps don't go stale for days.
  • No mid-session reload. Ever. The OTP-paste protection that motivated the 120 s gate is now structural, not heuristic — we simply never reload mid-session unless the user asks us to.

2. Critical path (criticalIndex bumped). Unchanged from today:

  • <CriticalOTAOverlay> blocks UI; downloads; "Restart" button → reloadAsync().
  • The only mid-session reload path. Reserved for security/regulatory issues where interrupting is the point.

3. Manual path (user-initiated, from PR #1927). The third path closes the visibility + agency gap:

  • The Account footer (under the runtime/OTA version rows from #1891) renders an OTAUpdateButton.
  • Tap → requestOTACheck() → updates lastCheckOutcome to one of:
    • none → "You're up to date" (no update available)
    • available → button label changes to "Restart to apply" → tap → reloadAsync()
    • error → "Couldn't check for updates" (with a retry affordance)
  • Replaces the silent download-and-wait of the old service with a visible, debuggable flow that staff can ask users to trigger when investigating a session.

This is the right answer because:

  • Visibility: Combined with the version rows from #1891, every user can answer "am I on the latest?" without help. Today they can't.
  • Agency: Users who want the latest fix right now can get it without killing the app. Staff debugging a session can ask "tap that button and tell me what it says."
  • No surprise reloads: Non-critical updates never interrupt; the user opts into the restart. OTP flows are safe by construction.
  • No bespoke heuristics: The 120 s gate is gone. There is nothing magical about when an update applies — it's either "next cold start" (default), "user tap" (manual), or "criticalIndex says now" (critical).

Rejected alternative: a passive "Update ready, tap to restart" banner. The Account-screen button is a strictly better fit — it lives next to the version rows users would already look at, and it doubles as a debugging tool. We don't need both.

Files to delete

  • mobile-rn/app/services/ota_updates/service.ts (replaced by useOTAUpdates.ts + requestOTACheck)
  • mobile-rn/app/services/ota_updates/types.ts (OTAUpdateStatus enum replaced by useUpdates() state)
  • mobile-rn/config/initializers/ota.ts (no longer needed — useUpdates() is rendered inside RootLayoutInner)
  • mobile-rn/tests/services/ota_updates/service.test.ts (242 lines — replaced by ~80 lines of hook tests)

Files to modify

  • mobile-rn/app.config.js — remove checkAutomatically and fallbackToCacheTimeout keys from updates.
  • mobile-rn/app/services/ota_updates/index.ts — re-export useOTAUpdates, requestOTACheck instead of initializeOTA / applyDownloadedUpdate.
  • mobile-rn/app/stores/otaStore.ts — strip down to { isCritical, lastCheckOutcome }. The status / setStatus / OTAUpdateStatus references go away.
  • mobile-rn/app/components/CriticalOTAOverlay.tsx — change status: OTAUpdateStatus prop to a boolean isReady: boolean (we no longer have the 'checking' / 'downloading' distinction; isUpdatePending is the only state that matters for the overlay).
  • mobile-rn/app/routes/_layout.tsx — call useOTAUpdates(); render <CriticalOTAOverlay> based on isCritical && isUpdatePending.
  • mobile-rn/app/features/account/hooks/useOTAUpdateControl.ts (from #1927) — replace its read of otaStore.status with useUpdates() fields (isUpdateAvailable, isUpdatePending); keep its requestOTACheck invocation; keep its label/helper-text logic driven by lastCheckOutcome.
  • mobile-rn/app/features/account/components/OTAUpdateButton.tsx (from #1927) — no API change, only prop names if the control hook's return type shifts.

Files to keep unchanged

  • mobile-rn/app/utils/getCriticalIndex.ts — manifest-extras reader, still useful.
  • mobile-rn/app/services/appLifecycleTracker.tsonForeground subscription model is independently useful and battle-tested.
  • mobile-rn/app/services/deviceInfoService.ts — runtime/OTA version wrappers from #1891 are unaffected.
  • mobile-rn/app/features/account/components/ContactUsFooter.tsx — the runtime/OTA version rows (#1891) and the button mount (#1927) are unchanged at the component level.
  • mobile-rn/eas.json — channels and runtime config are unchanged.

Migration plan

The sequencing question is whether to land #1927 as-written (on the current service) and refactor afterwards, or rebase #1927 onto the new hook architecture and ship them together. Recommendation: rebase #1927 — its conceptual additions (button, control hook, lastCheckOutcome, in-flight guard) survive the refactor unchanged; only the underlying service plumbing it touches goes away.

PR 1 — Add the new hook, behind a feature flag (depends on nothing)

Risk: low. Read-only addition.

  1. Add mobile-rn/app/services/ota_updates/useOTAUpdates.ts + requestOTACheck (sketch above).
  2. Add lastCheckOutcome to otaStore (matches the field #1927 adds — port it over verbatim with the same name + values for downstream-PR continuity).
  3. Add a tiny client feature flag ota_v2_enabled (default: false). When true, call useOTAUpdates() from _layout.tsx; when false, keep initializeOTA() from the initializer.
  4. Add observability: Sentry.captureMessage calls for checkError and downloadError. (Closes the silent-failure gap that hid today's incident.)
  5. Tests for the new hook — useUpdates() is mockable via jest.mock('expo-updates', …) (the same mock #1891 added in jest.setup.ts).

Ship and verify on staging by flipping the flag for internal builds.

PR 2 — Rebase #1927 onto the new hook (depends on PR 1)

Risk: low. UI-only change; net-new surface.

  1. Rebase feat/CORE-2252-account-ota-update-button onto the PR 1 branch.
  2. Rewrite useOTAUpdateControl.ts to consume useUpdates() fields + useOTAStore selectors instead of the old otaStore.status enum.
  3. The component (OTAUpdateButton.tsx), tests, and ContactUsFooter mount point land essentially as-is.
  4. Behind ota_v2_enabled, the button works against the new hook. When the flag is off, the button hides (or shows disabled with a tooltip).

This lets us ship the user-visible control surface even while the apply-behaviour change is still gated on staging.

PR 3 — Switch defaults; remove the gate (depends on PRs 1 & 2)

Risk: medium. Behavioural change in prod.

  1. Flip ota_v2_enabled default to true.
  2. Remove checkAutomatically: 'NEVER' and fallbackToCacheTimeout: 0 from app.config.js.
  3. Bump the runtime version (this is a native config change — required by Expo for app.config.js changes that affect updates.*).
  4. Submit a new TestFlight build (3.30.X3.31.0 or runtime 4647 per CI's bump policy).

Verify on TestFlight before promoting to prod. The manual button from PR 2 is the verification tool — tap it, confirm lastCheckOutcome updates, confirm Restart to apply reloads into the new bundle.

PR 4 — Delete the dead code (depends on PR 3 being stable)

Risk: zero (only runs after PR 3 is live and stable for ~1 week).

  1. Delete service.ts, types.ts, the boot initializer, and the old service.test.ts.
  2. Delete the ota_v2_enabled flag and its dead branch.
  3. Strip any vestigial OTAUpdateStatus references.

Behavioural before/after

Scenario Today After
User opens app, OTA available, no critical bump Download starts, status → ready, never applies unless backgrounded ≥ 2 min Download starts in background, applies on next cold start. User can also force-apply via Account → "Check for updates" button.
User opens app, OTA available, critical bump <CriticalOTAOverlay> blocks UI; user taps Restart Same — <CriticalOTAOverlay> blocks UI; user taps Restart
User backgrounds for 30 s then returns Download triggers if not done; no apply (gate not met) Foreground triggers checkForUpdateAsync re-check; no apply mid-session
User backgrounds for 5 min then returns Apply fires if downloaded No apply mid-session (deliberate — protects OTP flows)
User kills and reopens app Apply only via Expo's native launch logic (if bundle was on disk pre-kill) Apply on every cold start once download lands. Cleaner cache, no gate.
Mid-session OTP paste Safe (gate prevents reload) Safe (we never reload mid-session for non-critical)
checkForUpdateAsync fails Silent Sentry breadcrumb + error reported; button surfaces lastCheckOutcome: 'error' to user
User wants to know "am I on the latest?" No way to tell Account footer shows RUNTIME: 46 · 019e9772 · … (shipped in #1891) and the manual button reports "up to date" / "ready to apply"
Support staff debugging a user session "Have you restarted the app?" "Tap Check for updates and tell me what it says"

Risks

R1 — Non-critical OTAs take one extra cold start to apply

Impact: A copy fix published Monday morning is seen by an active user on Tuesday morning when they re-open the app.

Mitigation: This is acceptable for the >95% of OTAs that are non-critical (copy, layout, analytics). The criticalIndex mechanism remains the escape hatch for "must apply now."

If unacceptable: ship Option B (banner) instead.

R2 — Some active users are on builds older than runtime 46

Impact: Unrelated to this change, but worth noting — runtime-version compatibility is unaffected by this refactor.

Mitigation: No change to runtime policy in this PR. See § Follow-ups.

R3 — useUpdates() API differences between Expo SDK versions

Impact: We're on expo-updates@29.0.16 (SDK 54), which has stable useUpdates() semantics. A future SDK bump could rename fields.

Mitigation: The hook is small enough (~30 lines) that field renames are trivial to follow. Worse than current setup? No — Updates.checkForUpdateAsync is the same API that backs both implementations.

R4 — Observability gap during migration

Impact: We have no data today on how often the 120 s gate denies reloads, so we can't quantify the improvement.

Mitigation: Add Sentry events for checkError and downloadError in PR 1, and emit a one-time analytics event when isUpdatePending transitions true. Compare OTA adoption curves on the dashboard before/after PR 2.

R5 — app.config.js change requires a binary rebuild

Impact: Removing checkAutomatically from updates is a native config change, not an OTA-able JS change.

Mitigation: Standard runtime-version bump + new TestFlight build. Coordinate with the next planned binary release; no need for an extraordinary out-of-cycle build.


Open questions

  1. Land #1927 as-written first, or rebase onto the new architecture? Recommendation: rebase. The plumbing #1927 touches (service.checkForUpdate, otaStore.status) is going away — landing it on the doomed service means rewriting the same code twice. Rebasing keeps the user-facing diff (button + control hook + tests) intact while sidestepping the deleted layer.
  2. Keep appLifecycleTracker integration? The new hook can either subscribe to our tracker (filters inactive blips) or to RN's AppState directly. Recommend tracker — consistent with the rest of the codebase and the inactive-blip filter is genuinely useful.
  3. Delete otaStore entirely? With { isCritical, lastCheckOutcome } as the only state, it could live inside the hook via useState. But the button hook (useOTAUpdateControl) needs to read lastCheckOutcome from outside the useOTAUpdates mount point, which is awkward without a store. Recommend keeping the store at its reduced size.
  4. Button placement. #1927 puts it in ContactUsFooter below the version rows. Confirm this is where we want it long-term, or whether it deserves a dedicated row higher up.

Follow-ups (out of scope for this PR set)

Unify runtime-version policy across envs

app.config.js:96-99 uses static '46' in production and { policy: 'fingerprint' } in staging. Per the Expo runtime-versions docs, both work, but mixing means staging compatibility doesn't predict production compatibility — a native dep bump that shifts the staging fingerprint without bumping the static prod version can land an OTA on a prod binary it wasn't tested against.

Recommendation: move to policy: 'fingerprint' everywhere, with CI gating that fails if the published OTA's fingerprint doesn't match a currently-shipped binary. Separate PR.

Add OTA adoption dashboard

We have no observability on OTA adoption rate (how quickly a published update reaches X% of installs). The Expo dashboard's "downloads" count is the only signal. With the new hook we can fire an analytics event on isUpdatePending → true and another on next cold-start launch — giving us a real adoption curve.


References

Expo official docs

Production patterns (third-party)

Caveats / known issues

  • expo/expo#16264reloadAsync() from an AppState foreground listener has historically had iOS edge cases after long backgrounding. Confirms that some foreground gating is defensible, but not the 120 s heuristic.

Internal references

  • Linear: CORE-2252
  • In-flight PRs:
  • Current implementation: mobile-rn/app/services/ota_updates/service.ts, mobile-rn/app/stores/otaStore.ts, mobile-rn/config/initializers/ota.ts, mobile-rn/app/components/CriticalOTAOverlay.tsx, mobile-rn/app.config.js:96-108.
  • Lifecycle tracker (preserve as-is): mobile-rn/app/services/appLifecycleTracker.ts.
  • Manifest reader (preserve as-is): mobile-rn/app/utils/getCriticalIndex.ts.
  • Device-info wrappers (preserve as-is, shipped in #1891): mobile-rn/app/services/deviceInfoService.ts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment