diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 61bc201467..71a3437ad2 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -94,9 +94,9 @@ export function useIntentHandler() { : null if (!channel) { Alert.alert('Error', 'No channel provided to look for.') - } else { - tryApplyUpdate(channel, appVersion) + return } + tryApplyUpdate(channel, appVersion) return } default: { diff --git a/src/lib/hooks/useOTAUpdates.test.ts b/src/lib/hooks/useOTAUpdates.test.ts index d174182a33..da0fe0fb56 100644 --- a/src/lib/hooks/useOTAUpdates.test.ts +++ b/src/lib/hooks/useOTAUpdates.test.ts @@ -4,11 +4,13 @@ import { fetchUpdateAsync, reloadAsync, setExtraParamAsync, + UpdateCheckResultNotAvailableReason, useUpdates, } from 'expo-updates' import {act, renderHook, waitFor} from '@testing-library/react-native' import {logger} from '#/logger' +import {APP_VERSION} from '#/env' import {device} from '#/storage' import { useApplyPullRequestOTAUpdate, @@ -22,6 +24,7 @@ jest.mock('expo-updates', () => ({ reloadAsync: jest.fn(), setExtraParamAsync: jest.fn(), UpdateCheckResultNotAvailableReason: { + NO_UPDATE_AVAILABLE_ON_SERVER: 'noUpdateAvailableOnServer', UPDATE_PREVIOUSLY_FAILED: 'updatePreviouslyFailed', }, useUpdates: jest.fn(), @@ -42,25 +45,156 @@ jest.mock('#/storage', () => ({ }, })) -const currentUpdate = { - channel: 'testflight', - emergencyLaunchReason: null, - isEmbeddedLaunch: false, - isEmergencyLaunch: false, - updateId: 'current-update', +/** + * `channel` here is the build-time constant baked into the native build, not the + * channel of the running bundle. `channel` is passed as the manifest metadata + * channel our update server stamps into every published update - omit it to + * simulate an embedded launch, which has no server manifest. + */ +function mockCurrentlyRunning({ + buildChannel = 'testflight', + channel, + updateId = 'current-update', +}: { + buildChannel?: string + channel?: string + updateId?: string +} = {}) { + const currentlyRunning = { + channel: buildChannel, + emergencyLaunchReason: null, + isEmbeddedLaunch: !channel, + isEmergencyLaunch: false, + updateId, + manifest: channel ? {id: updateId, metadata: {channel}} : undefined, + } + jest.mocked(useUpdates).mockReturnValue({ + currentlyRunning, + } as ReturnType) + return currentlyRunning } +const currentUpdate = {updateId: 'current-update'} + beforeEach(() => { jest.clearAllMocks() - jest.mocked(useUpdates).mockReturnValue({ - currentlyRunning: currentUpdate, - } as ReturnType) + mockCurrentlyRunning() jest.mocked(setExtraParamAsync).mockResolvedValue(undefined) jest.mocked(reloadAsync).mockResolvedValue(undefined) jest.spyOn(Alert, 'alert').mockImplementation(() => {}) }) describe('useApplyPullRequestOTAUpdate', () => { + it('detects a running PR deployment from the manifest metadata', () => { + mockCurrentlyRunning({ + buildChannel: 'testflight', + channel: 'pull-request-123', + }) + + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + expect(result.current.currentChannel).toBe('pull-request-123') + expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(true) + expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(true) + }) + + it('treats a standard downloaded update as a standard channel', () => { + mockCurrentlyRunning({buildChannel: 'testflight', channel: 'testflight'}) + + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + expect(result.current.currentChannel).toBe('testflight') + expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(false) + expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false) + }) + + it('falls back to the build channel for an embedded launch', () => { + mockCurrentlyRunning({buildChannel: 'testflight'}) + + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + expect(result.current.currentChannel).toBe('testflight') + expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false) + }) + + it('reports no channel when updates are disabled', () => { + mockCurrentlyRunning({buildChannel: ''}) + + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + expect(result.current.currentChannel).toBeUndefined() + expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false) + }) + + it('stays quiet when already running the latest of the requested channel', async () => { + mockCurrentlyRunning({ + buildChannel: 'testflight', + channel: 'pull-request-123', + }) + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: false, + reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123')) + + expect(Alert.alert).not.toHaveBeenCalled() + }) + + it('warns when no deployment is available for a different channel', async () => { + mockCurrentlyRunning({ + buildChannel: 'testflight', + channel: 'pull-request-123', + }) + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: false, + reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-456')) + + expect(Alert.alert).toHaveBeenCalledWith( + 'No Deployment Available', + expect.stringContaining('pull-request-456'), + ) + }) + + it('stays silent on a re-fired intent even when the app version differs', async () => { + mockCurrentlyRunning({ + buildChannel: 'testflight', + channel: 'pull-request-123', + }) + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: false, + reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0')) + + expect(Alert.alert).not.toHaveBeenCalled() + expect(fetchUpdateAsync).not.toHaveBeenCalled() + }) + + it('prompts to apply an available update when the app version matches', async () => { + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: true, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => + result.current.tryApplyUpdate('pull-request-123', APP_VERSION), + ) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Apply update from PR #123?', + expect.stringContaining('relaunch'), + expect.arrayContaining([expect.objectContaining({text: 'Apply'})]), + ) + }) + it('warns before applying an OTA built for a different app version', async () => { jest.mocked(checkForUpdateAsync).mockResolvedValue({ isAvailable: true, @@ -74,7 +208,6 @@ describe('useApplyPullRequestOTAUpdate', () => { await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0')) - expect(checkForUpdateAsync).not.toHaveBeenCalled() expect(Alert.alert).toHaveBeenCalledWith( 'App Version Mismatch', expect.stringContaining('Applying it anyway may cause'), diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index a7e043aeb2..7b7224bcb0 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -3,6 +3,7 @@ import {Alert, AppState, type AppStateStatus} from 'react-native' import {nativeBuildVersion} from 'expo-application' import { checkForUpdateAsync, + type CurrentlyRunningInfo, fetchUpdateAsync, isEnabled, reloadAsync, @@ -36,6 +37,32 @@ function getDeploymentName(channel: string) { return pullRequestNumber ? `PR #${pullRequestNumber}` : channel } +/** + * The channel of the update bundle that is actually running. The + * `currentlyRunning.channel` constant only reflects the channel baked into the + * native build config, so a manually applied deployment (e.g. a pull request + * channel) must be detected from the manifest metadata our update server stamps + * into every published update. Embedded launches have no server manifest and + * fall back to the build constant. + */ +function getRunningChannel( + currentlyRunning: CurrentlyRunningInfo | undefined, +): string | undefined { + /* + * `metadata` is typed as a bare `object` by expo-manifests, and is absent + * entirely from embedded manifests, so narrow it ourselves. + */ + const manifest = currentlyRunning?.manifest as + | {metadata?: {channel?: unknown}} + | undefined + const channel = manifest?.metadata?.channel + if (typeof channel === 'string' && channel) { + return channel + } + // The build constant is an empty string rather than null when unconfigured. + return currentlyRunning?.channel || undefined +} + async function setExtraParams() { await setExtraParamAsync( IS_IOS ? 'ios-build-number' : 'android-build-number', @@ -85,12 +112,12 @@ async function updateTestflight() { export function useApplyPullRequestOTAUpdate() { const {currentlyRunning} = useUpdates() const [pending, setPending] = useState(false) - const currentChannel = currentlyRunning?.channel + const currentChannel = getRunningChannel(currentlyRunning) const isCurrentlyRunningPullRequestDeployment = currentChannel?.startsWith('pull-request') /* * Covers pull request deployments as well as any other channel we manually - * applied an update from. Note that `channel` is null when updates are + * applied an update from. Note that the channel is undefined when updates are * disabled (e.g. in dev), in which case there's nothing to restore. */ const isCurrentlyRunningNonStandardChannel = Boolean( @@ -141,6 +168,12 @@ export function useApplyPullRequestOTAUpdate() { updateId: fetchedUpdate.manifest.id, }) try { + /* + * TODO: once expo-linking is upgraded to >= 57, enable this so the + * re-delivered initial URL doesn't trigger a redundant silent check + * after the reload. + */ + // Linking.clearInitialURL() await reloadAsync() } catch (e) { device.remove(['pendingOTAUpdate']) @@ -159,29 +192,35 @@ export function useApplyPullRequestOTAUpdate() { })() } - if (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.`, - [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Apply Anyway', - style: 'destructive', - onPress: applyUpdate, - }, - ], - ) - return - } - + /* + * Check before prompting about anything, so that re-running this while + * already on the newest update of `channel` stays silent. Reloading into an + * 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 (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.`, + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Apply Anyway', + style: 'destructive', + onPress: applyUpdate, + }, + ], + ) + return + } + Alert.alert( `Apply update from ${deploymentName}?`, 'The app will relaunch after the update is applied.', @@ -301,7 +340,7 @@ export function useOTAUpdates() { const ranInitialCheck = useRef(false) const timeout = useRef(undefined) const {currentlyRunning, isUpdatePending} = useUpdates() - const currentChannel = currentlyRunning?.channel + const currentChannel = getRunningChannel(currentlyRunning) const setCheckTimeout = useCallback(() => { timeout.current = setTimeout(async () => {