diff --git a/src/lib/hooks/__tests__/useOTAUpdates.test.ts b/src/lib/hooks/__tests__/useOTAUpdates.test.ts new file mode 100644 index 0000000000..a6ad40fb06 --- /dev/null +++ b/src/lib/hooks/__tests__/useOTAUpdates.test.ts @@ -0,0 +1,60 @@ +import {beforeEach, describe, expect, jest, test} from '@jest/globals' + +import { + consumeOTAReloadMarker, + reloadWithUpdate, +} from '#/lib/hooks/useOTAUpdates' +import {device} from '#/storage' + +jest.mock('react-native-mmkv', () => ({ + MMKV: class MMKVMock { + _store = new Map() + + set(key: string, value: unknown) { + this._store.set(key, value) + } + + getString(key: string) { + return this._store.get(key) + } + + delete(key: string) { + return this._store.delete(key) + } + }, +})) + +jest.mock('expo-updates', () => ({ + checkForUpdateAsync: jest.fn(), + fetchUpdateAsync: jest.fn(), + isEnabled: true, + reloadAsync: jest.fn(), + setExtraParamAsync: jest.fn(), + useUpdates: jest.fn(), +})) + +describe('consumeOTAReloadMarker', () => { + beforeEach(() => { + device.remove(['otaReloadedAt']) + }) + + test('is false when the app was not reloaded by us', () => { + expect(consumeOTAReloadMarker()).toBe(false) + }) + + test('is true in the runtime that follows a reload', async () => { + await reloadWithUpdate() + expect(consumeOTAReloadMarker()).toBe(true) + }) + + test('only the first caller in a runtime sees the marker', async () => { + await reloadWithUpdate() + expect(consumeOTAReloadMarker()).toBe(true) + expect(consumeOTAReloadMarker()).toBe(false) + }) + + test('ignores a marker left behind by a reload that never happened', () => { + device.set(['otaReloadedAt'], Date.now() - 5 * 60e3) + expect(consumeOTAReloadMarker()).toBe(false) + }) +}) diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index eaa6482830..7513876b25 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -15,14 +15,29 @@ import {useIntentDialogs} from '#/components/intents/IntentDialogs' import {useAnalytics} from '#/analytics' import {IS_IOS, IS_NATIVE} from '#/env' import {Referrer} from '../../../modules/expo-bluesky-swiss-army' -import {useApplyPullRequestOTAUpdate} from './useOTAUpdates' +import { + consumeOTAReloadMarker, + useApplyPullRequestOTAUpdate, +} from './useOTAUpdates' type IntentType = 'compose' | 'verify-email' | 'age-assurance' | 'apply-ota' const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/ -// This needs to stay outside of react to persist between account switches -let previousIntentUrl = '' +/* + * This needs to stay outside of react to persist between account switches. + * + * Reloading the app to apply an OTA update restarts the JS runtime but not the + * native process, and `expo-linking` keeps reporting the URL the app was opened + * with, so the launch URL is handed to us again in the new runtime. Left alone, + * an `intent/apply-ota` link would fire again on the reload it just caused - + * prompting to apply the deployment, reloading, and looping. So when the runtime + * we're starting in is one we reloaded into, seed the guard with the launch URL + * to mark it as already handled. + */ +let previousIntentUrl = consumeOTAReloadMarker() + ? (Linking.getLinkingURL() ?? '') + : '' export function useIntentHandler() { const incomingUrl = Linking.useLinkingURL() diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 766fb959df..922ad3dd83 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -13,9 +13,66 @@ import { import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env' +import {device} from '#/storage' const MINIMUM_MINIMIZE_TIME = 15 * 60e3 +/* + * A reload brings up a new JS runtime within a few seconds, so a marker older + * than this was left behind by a reload that never happened and is ignored. + */ +const RELOAD_MARKER_MAX_AGE = 60e3 + +/** + * Reload the app to launch a downloaded update. Always use this instead of + * `reloadAsync` so that `consumeOTAReloadMarker` can tell that the next JS + * runtime came from a reload. + */ +export async function reloadWithUpdate() { + device.set(['otaReloadedAt'], Date.now()) + await reloadAsync() +} + +/** + * Whether this JS runtime was started by us reloading the app to apply an + * update, rather than by the user opening the app. + * + * `reloadAsync` restarts the JS runtime but not the native process, and + * `expo-linking` keeps handing out the URL the app was originally opened with + * (its native registry outlives the runtime), so anything that acts on that URL + * needs to know to sit out the first pass after a reload. Clears the marker, so + * only the first caller within a runtime gets `true`. + */ +export function consumeOTAReloadMarker() { + const reloadedAt = device.get(['otaReloadedAt']) + if (reloadedAt === undefined) return false + device.remove(['otaReloadedAt']) + return Date.now() - reloadedAt < RELOAD_MARKER_MAX_AGE +} + +/** + * The pull request deployment channel the running update was served from, or + * `undefined` if we're not running a pull request deployment. + * + * `useUpdates().currentlyRunning.channel` is the channel configured in the + * native build (`production` or `testflight`), not the channel the running + * update was served from - our updates service picks that up from the `channel` + * extra param - so it can never name a pull request deployment. Instead we + * compare the running update against the one `tryApplyUpdate` recorded. + */ +function getRunningPullRequestChannel(runningUpdateId: string | undefined) { + const applied = device.get(['appliedOTADeployment']) + if (!applied || !runningUpdateId) return undefined + /* + * expo-updates lowercases the running update id (iOS reports uppercase + * UUIDs), while the manifest id we recorded is whatever the service sent. + */ + if (applied.updateId.toLowerCase() !== runningUpdateId.toLowerCase()) { + return undefined + } + return applied.channel +} + async function setExtraParams() { await setExtraParamAsync( IS_IOS ? 'ios-build-number' : 'android-build-number', @@ -57,7 +114,7 @@ async function updateTestflight() { text: 'Relaunch', style: 'default', onPress: async () => { - await reloadAsync() + await reloadWithUpdate() }, }, ], @@ -68,9 +125,12 @@ async function updateTestflight() { export function useApplyPullRequestOTAUpdate() { const {currentlyRunning} = useUpdates() const [pending, setPending] = useState(false) - const currentChannel = currentlyRunning?.channel + const pullRequestChannel = getRunningPullRequestChannel( + currentlyRunning?.updateId, + ) const isCurrentlyRunningPullRequestDeployment = - currentChannel?.startsWith('pull-request') + pullRequestChannel !== undefined + const currentChannel = pullRequestChannel ?? currentlyRunning?.channel const tryApplyUpdate = async (channel: string) => { setPending(true) @@ -89,8 +149,19 @@ export function useApplyPullRequestOTAUpdate() { text: 'Relaunch', style: 'default', onPress: async () => { - await fetchUpdateAsync() - await reloadAsync() + const fetched = await fetchUpdateAsync() + /* + * Record what we're about to launch so we can recognize the + * deployment as running once it comes up, see + * `getRunningPullRequestChannel`. + */ + if (fetched.isNew) { + device.set(['appliedOTADeployment'], { + channel, + updateId: fetched.manifest.id, + }) + } + await reloadWithUpdate() }, }, ], @@ -105,6 +176,13 @@ export function useApplyPullRequestOTAUpdate() { } const revertToEmbedded = async () => { + /* + * Drop the record before reverting: `updateTestflight` can only relaunch us + * if a newer regular update happens to be available, and as long as we + * consider a pull request deployment to be running the automatic checks stay + * backed off - which would leave no way off the deployment at all. + */ + device.remove(['appliedOTADeployment']) try { await updateTestflight() } catch (e: any) { @@ -129,7 +207,8 @@ export function useOTAUpdates() { const ranInitialCheck = useRef(false) const timeout = useRef(undefined) const {currentlyRunning, isUpdatePending} = useUpdates() - const currentChannel = currentlyRunning?.channel + const isRunningPullRequestDeployment = + getRunningPullRequestChannel(currentlyRunning?.updateId) !== undefined const setCheckTimeout = useCallback(() => { timeout.current = setTimeout(async () => { @@ -165,7 +244,7 @@ export function useOTAUpdates() { useEffect(() => { // We don't need to check anything if the current update is a PR update - if (currentChannel?.startsWith('pull-request')) { + if (isRunningPullRequestDeployment) { return } @@ -182,13 +261,18 @@ export function useOTAUpdates() { setCheckTimeout() ranInitialCheck.current = true - }, [onIsTestFlight, currentChannel, setCheckTimeout, shouldReceiveUpdates]) + }, [ + onIsTestFlight, + isRunningPullRequestDeployment, + 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')) { + if (!isEnabled || isRunningPullRequestDeployment) { return } @@ -210,7 +294,7 @@ export function useOTAUpdates() { // 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() + await reloadWithUpdate() } else { setCheckTimeout() } @@ -227,5 +311,5 @@ export function useOTAUpdates() { clearTimeout(timeout.current) subscription.remove() } - }, [isUpdatePending, currentChannel, setCheckTimeout]) + }, [isUpdatePending, isRunningPullRequestDeployment, setCheckTimeout]) } diff --git a/src/lib/hooks/useOTAUpdates.web.ts b/src/lib/hooks/useOTAUpdates.web.ts index 30cb2c6dba..c39067adef 100644 --- a/src/lib/hooks/useOTAUpdates.web.ts +++ b/src/lib/hooks/useOTAUpdates.web.ts @@ -8,3 +8,9 @@ export function useApplyPullRequestOTAUpdate() { pending: false, } } +/** + * There are no OTA updates on web, so the runtime is never one we reloaded into. + */ +export function consumeOTAReloadMarker() { + return false +} diff --git a/src/screens/Settings/components/OTAInfo.tsx b/src/screens/Settings/components/OTAInfo.tsx index 9ab1962b97..f93bfbe2b5 100644 --- a/src/screens/Settings/components/OTAInfo.tsx +++ b/src/screens/Settings/components/OTAInfo.tsx @@ -4,6 +4,7 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useMutation, useQuery} from '@tanstack/react-query' +import {reloadWithUpdate} from '#/lib/hooks/useOTAUpdates' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate' import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes' @@ -31,7 +32,7 @@ export function OTAInfo() { useMutation({ mutationFn: async () => { await Updates.fetchUpdateAsync() - await Updates.reloadAsync() + await reloadWithUpdate() }, onError: error => Toast.show(`Failed to update: ${error.message}`, { diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 33c6bc0b56..7f9b5e2ecd 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -73,6 +73,22 @@ export type Device = { */ policyUpdateDebugOverride?: boolean [PolicyUpdate202508]?: boolean + + /** + * Timestamp of the last JS runtime reload we performed to apply an OTA + * update. Read and cleared once per runtime, see `consumeOTAReloadMarker`. + */ + otaReloadedAt?: number + /** + * The pull request OTA deployment last applied on this device, along with the + * id of the update it installed. Used to tell whether the update we're + * running came from a pull request channel, see + * `useApplyPullRequestOTAUpdate`. + */ + appliedOTADeployment?: { + channel: string + updateId: string + } } export type Account = {