384987262b
Reloading into an update re-delivers the deep link that triggered it, and the same link can also just be tapped again. Checking first means both cases are silent when there is nothing new to apply - the version mismatch warning no longer fires ahead of the check either. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
import {useCallback, useEffect, useRef, useState} from 'react'
|
|
import {Alert, AppState, type AppStateStatus} from 'react-native'
|
|
import {nativeBuildVersion} from 'expo-application'
|
|
import {
|
|
checkForUpdateAsync,
|
|
type CurrentlyRunningInfo,
|
|
fetchUpdateAsync,
|
|
isEnabled,
|
|
reloadAsync,
|
|
setExtraParamAsync,
|
|
UpdateCheckResultNotAvailableReason,
|
|
useUpdates,
|
|
} from 'expo-updates'
|
|
|
|
import {isNetworkError} from '#/lib/strings/errors'
|
|
import {logger} from '#/logger'
|
|
import {APP_VERSION, IS_IOS, IS_TESTFLIGHT} from '#/env'
|
|
import {device} from '#/storage'
|
|
|
|
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
|
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'
|
|
|
|
/**
|
|
* Channels that our native builds are configured with, see `eas.json`. An
|
|
* update running on any other channel was applied manually.
|
|
*/
|
|
const STANDARD_CHANNELS = ['production', 'testflight', 'development']
|
|
|
|
function getDeploymentName(channel: string) {
|
|
const pullRequestNumber = channel.match(/^pull-request-(\d+)$/)?.[1]
|
|
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',
|
|
// 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) {
|
|
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', channel)
|
|
}
|
|
|
|
async function updateTestflight() {
|
|
await setExtraParams()
|
|
|
|
const res = await checkForUpdateAsync()
|
|
if (res.isAvailable) {
|
|
await fetchUpdateAsync()
|
|
Alert.alert(
|
|
'Update Available',
|
|
'A new version of the app is available. Relaunch now?',
|
|
[
|
|
{
|
|
text: 'No',
|
|
style: 'cancel',
|
|
},
|
|
{
|
|
text: 'Relaunch',
|
|
style: 'default',
|
|
onPress: async () => {
|
|
await reloadAsync()
|
|
},
|
|
},
|
|
],
|
|
)
|
|
}
|
|
}
|
|
|
|
export function useApplyPullRequestOTAUpdate() {
|
|
const {currentlyRunning} = useUpdates()
|
|
const [pending, setPending] = useState(false)
|
|
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 the channel is undefined when updates are
|
|
* disabled (e.g. in dev), in which case there's nothing to restore.
|
|
*/
|
|
const isCurrentlyRunningNonStandardChannel = Boolean(
|
|
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
|
|
)
|
|
|
|
const tryApplyUpdate = async (
|
|
channel: string,
|
|
declaredAppVersion?: string | null,
|
|
) => {
|
|
const deploymentName = getDeploymentName(channel)
|
|
|
|
const checkForDeployment = async () => {
|
|
await setExtraParamsPullRequest(channel)
|
|
const res = await checkForUpdateAsync()
|
|
if (!res.isAvailable) {
|
|
if (
|
|
res.reason ===
|
|
UpdateCheckResultNotAvailableReason.UPDATE_PREVIOUSLY_FAILED
|
|
) {
|
|
Alert.alert(
|
|
'Deployment Blocked',
|
|
`The ${deploymentName} deployment previously failed to start on this device, so the app will not try to apply it again.`,
|
|
)
|
|
} else if (currentChannel !== channel) {
|
|
Alert.alert(
|
|
'No Deployment Available',
|
|
`No new deployments of ${channel} are currently available for your current native build.`,
|
|
)
|
|
}
|
|
}
|
|
return res.isAvailable
|
|
}
|
|
|
|
const applyUpdate = () => {
|
|
setPending(true)
|
|
void (async () => {
|
|
try {
|
|
if (!(await checkForDeployment())) return
|
|
const fetchedUpdate = await fetchUpdateAsync()
|
|
if (!fetchedUpdate.isNew) {
|
|
throw new Error('Expo did not download a new update.')
|
|
}
|
|
|
|
device.set(['pendingOTAUpdate'], {
|
|
attemptedAt: Date.now(),
|
|
channel,
|
|
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'])
|
|
throw e
|
|
}
|
|
} catch (e: unknown) {
|
|
const error = String(e)
|
|
logger.error('Internal OTA Update Error', {error})
|
|
Alert.alert(
|
|
'Update Failed',
|
|
`Could not apply the ${deploymentName} deployment: ${error}`,
|
|
)
|
|
} finally {
|
|
setPending(false)
|
|
}
|
|
})()
|
|
}
|
|
|
|
/*
|
|
* 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.',
|
|
[
|
|
{
|
|
text: 'Cancel',
|
|
style: 'cancel',
|
|
},
|
|
{
|
|
text: 'Apply',
|
|
style: 'default',
|
|
onPress: applyUpdate,
|
|
},
|
|
],
|
|
)
|
|
} 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.
|
|
*/
|
|
const restoreDefaultChannel = async () => {
|
|
setPending(true)
|
|
try {
|
|
await setExtraParams()
|
|
const res = await checkForUpdateAsync()
|
|
if (res.isAvailable) {
|
|
await fetchUpdateAsync()
|
|
await reloadAsync()
|
|
} else {
|
|
Alert.alert(
|
|
'Nothing to Restore',
|
|
`No deployment of ${DEFAULT_CHANNEL} is currently available for your native build. Reinstall the app to get back to a standard build.`,
|
|
)
|
|
}
|
|
} catch (e: any) {
|
|
logger.error('Internal OTA Update Error', {error: `${e}`})
|
|
Alert.alert(
|
|
'Restore Failed',
|
|
`Could not restore the ${DEFAULT_CHANNEL} deployment: ${e}`,
|
|
)
|
|
} finally {
|
|
setPending(false)
|
|
}
|
|
}
|
|
|
|
return {
|
|
tryApplyUpdate,
|
|
restoreDefaultChannel,
|
|
isCurrentlyRunningPullRequestDeployment,
|
|
isCurrentlyRunningNonStandardChannel,
|
|
currentChannel,
|
|
defaultChannel: DEFAULT_CHANNEL,
|
|
pending,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reports when expo-updates recovered from a custom OTA that failed to launch.
|
|
* The attempted update ID is persisted before reload so the previous bundle can
|
|
* distinguish a successful relaunch from an automatic fallback.
|
|
*/
|
|
export function useOTAUpdateRecovery() {
|
|
const {currentlyRunning} = useUpdates()
|
|
|
|
useEffect(() => {
|
|
const pendingUpdate = device.get(['pendingOTAUpdate'])
|
|
if (!pendingUpdate || !currentlyRunning) return
|
|
|
|
device.remove(['pendingOTAUpdate'])
|
|
if (
|
|
pendingUpdate.updateId.toLowerCase() ===
|
|
currentlyRunning.updateId?.toLowerCase()
|
|
) {
|
|
return
|
|
}
|
|
|
|
// A fallback relaunch is immediate. A stale marker can be left by a
|
|
// successful runtime-compatible bundle that predates this hook.
|
|
if (
|
|
typeof pendingUpdate.attemptedAt !== 'number' ||
|
|
Date.now() - pendingUpdate.attemptedAt >= OTA_RECOVERY_WINDOW
|
|
) {
|
|
return
|
|
}
|
|
|
|
const deploymentName = getDeploymentName(pendingUpdate.channel)
|
|
logger.error('Custom OTA Update Failed to Launch', {
|
|
channel: pendingUpdate.channel,
|
|
attemptedUpdateId: pendingUpdate.updateId,
|
|
currentUpdateId: currentlyRunning.updateId,
|
|
isEmergencyLaunch: currentlyRunning.isEmergencyLaunch,
|
|
emergencyLaunchReason: currentlyRunning.emergencyLaunchReason,
|
|
})
|
|
Alert.alert(
|
|
'Update Failed',
|
|
`The ${deploymentName} deployment could not start. The app recovered by loading a working version instead.`,
|
|
)
|
|
}, [currentlyRunning])
|
|
}
|
|
|
|
export function useOTAUpdates() {
|
|
const shouldReceiveUpdates = isEnabled && !__DEV__
|
|
|
|
const appState = useRef<AppStateStatus>('active')
|
|
const lastMinimize = useRef(0)
|
|
const ranInitialCheck = useRef(false)
|
|
const timeout = useRef<NodeJS.Timeout>(undefined)
|
|
const {currentlyRunning, isUpdatePending} = useUpdates()
|
|
const currentChannel = getRunningChannel(currentlyRunning)
|
|
|
|
const setCheckTimeout = useCallback(() => {
|
|
timeout.current = setTimeout(async () => {
|
|
try {
|
|
await setExtraParams()
|
|
|
|
logger.debug('Checking for update...')
|
|
const res = await checkForUpdateAsync()
|
|
|
|
if (res.isAvailable) {
|
|
logger.debug('Attempting to fetch update...')
|
|
await fetchUpdateAsync()
|
|
} else {
|
|
logger.debug('No update available.')
|
|
}
|
|
} catch (err) {
|
|
if (!isNetworkError(err)) {
|
|
logger.error('OTA Update Error', {safeMessage: err})
|
|
}
|
|
}
|
|
}, 10e3)
|
|
}, [])
|
|
|
|
const onIsTestFlight = useCallback(async () => {
|
|
try {
|
|
await updateTestflight()
|
|
} catch (err: any) {
|
|
if (!isNetworkError(err)) {
|
|
logger.error('Internal OTA Update Error', {safeMessage: err})
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
// We don't need to check anything if the current update is a PR update
|
|
if (currentChannel?.startsWith('pull-request')) {
|
|
return
|
|
}
|
|
|
|
// We use this setTimeout to allow analytics to initialize before we check for an update
|
|
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
|
|
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
|
|
// immediately.
|
|
if (IS_TESTFLIGHT) {
|
|
onIsTestFlight()
|
|
return
|
|
} else if (!shouldReceiveUpdates || ranInitialCheck.current) {
|
|
return
|
|
}
|
|
|
|
setCheckTimeout()
|
|
ranInitialCheck.current = true
|
|
}, [onIsTestFlight, currentChannel, setCheckTimeout, shouldReceiveUpdates])
|
|
|
|
// After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available
|
|
// or B check for an update again.
|
|
useEffect(() => {
|
|
// We also don't start this timeout if the user is on a pull request update
|
|
if (!isEnabled || currentChannel?.startsWith('pull-request')) {
|
|
return
|
|
}
|
|
|
|
const subscription = AppState.addEventListener(
|
|
'change',
|
|
async nextAppState => {
|
|
if (
|
|
appState.current.match(/inactive|background/) &&
|
|
nextAppState === 'active'
|
|
) {
|
|
// 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 (isUpdatePending) {
|
|
await reloadAsync()
|
|
} else {
|
|
setCheckTimeout()
|
|
}
|
|
}
|
|
} else {
|
|
lastMinimize.current = Date.now()
|
|
}
|
|
|
|
appState.current = nextAppState
|
|
},
|
|
)
|
|
|
|
return () => {
|
|
clearTimeout(timeout.current)
|
|
subscription.remove()
|
|
}
|
|
}, [isUpdatePending, currentChannel, setCheckTimeout])
|
|
}
|