improve ota channel notice in drawer
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
_channel: string,
|
||||
_declaredAppVersion?: string | null,
|
||||
) => {},
|
||||
checkForUpdates: async () => {},
|
||||
restoreDefaultChannel: async () => {},
|
||||
isCurrentlyRunningPullRequestDeployment: false,
|
||||
isCurrentlyRunningNonStandardChannel: false,
|
||||
|
||||
Reference in New Issue
Block a user