handle fingerprint ota previews and request lifecycles

This commit is contained in:
Samuel Newman
2026-09-05 18:56:00 +03:00
parent badce43161
commit 50752603ce
5 changed files with 424 additions and 38 deletions
+3 -1
View File
@@ -92,11 +92,13 @@ export function useIntentHandler() {
releaseVersion && buildNumber
? `${releaseVersion}.${buildNumber}`
: null
const sourceCommit = params.get('sourceCommit')
const publicationId = params.get('publicationId')
if (!channel) {
Alert.alert('Error', 'No channel provided to look for.')
return
}
tryApplyUpdate(channel, appVersion)
tryApplyUpdate(channel, appVersion, {sourceCommit, publicationId})
return
}
default: {
+207 -2
View File
@@ -1,4 +1,4 @@
import {Alert} from 'react-native'
import {Alert, AppState} from 'react-native'
import {
checkForUpdateAsync,
fetchUpdateAsync,
@@ -13,15 +13,24 @@ import {logger} from '#/logger'
import {APP_VERSION} from '#/env'
import {device} from '#/storage'
import {
checkForOTAUpdate,
prepareOTAUpdateRequest,
useApplyPullRequestOTAUpdate,
useOTAUpdateRecovery,
useOTAUpdates,
} from './useOTAUpdates'
let mockRuntimeVersion = '1.133.0'
jest.mock('expo-updates', () => ({
channel: 'testflight',
checkForUpdateAsync: jest.fn(),
fetchUpdateAsync: jest.fn(),
isEnabled: true,
reloadAsync: jest.fn(),
get runtimeVersion() {
return mockRuntimeVersion
},
setExtraParamAsync: jest.fn(),
UpdateCheckResultNotAvailableReason: {
NO_UPDATE_AVAILABLE_ON_SERVER: 'noUpdateAvailableOnServer',
@@ -61,10 +70,12 @@ function mockCurrentlyRunning({
buildChannel = 'testflight',
channel,
updateId = 'current-update',
isUpdatePending = false,
}: {
buildChannel?: string
channel?: string
updateId?: string
isUpdatePending?: boolean
} = {}) {
const currentlyRunning = {
channel: buildChannel,
@@ -76,6 +87,7 @@ function mockCurrentlyRunning({
}
jest.mocked(useUpdates).mockReturnValue({
currentlyRunning,
isUpdatePending,
} as ReturnType<typeof useUpdates>)
return currentlyRunning
}
@@ -84,13 +96,30 @@ const currentUpdate = {updateId: 'current-update'}
beforeEach(() => {
jest.clearAllMocks()
mockRuntimeVersion = '1.133.0'
mockCurrentlyRunning()
jest.mocked(setExtraParamAsync).mockResolvedValue(undefined)
jest.mocked(reloadAsync).mockResolvedValue(undefined)
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({status: 'available', deployments: []}),
})
})
describe('useApplyPullRequestOTAUpdate', () => {
it('rejects manually applying a non-PR channel', async () => {
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('testflight'))
expect(Alert.alert).toHaveBeenCalledWith(
'Invalid Deployment',
expect.stringContaining('Only pull request'),
)
expect(checkForUpdateAsync).not.toHaveBeenCalled()
})
it('detects a running PR deployment from the manifest metadata', () => {
mockCurrentlyRunning({
buildChannel: 'testflight',
@@ -209,7 +238,7 @@ describe('useApplyPullRequestOTAUpdate', () => {
isNew: true,
isRollBackToEmbedded: false,
manifest: {id: 'mismatched-update'},
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
} as unknown as Awaited<ReturnType<typeof fetchUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
@@ -231,6 +260,103 @@ describe('useApplyPullRequestOTAUpdate', () => {
})
})
it('does not offer an app-version override to fingerprint clients', async () => {
mockRuntimeVersion = 'a'.repeat(40)
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
expect(Alert.alert).toHaveBeenCalledWith(
'Apply update from PR #123?',
expect.stringContaining('relaunch'),
expect.arrayContaining([expect.objectContaining({text: 'Apply'})]),
)
expect(Alert.alert).not.toHaveBeenCalledWith(
'App Version Mismatch',
expect.anything(),
expect.anything(),
)
})
it.each([
['runtime-mismatch', 'Different Native Build Required'],
['not-published', 'No Deployment Available'],
['stale-link', 'Deployment Link Is Out of Date'],
] as const)('handles the %s diagnostic status', async (status, title) => {
mockRuntimeVersion = 'a'.repeat(40)
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({status, deployments: []}),
})
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
expect(Alert.alert).toHaveBeenCalledWith(title, expect.any(String))
expect(checkForUpdateAsync).not.toHaveBeenCalled()
})
it('stays quiet when diagnostics report the deployment already running', async () => {
mockRuntimeVersion = 'a'.repeat(40)
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({status: 'already-running', deployments: []}),
})
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
expect(Alert.alert).not.toHaveBeenCalled()
expect(checkForUpdateAsync).not.toHaveBeenCalled()
expect(result.current.pending).toBe(false)
})
it('allows only one PR confirmation prompt at a time', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
await act(() => result.current.tryApplyUpdate('pull-request-456'))
expect(Alert.alert).toHaveBeenCalledTimes(1)
expect(result.current.pending).toBe(true)
})
it('rejects a fetched deployment whose source does not match the link', async () => {
mockRuntimeVersion = 'a'.repeat(40)
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
jest.mocked(fetchUpdateAsync).mockResolvedValue({
isNew: true,
isRollBackToEmbedded: false,
manifest: {id: 'new-update', extra: {ota: {sourceCommit: 'newer'}}},
} as unknown as Awaited<ReturnType<typeof fetchUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() =>
result.current.tryApplyUpdate('pull-request-123', null, {
sourceCommit: 'expected',
}),
)
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
void act(() => buttons?.[1].onPress?.())
await waitFor(() =>
expect(Alert.alert).toHaveBeenLastCalledWith(
'Update Failed',
expect.stringContaining('deployment changed'),
),
)
expect(reloadAsync).not.toHaveBeenCalled()
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
})
it('informs the user when checking for an OTA fails', async () => {
jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline'))
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
@@ -297,8 +423,87 @@ describe('useApplyPullRequestOTAUpdate', () => {
updateId: 'new-update',
})
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
expect(result.current.pending).toBe(false)
})
it('restores the default request after cancellation', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
await act(() => result.current.tryApplyUpdate('pull-request-123'))
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
await act(() => buttons?.[0].onPress?.())
await waitFor(() =>
expect(setExtraParamAsync).toHaveBeenLastCalledWith(
'channel',
'testflight',
),
)
expect(result.current.pending).toBe(false)
})
})
describe('useOTAUpdates', () => {
it('does not reload a pending default update while a PR prompt owns the request', async () => {
let appStateListener: ((state: string) => Promise<void>) | undefined
jest
.spyOn(AppState, 'addEventListener')
.mockImplementation((_, listener) => {
appStateListener = listener as (state: string) => Promise<void>
return {remove: jest.fn()}
})
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: true,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
mockCurrentlyRunning({isUpdatePending: true})
const apply = renderHook(() => useApplyPullRequestOTAUpdate())
renderHook(() => useOTAUpdates())
await act(() => apply.result.current.tryApplyUpdate('pull-request-123'))
jest
.spyOn(Date, 'now')
.mockReturnValueOnce(0)
.mockReturnValue(16 * 60e3)
await act(async () => {
await appStateListener?.('background')
await appStateListener?.('active')
})
expect(reloadAsync).not.toHaveBeenCalled()
})
})
describe('OTA request preparation', () => {
it('sets the native build number before the channel', async () => {
await prepareOTAUpdateRequest('pull-request-123')
expect(setExtraParamAsync).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/^(ios|android)-build-number$/),
expect.any(String),
)
expect(setExtraParamAsync).toHaveBeenNthCalledWith(
2,
'channel',
'pull-request-123',
)
})
it('prepares the default channel before checking', async () => {
jest.mocked(checkForUpdateAsync).mockResolvedValue({
isAvailable: false,
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
await checkForOTAUpdate()
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
expect(checkForUpdateAsync).toHaveBeenCalledTimes(1)
})
})
describe('useOTAUpdateRecovery', () => {
+203 -32
View File
@@ -7,12 +7,14 @@ import {
} from 'react-native'
import {nativeBuildVersion} from 'expo-application'
import {
channel as nativeChannel,
checkForUpdateAsync,
type CurrentlyRunningInfo,
fetchUpdateAsync,
isEnabled,
reloadAsync,
type ReloadScreenOptions,
runtimeVersion as nativeRuntimeVersion,
setExtraParamAsync,
UpdateCheckResultNotAvailableReason,
useUpdates,
@@ -31,7 +33,42 @@ const OTA_RECOVERY_WINDOW = 5 * 60e3
* The channel this native build is expected to receive updates from. Anything
* else is only reachable through the dev tooling in settings.
*/
const DEFAULT_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
const DEFAULT_CHANNEL =
nativeChannel || (IS_TESTFLIGHT ? 'testflight' : 'production')
const FINGERPRINT_RUNTIME_VERSION = /^[0-9a-f]{40}$/
const OTA_RESOLVE_URL = 'https://updates.bsky.app/v1/ota/resolve'
type OTAResolveResult = {
status:
| 'available'
| 'already-running'
| 'runtime-mismatch'
| 'not-published'
| 'stale-link'
deployments: {
runtimeVersion: string
sourceCommit?: string
publicationId?: string
updateId?: string
}[]
}
let requestQueue = Promise.resolve()
let manualRequestOwner: symbol | undefined
function isManualRequestPending() {
return manualRequestOwner !== undefined
}
function serializeOTARequest<T>(operation: () => Promise<T>): Promise<T> {
const result = requestQueue.then(operation, operation)
requestQueue = result.then(
() => undefined,
() => undefined,
)
return result
}
/**
* Channels that our native builds are configured with, see `eas.json`. An
@@ -69,17 +106,7 @@ function getRunningChannel(
return currentlyRunning?.channel || undefined
}
async function setExtraParams() {
await setExtraParamAsync(
IS_IOS ? 'ios-build-number' : 'android-build-number',
// Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
// This just ensures it gets passed as a string
`${nativeBuildVersion}`,
)
await setExtraParamAsync('channel', DEFAULT_CHANNEL)
}
async function setExtraParamsPullRequest(channel: string) {
export async function prepareOTAUpdateRequest(channel = DEFAULT_CHANNEL) {
await setExtraParamAsync(
IS_IOS ? 'ios-build-number' : 'android-build-number',
// Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
@@ -89,12 +116,55 @@ async function setExtraParamsPullRequest(channel: string) {
await setExtraParamAsync('channel', channel)
}
async function updateTestflight(scheme: 'light' | 'dark') {
await setExtraParams()
export function checkForOTAUpdate(channel = DEFAULT_CHANNEL) {
return serializeOTARequest(async () => {
await prepareOTAUpdateRequest(channel)
return checkForUpdateAsync()
})
}
const res = await checkForUpdateAsync()
export function fetchOTAUpdate(channel = DEFAULT_CHANNEL) {
return serializeOTARequest(async () => {
await prepareOTAUpdateRequest(channel)
return fetchUpdateAsync()
})
}
async function resolvePullRequestDeployment({
channel,
currentUpdateId,
expected,
}: {
channel: string
currentUpdateId?: string
expected?: {sourceCommit?: string | null; publicationId?: string | null}
}): Promise<OTAResolveResult | undefined> {
if (!FINGERPRINT_RUNTIME_VERSION.test(nativeRuntimeVersion || '')) {
return undefined
}
const query = new URLSearchParams({
channel,
platform: IS_IOS ? 'ios' : 'android',
runtimeVersion: nativeRuntimeVersion!,
})
if (currentUpdateId) query.set('currentUpdateId', currentUpdateId)
if (expected?.sourceCommit) {
query.set('sourceCommit', expected.sourceCommit)
}
if (expected?.publicationId) {
query.set('publicationId', expected.publicationId)
}
const response = await fetch(`${OTA_RESOLVE_URL}?${query}`)
if (!response.ok) throw new Error(`OTA resolve failed (${response.status})`)
return response.json() as Promise<OTAResolveResult>
}
async function updateTestflight(scheme: 'light' | 'dark') {
const res = await checkForOTAUpdate()
if (res.isAvailable) {
await fetchUpdateAsync()
await fetchOTAUpdate()
Alert.alert(
'Update Available',
'A new version of the app is available. Relaunch now?',
@@ -121,6 +191,7 @@ export function useApplyPullRequestOTAUpdate() {
const t = useTheme()
const {currentlyRunning} = useUpdates()
const [pending, setPending] = useState(false)
const requestOwnerRef = useRef<symbol>(undefined)
const currentChannel = getRunningChannel(currentlyRunning)
const isCurrentlyRunningPullRequestDeployment =
currentChannel?.startsWith('pull-request')
@@ -133,15 +204,73 @@ export function useApplyPullRequestOTAUpdate() {
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
)
useEffect(() => {
return () => {
if (manualRequestOwner === requestOwnerRef.current) {
manualRequestOwner = undefined
void serializeOTARequest(() => prepareOTAUpdateRequest())
}
}
}, [])
const tryApplyUpdate = async (
channel: string,
declaredAppVersion?: string | null,
expected?: {sourceCommit?: string | null; publicationId?: string | null},
) => {
if (!/^pull-request-[1-9]\d*$/.test(channel)) {
Alert.alert(
'Invalid Deployment',
'Only pull request deployments can be applied manually.',
)
return
}
if (manualRequestOwner) return
const requestOwner = Symbol(channel)
manualRequestOwner = requestOwner
requestOwnerRef.current = requestOwner
setPending(true)
const deploymentName = getDeploymentName(channel)
let resolvedDeployment: OTAResolveResult | undefined
const checkForDeployment = async () => {
await setExtraParamsPullRequest(channel)
const res = await checkForUpdateAsync()
try {
resolvedDeployment = await resolvePullRequestDeployment({
channel,
currentUpdateId: currentlyRunning?.updateId,
expected,
})
} catch (err) {
logger.debug('Could not resolve OTA deployment diagnostics', {
safeMessage: err,
})
}
if (resolvedDeployment?.status === 'already-running') return false
if (resolvedDeployment?.status === 'runtime-mismatch') {
Alert.alert(
'Different Native Build Required',
`The ${deploymentName} deployment requires a different native build. Install a compatible TestFlight build and try again.`,
)
return false
}
if (resolvedDeployment?.status === 'not-published') {
Alert.alert(
'No Deployment Available',
`The ${deploymentName} deployment has not been published or is no longer available.`,
)
return false
}
if (resolvedDeployment?.status === 'stale-link') {
Alert.alert(
'Deployment Link Is Out of Date',
`This link does not refer to the latest ${deploymentName} deployment. Open the newest link and try again.`,
)
return false
}
const res = await checkForOTAUpdate(channel)
if (!res.isAvailable) {
if (
res.reason ===
@@ -161,16 +290,50 @@ export function useApplyPullRequestOTAUpdate() {
return res.isAvailable
}
const finishManualRequest = async (restoreDefault: boolean) => {
if (manualRequestOwner !== requestOwner) return
manualRequestOwner = undefined
requestOwnerRef.current = undefined
if (restoreDefault) {
await serializeOTARequest(() => prepareOTAUpdateRequest()).catch(
() => {},
)
}
setPending(false)
}
const restoreAfterCancellation = () => {
void finishManualRequest(true)
}
const applyUpdate = () => {
setPending(true)
void (async () => {
let reloadSucceeded = false
try {
if (!(await checkForDeployment())) return
const fetchedUpdate = await fetchUpdateAsync()
const fetchedUpdate = await fetchOTAUpdate(channel)
if (!fetchedUpdate.isNew) {
throw new Error('Expo did not download a new update.')
}
const manifest = fetchedUpdate.manifest as {
extra?: {
ota?: {sourceCommit?: unknown; publicationId?: unknown}
}
}
const sourceCommit = manifest.extra?.ota?.sourceCommit
const publicationId = manifest.extra?.ota?.publicationId
if (
(expected?.sourceCommit &&
sourceCommit !== expected.sourceCommit) ||
(expected?.publicationId &&
publicationId !== expected.publicationId)
) {
throw new Error(
'The deployment changed while it was being downloaded. Check the link again to review the newest deployment.',
)
}
device.set(['pendingOTAUpdate'], {
attemptedAt: Date.now(),
channel,
@@ -180,6 +343,7 @@ export function useApplyPullRequestOTAUpdate() {
await reloadAsync({
reloadScreenOptions: splash(t.scheme),
})
reloadSucceeded = true
} catch (e) {
device.remove(['pendingOTAUpdate'])
throw e
@@ -192,7 +356,7 @@ export function useApplyPullRequestOTAUpdate() {
`Could not apply the ${deploymentName} deployment: ${error}`,
)
} finally {
setPending(false)
await finishManualRequest(!reloadSucceeded)
}
})()
}
@@ -203,11 +367,17 @@ export function useApplyPullRequestOTAUpdate() {
* update re-delivers the deep link that triggered it, and the same link may
* also just be tapped again.
*/
setPending(true)
try {
if (!(await checkForDeployment())) return
if (!(await checkForDeployment())) {
await finishManualRequest(true)
return
}
if (declaredAppVersion && declaredAppVersion !== APP_VERSION) {
if (
!FINGERPRINT_RUNTIME_VERSION.test(nativeRuntimeVersion || '') &&
declaredAppVersion &&
declaredAppVersion !== APP_VERSION
) {
Alert.alert(
'App Version Mismatch',
`This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`,
@@ -215,6 +385,7 @@ export function useApplyPullRequestOTAUpdate() {
{
text: 'Cancel',
style: 'cancel',
onPress: restoreAfterCancellation,
},
{
text: 'Apply Anyway',
@@ -233,6 +404,7 @@ export function useApplyPullRequestOTAUpdate() {
{
text: 'Cancel',
style: 'cancel',
onPress: restoreAfterCancellation,
},
{
text: 'Apply',
@@ -242,14 +414,13 @@ export function useApplyPullRequestOTAUpdate() {
],
)
} catch (e: unknown) {
await finishManualRequest(true)
const error = String(e)
logger.error('Internal OTA Update Error', {error})
Alert.alert(
'Update Check Failed',
`Could not check the ${deploymentName} deployment: ${error}`,
)
} finally {
setPending(false)
}
}
@@ -260,10 +431,9 @@ export function useApplyPullRequestOTAUpdate() {
const restoreDefaultChannel = async () => {
setPending(true)
try {
await setExtraParams()
const res = await checkForUpdateAsync()
const res = await checkForOTAUpdate()
if (res.isAvailable) {
await fetchUpdateAsync()
await fetchOTAUpdate()
await reloadAsync()
} else {
Alert.alert(
@@ -351,14 +521,14 @@ export function useOTAUpdates() {
const setCheckTimeout = useCallback(() => {
timeout.current = setTimeout(async () => {
try {
await setExtraParams()
if (isManualRequestPending()) return
logger.debug('Checking for update...')
const res = await checkForUpdateAsync()
const res = await checkForOTAUpdate()
if (res.isAvailable) {
logger.debug('Attempting to fetch update...')
await fetchUpdateAsync()
await fetchOTAUpdate()
} else {
logger.debug('No update available.')
}
@@ -419,6 +589,7 @@ export function useOTAUpdates() {
// If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since
// chances are that there isn't anything important going on in the current session.
if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
if (isManualRequestPending()) return
if (isUpdatePending) {
await reloadAsync({
reloadScreenOptions: splash(t.scheme),
+4
View File
@@ -5,6 +5,10 @@ export function useApplyPullRequestOTAUpdate() {
tryApplyUpdate: async (
_channel: string,
_declaredAppVersion?: string | null,
_expected?: {
sourceCommit?: string | null
publicationId?: string | null
},
) => {},
restoreDefaultChannel: async () => {},
isCurrentlyRunningPullRequestDeployment: false,
+7 -3
View File
@@ -4,7 +4,11 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useMutation, useQuery} from '@tanstack/react-query'
import {splash} from '#/lib/hooks/useOTAUpdates'
import {
checkForOTAUpdate,
fetchOTAUpdate,
splash,
} from '#/lib/hooks/useOTAUpdates'
import {useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
@@ -25,7 +29,7 @@ export function OTAInfo() {
} = useQuery({
queryKey: ['ota-info'],
queryFn: async () => {
const status = await Updates.checkForUpdateAsync()
const status = await checkForOTAUpdate()
return status.isAvailable
},
})
@@ -33,7 +37,7 @@ export function OTAInfo() {
const {mutate: fetchAndLaunchUpdate, isPending: isPendingUpdate} =
useMutation({
mutationFn: async () => {
await Updates.fetchUpdateAsync()
await fetchOTAUpdate()
await Updates.reloadAsync({
reloadScreenOptions: splash(t.scheme),
})