diff --git a/src/components/OTAChannelNotice.tsx b/src/components/OTAChannelNotice.tsx index b00f75f56c..44abf3df58 100644 --- a/src/components/OTAChannelNotice.tsx +++ b/src/components/OTAChannelNotice.tsx @@ -1,65 +1,231 @@ +import {useRef, useState} from 'react' import {type StyleProp, View, type ViewStyle} from 'react-native' import {Trans, useLingui} from '@lingui/react/macro' import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, useTheme, web} from '#/alf' import * as Admonition from '#/components/Admonition' -import {ButtonIcon, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as TextField from '#/components/forms/TextField' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' +import {IS_DEV, IS_INTERNAL, IS_NATIVE} from '#/env' /** - * Warns that the running bundle came from a channel this build doesn't normally - * receive updates from, e.g. a pull request deployment applied from the dev - * settings. Renders nothing on a standard channel, and never on web. + * Reports which OTA channel the running bundle came from. On a standard channel + * this is informational and only shown to internal builds, but a channel this + * build doesn't normally receive updates from - e.g. a pull request deployment + * applied from the dev settings or a deep link - is called out as a warning to + * everyone, alongside a way back to a standard build. Renders nothing on web, or + * in a dev build, where expo-updates is disabled and none of these actions can + * do anything. */ export function OTAChannelNotice({style}: {style?: StyleProp}) { const t = useTheme() const {t: l} = useLingui() + const switchChannelControl = Dialog.useDialogControl() + /* + * `pending` is shared by every action the hook exposes, so track which button + * started the work to avoid spinning all of them at once. + */ + const [activeAction, setActiveAction] = useState<'restore' | 'check'>() const { currentChannel, defaultChannel, isCurrentlyRunningNonStandardChannel, + isCurrentlyRunningPullRequestDeployment, restoreDefaultChannel, + checkForUpdates, + tryApplyUpdate, pending, } = useApplyPullRequestOTAUpdate() - if (!isCurrentlyRunningNonStandardChannel) return null + if (IS_DEV) return null + + if (!isCurrentlyRunningNonStandardChannel && !(IS_INTERNAL && IS_NATIVE)) { + return null + } + + /* + * Internal builds get the dev tooling regardless of which channel is running. + * Everyone else only ever sees the way back to a standard build. + */ + const showDevTools = IS_INTERNAL && IS_NATIVE return ( - - - - - - Non-standard OTA channel - - - - This app is running a deployment of{' '} - - {currentChannel} - - . Restore the {defaultChannel} deployment to get back to a - standard build. - - - - - - void restoreDefaultChannel()}> - - Restore default - - {pending && } - - - + <> + + + + + + {isCurrentlyRunningNonStandardChannel ? ( + <> + + Non-standard OTA channel + + + + This app is running a deployment of{' '} + + {currentChannel} + + . Restore the {defaultChannel} deployment to get back to a + standard build. + + + + ) : ( + <> + + OTA channel + + + + This app is receiving updates from{' '} + + {currentChannel ?? defaultChannel} + + . + + + + )} + + + {isCurrentlyRunningNonStandardChannel && ( + { + setActiveAction('restore') + void restoreDefaultChannel() + }}> + + Restore default + + {pending && activeAction === 'restore' && ( + + )} + + )} + {showDevTools && ( + <> + { + setActiveAction('check') + void checkForUpdates() + }}> + + Check for updates + + {pending && activeAction === 'check' && ( + + )} + + switchChannelControl.open()}> + + Switch channel + + + + )} + + + + + + {showDevTools && ( + void tryApplyUpdate(channel)} + /> + )} + + ) +} + +/** + * Prompts for an arbitrary OTA channel to apply an update from. Internal builds + * only - the channel is free text because it can be any deployment our update + * server knows about. + */ +function SwitchChannelDialog({ + control, + defaultChannel, + onSubmit, +}: { + control: Dialog.DialogControlProps + defaultChannel: string + onSubmit: (channel: string) => void +}) { + const {t: l} = useLingui() + const channel = useRef(defaultChannel) + + const onPressApply = () => { + const value = channel.current.trim() + if (!value) return + /* + * `onSubmit` shows native alerts of its own, which race with the sheet's + * close animation if fired before it finishes. + */ + control.close(() => onSubmit(value)) + } + + return ( + + + + + + Switch OTA channel + + + + Channel + + + (channel.current = value)} + accessibilityLabelledBy="ota-channel-label" + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + spellCheck={false} + /> + + + + + + ) } diff --git a/src/lib/hooks/useOTAUpdates.test.ts b/src/lib/hooks/useOTAUpdates.test.ts index 871cac0e63..ccd0577b55 100644 --- a/src/lib/hooks/useOTAUpdates.test.ts +++ b/src/lib/hooks/useOTAUpdates.test.ts @@ -299,6 +299,74 @@ describe('useApplyPullRequestOTAUpdate', () => { expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate']) expect(result.current.pending).toBe(false) }) + + describe('checkForUpdates', () => { + it('offers to relaunch into a newer update of the running channel', async () => { + mockCurrentlyRunning({ + buildChannel: 'testflight', + channel: 'pull-request-123', + }) + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: true, + } as Awaited>) + jest.mocked(fetchUpdateAsync).mockResolvedValue({ + isNew: true, + isRollBackToEmbedded: false, + manifest: {id: 'new-update'}, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.checkForUpdates()) + + // The running channel is checked, not the build's default channel. + expect(setExtraParamAsync).toHaveBeenCalledWith( + 'channel', + 'pull-request-123', + ) + expect(Alert.alert).toHaveBeenCalledWith( + 'Update Available', + expect.stringContaining('PR #123'), + expect.arrayContaining([expect.objectContaining({text: 'Relaunch'})]), + ) + + const buttons = jest.mocked(Alert.alert).mock.calls[0][2] + act(() => buttons?.[1].onPress?.()) + + await waitFor(() => expect(reloadAsync).toHaveBeenCalled()) + expect(result.current.pending).toBe(false) + }) + + it('reports being up to date on a standard channel', async () => { + mockCurrentlyRunning({buildChannel: 'testflight', channel: 'testflight'}) + jest.mocked(checkForUpdateAsync).mockResolvedValue({ + isAvailable: false, + reason: + UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER, + } as Awaited>) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.checkForUpdates()) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Up to Date', + expect.stringContaining('testflight'), + ) + expect(fetchUpdateAsync).not.toHaveBeenCalled() + }) + + it('informs the user when the check fails', async () => { + jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline')) + const {result} = renderHook(() => useApplyPullRequestOTAUpdate()) + + await act(() => result.current.checkForUpdates()) + + expect(Alert.alert).toHaveBeenCalledWith( + 'Update Check Failed', + expect.stringContaining('Error: offline'), + ) + expect(result.current.pending).toBe(false) + }) + }) }) describe('useOTAUpdateRecovery', () => { diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 180102c2cd..19f1a7af8a 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -260,6 +260,64 @@ export function useApplyPullRequestOTAUpdate() { } } + /** + * Checks the channel that is currently running for a newer update, and offers + * to relaunch into it if one is found. Unlike `tryApplyUpdate` this never + * switches channels, so it's safe to run from a non-standard deployment. + */ + const checkForUpdates = async () => { + const channel = currentChannel ?? DEFAULT_CHANNEL + const deploymentName = getDeploymentName(channel) + + setPending(true) + try { + if (isCurrentlyRunningNonStandardChannel) { + await setExtraParamsPullRequest(channel) + } else { + await setExtraParams() + } + + const res = await checkForUpdateAsync() + if (!res.isAvailable) { + Alert.alert( + 'Up to Date', + `You're already running the newest available update of ${deploymentName}.`, + ) + return + } + + await fetchUpdateAsync() + Alert.alert( + 'Update Available', + `A newer update of ${deploymentName} has been downloaded. Relaunch now?`, + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: () => { + void reloadAsync({ + reloadScreenOptions: splash(t.scheme), + }) + }, + }, + ], + ) + } catch (e: unknown) { + 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) + } + } + /** * Pulls the newest update from the channel this build ships with and relaunches * into it, undoing a manually applied deployment. @@ -291,6 +349,7 @@ export function useApplyPullRequestOTAUpdate() { return { tryApplyUpdate, + checkForUpdates, restoreDefaultChannel, isCurrentlyRunningPullRequestDeployment, isCurrentlyRunningNonStandardChannel, diff --git a/src/lib/hooks/useOTAUpdates.web.ts b/src/lib/hooks/useOTAUpdates.web.ts index 970deebf1b..a53b84aef9 100644 --- a/src/lib/hooks/useOTAUpdates.web.ts +++ b/src/lib/hooks/useOTAUpdates.web.ts @@ -6,6 +6,7 @@ export function useApplyPullRequestOTAUpdate() { _channel: string, _declaredAppVersion?: string | null, ) => {}, + checkForUpdates: async () => {}, restoreDefaultChannel: async () => {}, isCurrentlyRunningPullRequestDeployment: false, isCurrentlyRunningNonStandardChannel: false,