improve ota channel notice in drawer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-07-31 20:17:54 +03:00
parent f99a17bb6e
commit b8a6b6afda
4 changed files with 334 additions and 40 deletions
+206 -40
View File
@@ -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<ViewStyle>}) {
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 (
<Admonition.Outer
type="warning"
style={[t.atoms.bg_contrast_25, a.gap_sm, style]}>
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content style={[a.gap_2xs]}>
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
<Trans>Non-standard OTA channel</Trans>
</Text>
<Admonition.Text>
<Trans>
This app is running a deployment of{' '}
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
{currentChannel}
</Text>
. Restore the {defaultChannel} deployment to get back to a
standard build.
</Trans>
</Admonition.Text>
</Admonition.Content>
</Admonition.Row>
<View style={[a.flex_row]}>
<Admonition.Button
color="secondary_inverted"
label={l`Restore the ${defaultChannel} deployment`}
disabled={pending}
onPress={() => void restoreDefaultChannel()}>
<ButtonText>
<Trans>Restore default</Trans>
</ButtonText>
{pending && <ButtonIcon icon={Loader} />}
</Admonition.Button>
</View>
</Admonition.Outer>
<>
<Admonition.Outer
type={isCurrentlyRunningNonStandardChannel ? 'warning' : 'info'}
style={[t.atoms.bg_contrast_25, style]}>
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<View style={[a.gap_2xs]}>
{isCurrentlyRunningNonStandardChannel ? (
<>
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
<Trans>Non-standard OTA channel</Trans>
</Text>
<Admonition.Text>
<Trans>
This app is running a deployment of{' '}
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
{currentChannel}
</Text>
. Restore the {defaultChannel} deployment to get back to a
standard build.
</Trans>
</Admonition.Text>
</>
) : (
<>
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
<Trans>OTA channel</Trans>
</Text>
<Admonition.Text>
<Trans>
This app is receiving updates from{' '}
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
{currentChannel ?? defaultChannel}
</Text>
.
</Trans>
</Admonition.Text>
</>
)}
</View>
<View style={[a.flex_row, a.flex_wrap, a.gap_sm]}>
{isCurrentlyRunningNonStandardChannel && (
<Admonition.Button
color="secondary_inverted"
label={l`Restore the ${defaultChannel} deployment`}
disabled={pending}
onPress={() => {
setActiveAction('restore')
void restoreDefaultChannel()
}}>
<ButtonText>
<Trans>Restore default</Trans>
</ButtonText>
{pending && activeAction === 'restore' && (
<ButtonIcon icon={Loader} />
)}
</Admonition.Button>
)}
{showDevTools && (
<>
<Admonition.Button
color="secondary"
label={l`Check for OTA updates`}
disabled={pending}
onPress={() => {
setActiveAction('check')
void checkForUpdates()
}}>
<ButtonText>
<Trans>Check for updates</Trans>
</ButtonText>
{pending && activeAction === 'check' && (
<ButtonIcon icon={Loader} />
)}
</Admonition.Button>
<Admonition.Button
color="secondary"
label={l`Switch OTA channel`}
disabled={pending}
onPress={() => switchChannelControl.open()}>
<ButtonText>
<Trans>Switch channel</Trans>
</ButtonText>
</Admonition.Button>
</>
)}
</View>
</Admonition.Content>
</Admonition.Row>
</Admonition.Outer>
{showDevTools && (
<SwitchChannelDialog
control={switchChannelControl}
defaultChannel={
isCurrentlyRunningPullRequestDeployment && currentChannel
? currentChannel
: 'pull-request-'
}
onSubmit={channel => 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 (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={l`Switch OTA channel`}
style={web({maxWidth: 400})}>
<View style={[a.gap_md]}>
<Text style={[a.text_xl, a.font_bold]}>
<Trans>Switch OTA channel</Trans>
</Text>
<View style={[a.gap_sm]}>
<TextField.LabelText nativeID="ota-channel-label">
<Trans>Channel</Trans>
</TextField.LabelText>
<TextField.Root>
<Dialog.Input
label={l`Channel`}
defaultValue={defaultChannel}
onChangeText={value => (channel.current = value)}
accessibilityLabelledBy="ota-channel-label"
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
spellCheck={false}
/>
</TextField.Root>
</View>
<Button
color="primary"
size="large"
label={l`Apply update from this channel`}
onPress={onPressApply}>
<ButtonText>
<Trans>Apply</Trans>
</ButtonText>
</Button>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+68
View File
@@ -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<ReturnType<typeof checkForUpdateAsync>>)
jest.mocked(fetchUpdateAsync).mockResolvedValue({
isNew: true,
isRollBackToEmbedded: false,
manifest: {id: 'new-update'},
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
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<ReturnType<typeof checkForUpdateAsync>>)
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', () => {
+59
View File
@@ -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,
+1
View File
@@ -6,6 +6,7 @@ export function useApplyPullRequestOTAUpdate() {
_channel: string,
_declaredAppVersion?: string | null,
) => {},
checkForUpdates: async () => {},
restoreDefaultChannel: async () => {},
isCurrentlyRunningPullRequestDeployment: false,
isCurrentlyRunningNonStandardChannel: false,