import {useCallback, useEffect, useRef, useState} from 'react' import {Alert, AppState, type AppStateStatus} from 'react-native' import {nativeBuildVersion} from 'expo-application' import { checkForUpdateAsync, fetchUpdateAsync, isEnabled, reloadAsync, setExtraParamAsync, useUpdates, } from 'expo-updates' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {IS_IOS, IS_TESTFLIGHT} from '#/env' const MINIMUM_MINIMIZE_TIME = 15 * 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'] 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 = currentlyRunning?.channel 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 * 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) => { setPending(true) await setExtraParamsPullRequest(channel) const res = await checkForUpdateAsync() if (res.isAvailable) { Alert.alert( 'Deployment Available', `A deployment of ${channel} is availalble. Applying this deployment may result in a bricked installation, in which case you will need to reinstall the app and may lose local data. Are you sure you want to proceed?`, [ { text: 'No', style: 'cancel', }, { text: 'Relaunch', style: 'default', onPress: async () => { await fetchUpdateAsync() await reloadAsync() }, }, ], ) } else { Alert.alert( 'No Deployment Available', `No new deployments of ${channel} are currently available for your current native build.`, ) } 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, } } export function useOTAUpdates() { const shouldReceiveUpdates = isEnabled && !__DEV__ const appState = useRef('active') const lastMinimize = useRef(0) const ranInitialCheck = useRef(false) const timeout = useRef(undefined) const {currentlyRunning, isUpdatePending} = useUpdates() const currentChannel = currentlyRunning?.channel 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]) }