handle fingerprint ota previews and request lifecycles
This commit is contained in:
@@ -92,11 +92,13 @@ export function useIntentHandler() {
|
||||
releaseVersion && buildNumber
|
||||
? `${releaseVersion}.${buildNumber}`
|
||||
: null
|
||||
const sourceCommit = params.get('sourceCommit')
|
||||
const publicationId = params.get('publicationId')
|
||||
if (!channel) {
|
||||
Alert.alert('Error', 'No channel provided to look for.')
|
||||
return
|
||||
}
|
||||
tryApplyUpdate(channel, appVersion)
|
||||
tryApplyUpdate(channel, appVersion, {sourceCommit, publicationId})
|
||||
return
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Alert} from 'react-native'
|
||||
import {Alert, AppState} from 'react-native'
|
||||
import {
|
||||
checkForUpdateAsync,
|
||||
fetchUpdateAsync,
|
||||
@@ -13,15 +13,24 @@ import {logger} from '#/logger'
|
||||
import {APP_VERSION} from '#/env'
|
||||
import {device} from '#/storage'
|
||||
import {
|
||||
checkForOTAUpdate,
|
||||
prepareOTAUpdateRequest,
|
||||
useApplyPullRequestOTAUpdate,
|
||||
useOTAUpdateRecovery,
|
||||
useOTAUpdates,
|
||||
} from './useOTAUpdates'
|
||||
|
||||
let mockRuntimeVersion = '1.133.0'
|
||||
|
||||
jest.mock('expo-updates', () => ({
|
||||
channel: 'testflight',
|
||||
checkForUpdateAsync: jest.fn(),
|
||||
fetchUpdateAsync: jest.fn(),
|
||||
isEnabled: true,
|
||||
reloadAsync: jest.fn(),
|
||||
get runtimeVersion() {
|
||||
return mockRuntimeVersion
|
||||
},
|
||||
setExtraParamAsync: jest.fn(),
|
||||
UpdateCheckResultNotAvailableReason: {
|
||||
NO_UPDATE_AVAILABLE_ON_SERVER: 'noUpdateAvailableOnServer',
|
||||
@@ -61,10 +70,12 @@ function mockCurrentlyRunning({
|
||||
buildChannel = 'testflight',
|
||||
channel,
|
||||
updateId = 'current-update',
|
||||
isUpdatePending = false,
|
||||
}: {
|
||||
buildChannel?: string
|
||||
channel?: string
|
||||
updateId?: string
|
||||
isUpdatePending?: boolean
|
||||
} = {}) {
|
||||
const currentlyRunning = {
|
||||
channel: buildChannel,
|
||||
@@ -76,6 +87,7 @@ function mockCurrentlyRunning({
|
||||
}
|
||||
jest.mocked(useUpdates).mockReturnValue({
|
||||
currentlyRunning,
|
||||
isUpdatePending,
|
||||
} as ReturnType<typeof useUpdates>)
|
||||
return currentlyRunning
|
||||
}
|
||||
@@ -84,13 +96,30 @@ const currentUpdate = {updateId: 'current-update'}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockRuntimeVersion = '1.133.0'
|
||||
mockCurrentlyRunning()
|
||||
jest.mocked(setExtraParamAsync).mockResolvedValue(undefined)
|
||||
jest.mocked(reloadAsync).mockResolvedValue(undefined)
|
||||
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({status: 'available', deployments: []}),
|
||||
})
|
||||
})
|
||||
|
||||
describe('useApplyPullRequestOTAUpdate', () => {
|
||||
it('rejects manually applying a non-PR channel', async () => {
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('testflight'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Invalid Deployment',
|
||||
expect.stringContaining('Only pull request'),
|
||||
)
|
||||
expect(checkForUpdateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects a running PR deployment from the manifest metadata', () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
@@ -209,7 +238,7 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
isNew: true,
|
||||
isRollBackToEmbedded: false,
|
||||
manifest: {id: 'mismatched-update'},
|
||||
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
|
||||
} as unknown as Awaited<ReturnType<typeof fetchUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
|
||||
@@ -231,6 +260,103 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not offer an app-version override to fingerprint clients', async () => {
|
||||
mockRuntimeVersion = 'a'.repeat(40)
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Apply update from PR #123?',
|
||||
expect.stringContaining('relaunch'),
|
||||
expect.arrayContaining([expect.objectContaining({text: 'Apply'})]),
|
||||
)
|
||||
expect(Alert.alert).not.toHaveBeenCalledWith(
|
||||
'App Version Mismatch',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['runtime-mismatch', 'Different Native Build Required'],
|
||||
['not-published', 'No Deployment Available'],
|
||||
['stale-link', 'Deployment Link Is Out of Date'],
|
||||
] as const)('handles the %s diagnostic status', async (status, title) => {
|
||||
mockRuntimeVersion = 'a'.repeat(40)
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({status, deployments: []}),
|
||||
})
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(title, expect.any(String))
|
||||
expect(checkForUpdateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays quiet when diagnostics report the deployment already running', async () => {
|
||||
mockRuntimeVersion = 'a'.repeat(40)
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({status: 'already-running', deployments: []}),
|
||||
})
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
|
||||
expect(Alert.alert).not.toHaveBeenCalled()
|
||||
expect(checkForUpdateAsync).not.toHaveBeenCalled()
|
||||
expect(result.current.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('allows only one PR confirmation prompt at a time', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-456'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.pending).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a fetched deployment whose source does not match the link', async () => {
|
||||
mockRuntimeVersion = 'a'.repeat(40)
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
jest.mocked(fetchUpdateAsync).mockResolvedValue({
|
||||
isNew: true,
|
||||
isRollBackToEmbedded: false,
|
||||
manifest: {id: 'new-update', extra: {ota: {sourceCommit: 'newer'}}},
|
||||
} as unknown as Awaited<ReturnType<typeof fetchUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() =>
|
||||
result.current.tryApplyUpdate('pull-request-123', null, {
|
||||
sourceCommit: 'expected',
|
||||
}),
|
||||
)
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
void act(() => buttons?.[1].onPress?.())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(Alert.alert).toHaveBeenLastCalledWith(
|
||||
'Update Failed',
|
||||
expect.stringContaining('deployment changed'),
|
||||
),
|
||||
)
|
||||
expect(reloadAsync).not.toHaveBeenCalled()
|
||||
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
|
||||
})
|
||||
|
||||
it('informs the user when checking for an OTA fails', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline'))
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
@@ -297,8 +423,87 @@ describe('useApplyPullRequestOTAUpdate', () => {
|
||||
updateId: 'new-update',
|
||||
})
|
||||
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
|
||||
expect(result.current.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the default request after cancellation', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
await act(() => buttons?.[0].onPress?.())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setExtraParamAsync).toHaveBeenLastCalledWith(
|
||||
'channel',
|
||||
'testflight',
|
||||
),
|
||||
)
|
||||
expect(result.current.pending).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOTAUpdates', () => {
|
||||
it('does not reload a pending default update while a PR prompt owns the request', async () => {
|
||||
let appStateListener: ((state: string) => Promise<void>) | undefined
|
||||
jest
|
||||
.spyOn(AppState, 'addEventListener')
|
||||
.mockImplementation((_, listener) => {
|
||||
appStateListener = listener as (state: string) => Promise<void>
|
||||
return {remove: jest.fn()}
|
||||
})
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
mockCurrentlyRunning({isUpdatePending: true})
|
||||
const apply = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
renderHook(() => useOTAUpdates())
|
||||
|
||||
await act(() => apply.result.current.tryApplyUpdate('pull-request-123'))
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValue(16 * 60e3)
|
||||
await act(async () => {
|
||||
await appStateListener?.('background')
|
||||
await appStateListener?.('active')
|
||||
})
|
||||
|
||||
expect(reloadAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OTA request preparation', () => {
|
||||
it('sets the native build number before the channel', async () => {
|
||||
await prepareOTAUpdateRequest('pull-request-123')
|
||||
|
||||
expect(setExtraParamAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringMatching(/^(ios|android)-build-number$/),
|
||||
expect.any(String),
|
||||
)
|
||||
expect(setExtraParamAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'channel',
|
||||
'pull-request-123',
|
||||
)
|
||||
})
|
||||
|
||||
it('prepares the default channel before checking', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: false,
|
||||
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
|
||||
await checkForOTAUpdate()
|
||||
|
||||
expect(setExtraParamAsync).toHaveBeenLastCalledWith('channel', 'testflight')
|
||||
expect(checkForUpdateAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOTAUpdateRecovery', () => {
|
||||
|
||||
+203
-32
@@ -7,12 +7,14 @@ import {
|
||||
} from 'react-native'
|
||||
import {nativeBuildVersion} from 'expo-application'
|
||||
import {
|
||||
channel as nativeChannel,
|
||||
checkForUpdateAsync,
|
||||
type CurrentlyRunningInfo,
|
||||
fetchUpdateAsync,
|
||||
isEnabled,
|
||||
reloadAsync,
|
||||
type ReloadScreenOptions,
|
||||
runtimeVersion as nativeRuntimeVersion,
|
||||
setExtraParamAsync,
|
||||
UpdateCheckResultNotAvailableReason,
|
||||
useUpdates,
|
||||
@@ -31,7 +33,42 @@ 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'
|
||||
const DEFAULT_CHANNEL =
|
||||
nativeChannel || (IS_TESTFLIGHT ? 'testflight' : 'production')
|
||||
|
||||
const FINGERPRINT_RUNTIME_VERSION = /^[0-9a-f]{40}$/
|
||||
const OTA_RESOLVE_URL = 'https://updates.bsky.app/v1/ota/resolve'
|
||||
|
||||
type OTAResolveResult = {
|
||||
status:
|
||||
| 'available'
|
||||
| 'already-running'
|
||||
| 'runtime-mismatch'
|
||||
| 'not-published'
|
||||
| 'stale-link'
|
||||
deployments: {
|
||||
runtimeVersion: string
|
||||
sourceCommit?: string
|
||||
publicationId?: string
|
||||
updateId?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
let requestQueue = Promise.resolve()
|
||||
let manualRequestOwner: symbol | undefined
|
||||
|
||||
function isManualRequestPending() {
|
||||
return manualRequestOwner !== undefined
|
||||
}
|
||||
|
||||
function serializeOTARequest<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = requestQueue.then(operation, operation)
|
||||
requestQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels that our native builds are configured with, see `eas.json`. An
|
||||
@@ -69,17 +106,7 @@ function getRunningChannel(
|
||||
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) {
|
||||
export async function prepareOTAUpdateRequest(channel = DEFAULT_CHANNEL) {
|
||||
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.
|
||||
@@ -89,12 +116,55 @@ async function setExtraParamsPullRequest(channel: string) {
|
||||
await setExtraParamAsync('channel', channel)
|
||||
}
|
||||
|
||||
async function updateTestflight(scheme: 'light' | 'dark') {
|
||||
await setExtraParams()
|
||||
export function checkForOTAUpdate(channel = DEFAULT_CHANNEL) {
|
||||
return serializeOTARequest(async () => {
|
||||
await prepareOTAUpdateRequest(channel)
|
||||
return checkForUpdateAsync()
|
||||
})
|
||||
}
|
||||
|
||||
const res = await checkForUpdateAsync()
|
||||
export function fetchOTAUpdate(channel = DEFAULT_CHANNEL) {
|
||||
return serializeOTARequest(async () => {
|
||||
await prepareOTAUpdateRequest(channel)
|
||||
return fetchUpdateAsync()
|
||||
})
|
||||
}
|
||||
|
||||
async function resolvePullRequestDeployment({
|
||||
channel,
|
||||
currentUpdateId,
|
||||
expected,
|
||||
}: {
|
||||
channel: string
|
||||
currentUpdateId?: string
|
||||
expected?: {sourceCommit?: string | null; publicationId?: string | null}
|
||||
}): Promise<OTAResolveResult | undefined> {
|
||||
if (!FINGERPRINT_RUNTIME_VERSION.test(nativeRuntimeVersion || '')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
channel,
|
||||
platform: IS_IOS ? 'ios' : 'android',
|
||||
runtimeVersion: nativeRuntimeVersion!,
|
||||
})
|
||||
if (currentUpdateId) query.set('currentUpdateId', currentUpdateId)
|
||||
if (expected?.sourceCommit) {
|
||||
query.set('sourceCommit', expected.sourceCommit)
|
||||
}
|
||||
if (expected?.publicationId) {
|
||||
query.set('publicationId', expected.publicationId)
|
||||
}
|
||||
|
||||
const response = await fetch(`${OTA_RESOLVE_URL}?${query}`)
|
||||
if (!response.ok) throw new Error(`OTA resolve failed (${response.status})`)
|
||||
return response.json() as Promise<OTAResolveResult>
|
||||
}
|
||||
|
||||
async function updateTestflight(scheme: 'light' | 'dark') {
|
||||
const res = await checkForOTAUpdate()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
await fetchOTAUpdate()
|
||||
Alert.alert(
|
||||
'Update Available',
|
||||
'A new version of the app is available. Relaunch now?',
|
||||
@@ -121,6 +191,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
const t = useTheme()
|
||||
const {currentlyRunning} = useUpdates()
|
||||
const [pending, setPending] = useState(false)
|
||||
const requestOwnerRef = useRef<symbol>(undefined)
|
||||
const currentChannel = getRunningChannel(currentlyRunning)
|
||||
const isCurrentlyRunningPullRequestDeployment =
|
||||
currentChannel?.startsWith('pull-request')
|
||||
@@ -133,15 +204,73 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (manualRequestOwner === requestOwnerRef.current) {
|
||||
manualRequestOwner = undefined
|
||||
void serializeOTARequest(() => prepareOTAUpdateRequest())
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const tryApplyUpdate = async (
|
||||
channel: string,
|
||||
declaredAppVersion?: string | null,
|
||||
expected?: {sourceCommit?: string | null; publicationId?: string | null},
|
||||
) => {
|
||||
if (!/^pull-request-[1-9]\d*$/.test(channel)) {
|
||||
Alert.alert(
|
||||
'Invalid Deployment',
|
||||
'Only pull request deployments can be applied manually.',
|
||||
)
|
||||
return
|
||||
}
|
||||
if (manualRequestOwner) return
|
||||
|
||||
const requestOwner = Symbol(channel)
|
||||
manualRequestOwner = requestOwner
|
||||
requestOwnerRef.current = requestOwner
|
||||
setPending(true)
|
||||
const deploymentName = getDeploymentName(channel)
|
||||
let resolvedDeployment: OTAResolveResult | undefined
|
||||
|
||||
const checkForDeployment = async () => {
|
||||
await setExtraParamsPullRequest(channel)
|
||||
const res = await checkForUpdateAsync()
|
||||
try {
|
||||
resolvedDeployment = await resolvePullRequestDeployment({
|
||||
channel,
|
||||
currentUpdateId: currentlyRunning?.updateId,
|
||||
expected,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.debug('Could not resolve OTA deployment diagnostics', {
|
||||
safeMessage: err,
|
||||
})
|
||||
}
|
||||
|
||||
if (resolvedDeployment?.status === 'already-running') return false
|
||||
if (resolvedDeployment?.status === 'runtime-mismatch') {
|
||||
Alert.alert(
|
||||
'Different Native Build Required',
|
||||
`The ${deploymentName} deployment requires a different native build. Install a compatible TestFlight build and try again.`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (resolvedDeployment?.status === 'not-published') {
|
||||
Alert.alert(
|
||||
'No Deployment Available',
|
||||
`The ${deploymentName} deployment has not been published or is no longer available.`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (resolvedDeployment?.status === 'stale-link') {
|
||||
Alert.alert(
|
||||
'Deployment Link Is Out of Date',
|
||||
`This link does not refer to the latest ${deploymentName} deployment. Open the newest link and try again.`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const res = await checkForOTAUpdate(channel)
|
||||
if (!res.isAvailable) {
|
||||
if (
|
||||
res.reason ===
|
||||
@@ -161,16 +290,50 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
return res.isAvailable
|
||||
}
|
||||
|
||||
const finishManualRequest = async (restoreDefault: boolean) => {
|
||||
if (manualRequestOwner !== requestOwner) return
|
||||
manualRequestOwner = undefined
|
||||
requestOwnerRef.current = undefined
|
||||
if (restoreDefault) {
|
||||
await serializeOTARequest(() => prepareOTAUpdateRequest()).catch(
|
||||
() => {},
|
||||
)
|
||||
}
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
const restoreAfterCancellation = () => {
|
||||
void finishManualRequest(true)
|
||||
}
|
||||
|
||||
const applyUpdate = () => {
|
||||
setPending(true)
|
||||
void (async () => {
|
||||
let reloadSucceeded = false
|
||||
try {
|
||||
if (!(await checkForDeployment())) return
|
||||
const fetchedUpdate = await fetchUpdateAsync()
|
||||
const fetchedUpdate = await fetchOTAUpdate(channel)
|
||||
if (!fetchedUpdate.isNew) {
|
||||
throw new Error('Expo did not download a new update.')
|
||||
}
|
||||
|
||||
const manifest = fetchedUpdate.manifest as {
|
||||
extra?: {
|
||||
ota?: {sourceCommit?: unknown; publicationId?: unknown}
|
||||
}
|
||||
}
|
||||
const sourceCommit = manifest.extra?.ota?.sourceCommit
|
||||
const publicationId = manifest.extra?.ota?.publicationId
|
||||
if (
|
||||
(expected?.sourceCommit &&
|
||||
sourceCommit !== expected.sourceCommit) ||
|
||||
(expected?.publicationId &&
|
||||
publicationId !== expected.publicationId)
|
||||
) {
|
||||
throw new Error(
|
||||
'The deployment changed while it was being downloaded. Check the link again to review the newest deployment.',
|
||||
)
|
||||
}
|
||||
|
||||
device.set(['pendingOTAUpdate'], {
|
||||
attemptedAt: Date.now(),
|
||||
channel,
|
||||
@@ -180,6 +343,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
await reloadAsync({
|
||||
reloadScreenOptions: splash(t.scheme),
|
||||
})
|
||||
reloadSucceeded = true
|
||||
} catch (e) {
|
||||
device.remove(['pendingOTAUpdate'])
|
||||
throw e
|
||||
@@ -192,7 +356,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
`Could not apply the ${deploymentName} deployment: ${error}`,
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
await finishManualRequest(!reloadSucceeded)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -203,11 +367,17 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
* 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 (!(await checkForDeployment())) {
|
||||
await finishManualRequest(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (declaredAppVersion && declaredAppVersion !== APP_VERSION) {
|
||||
if (
|
||||
!FINGERPRINT_RUNTIME_VERSION.test(nativeRuntimeVersion || '') &&
|
||||
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.`,
|
||||
@@ -215,6 +385,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
onPress: restoreAfterCancellation,
|
||||
},
|
||||
{
|
||||
text: 'Apply Anyway',
|
||||
@@ -233,6 +404,7 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
onPress: restoreAfterCancellation,
|
||||
},
|
||||
{
|
||||
text: 'Apply',
|
||||
@@ -242,14 +414,13 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
],
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
await finishManualRequest(true)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,10 +431,9 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
const restoreDefaultChannel = async () => {
|
||||
setPending(true)
|
||||
try {
|
||||
await setExtraParams()
|
||||
const res = await checkForUpdateAsync()
|
||||
const res = await checkForOTAUpdate()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
await fetchOTAUpdate()
|
||||
await reloadAsync()
|
||||
} else {
|
||||
Alert.alert(
|
||||
@@ -351,14 +521,14 @@ export function useOTAUpdates() {
|
||||
const setCheckTimeout = useCallback(() => {
|
||||
timeout.current = setTimeout(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
if (isManualRequestPending()) return
|
||||
|
||||
logger.debug('Checking for update...')
|
||||
const res = await checkForUpdateAsync()
|
||||
const res = await checkForOTAUpdate()
|
||||
|
||||
if (res.isAvailable) {
|
||||
logger.debug('Attempting to fetch update...')
|
||||
await fetchUpdateAsync()
|
||||
await fetchOTAUpdate()
|
||||
} else {
|
||||
logger.debug('No update available.')
|
||||
}
|
||||
@@ -419,6 +589,7 @@ export function useOTAUpdates() {
|
||||
// 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 (isManualRequestPending()) return
|
||||
if (isUpdatePending) {
|
||||
await reloadAsync({
|
||||
reloadScreenOptions: splash(t.scheme),
|
||||
|
||||
@@ -5,6 +5,10 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
tryApplyUpdate: async (
|
||||
_channel: string,
|
||||
_declaredAppVersion?: string | null,
|
||||
_expected?: {
|
||||
sourceCommit?: string | null
|
||||
publicationId?: string | null
|
||||
},
|
||||
) => {},
|
||||
restoreDefaultChannel: async () => {},
|
||||
isCurrentlyRunningPullRequestDeployment: false,
|
||||
|
||||
@@ -4,7 +4,11 @@ import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useMutation, useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {splash} from '#/lib/hooks/useOTAUpdates'
|
||||
import {
|
||||
checkForOTAUpdate,
|
||||
fetchOTAUpdate,
|
||||
splash,
|
||||
} from '#/lib/hooks/useOTAUpdates'
|
||||
import {useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
@@ -25,7 +29,7 @@ export function OTAInfo() {
|
||||
} = useQuery({
|
||||
queryKey: ['ota-info'],
|
||||
queryFn: async () => {
|
||||
const status = await Updates.checkForUpdateAsync()
|
||||
const status = await checkForOTAUpdate()
|
||||
return status.isAvailable
|
||||
},
|
||||
})
|
||||
@@ -33,7 +37,7 @@ export function OTAInfo() {
|
||||
const {mutate: fetchAndLaunchUpdate, isPending: isPendingUpdate} =
|
||||
useMutation({
|
||||
mutationFn: async () => {
|
||||
await Updates.fetchUpdateAsync()
|
||||
await fetchOTAUpdate()
|
||||
await Updates.reloadAsync({
|
||||
reloadScreenOptions: splash(t.scheme),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user