handle pr ota failures
This commit is contained in:
@@ -275,6 +275,10 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: read
|
contents: read
|
||||||
|
outputs:
|
||||||
|
release-version: ${{ steps.env.outputs.release-version }}
|
||||||
|
ios-build-number: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }}
|
||||||
|
android-build-number: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
|
||||||
steps:
|
steps:
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -342,11 +346,18 @@ jobs:
|
|||||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||||
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
|
||||||
|
|
||||||
|
- name: 🔢 Get native build numbers
|
||||||
|
id: build-info
|
||||||
|
run: bash scripts/setGitHubOutput.sh
|
||||||
|
|
||||||
- name: 🚀 Publish OTA to denis (S3)
|
- name: 🚀 Publish OTA to denis (S3)
|
||||||
run: pnpm use-build-number bash scripts/denisPublish.sh
|
run: pnpm use-build-number bash scripts/denisPublish.sh
|
||||||
env:
|
env:
|
||||||
RUNTIME_VERSION: ''
|
RUNTIME_VERSION: ''
|
||||||
CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }}
|
CHANNEL_NAME: pull-request-${{ github.event.pull_request.number }}
|
||||||
|
# Pin the publish to the same values exposed in the install link.
|
||||||
|
BSKY_IOS_BUILD_NUMBER: ${{ steps.build-info.outputs.BSKY_IOS_BUILD_NUMBER }}
|
||||||
|
BSKY_ANDROID_VERSION_CODE: ${{ steps.build-info.outputs.BSKY_ANDROID_VERSION_CODE }}
|
||||||
|
|
||||||
comment-pr-ota:
|
comment-pr-ota:
|
||||||
name: Comment PR OTA install link
|
name: Comment PR OTA install link
|
||||||
@@ -362,6 +373,6 @@ jobs:
|
|||||||
message: |
|
message: |
|
||||||
The OTA deployment for this PR was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
|
The OTA deployment for this PR was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
|
||||||
|
|
||||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.pull_request.number }}" width="300" height="300" alt="QR code for the PR OTA deployment">
|
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.publish-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.publish-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.publish-pr-ota.outputs.android-build-number }}" width="300" height="300" alt="QR code for the PR OTA deployment">
|
||||||
|
|
||||||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}`
|
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.pull_request.number }}&releaseVersion=${{ needs.publish-pr-ota.outputs.release-version }}&iosBuildNumber=${{ needs.publish-pr-ota.outputs.ios-build-number }}&androidBuildNumber=${{ needs.publish-pr-ota.outputs.android-build-number }}`
|
||||||
|
|||||||
@@ -707,7 +707,7 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
"typescript/no-misused-promises": {
|
"typescript/no-misused-promises": {
|
||||||
"count": 4
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/lib/hooks/usePermissions.ts": {
|
"src/lib/hooks/usePermissions.ts": {
|
||||||
|
|||||||
@@ -84,10 +84,18 @@ export function useIntentHandler() {
|
|||||||
}
|
}
|
||||||
case 'apply-ota': {
|
case 'apply-ota': {
|
||||||
const channel = params.get('channel')
|
const channel = params.get('channel')
|
||||||
|
const releaseVersion = params.get('releaseVersion')
|
||||||
|
const buildNumber = params.get(
|
||||||
|
IS_IOS ? 'iosBuildNumber' : 'androidBuildNumber',
|
||||||
|
)
|
||||||
|
const appVersion =
|
||||||
|
releaseVersion && buildNumber
|
||||||
|
? `${releaseVersion}.${buildNumber}`
|
||||||
|
: null
|
||||||
if (!channel) {
|
if (!channel) {
|
||||||
Alert.alert('Error', 'No channel provided to look for.')
|
Alert.alert('Error', 'No channel provided to look for.')
|
||||||
} else {
|
} else {
|
||||||
tryApplyUpdate(channel)
|
tryApplyUpdate(channel, appVersion)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import {Alert} from 'react-native'
|
||||||
|
import {
|
||||||
|
checkForUpdateAsync,
|
||||||
|
fetchUpdateAsync,
|
||||||
|
reloadAsync,
|
||||||
|
setExtraParamAsync,
|
||||||
|
useUpdates,
|
||||||
|
} from 'expo-updates'
|
||||||
|
import {act, renderHook, waitFor} from '@testing-library/react-native'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {device} from '#/storage'
|
||||||
|
import {
|
||||||
|
useApplyPullRequestOTAUpdate,
|
||||||
|
useOTAUpdateRecovery,
|
||||||
|
} from './useOTAUpdates'
|
||||||
|
|
||||||
|
jest.mock('expo-updates', () => ({
|
||||||
|
checkForUpdateAsync: jest.fn(),
|
||||||
|
fetchUpdateAsync: jest.fn(),
|
||||||
|
isEnabled: true,
|
||||||
|
reloadAsync: jest.fn(),
|
||||||
|
setExtraParamAsync: jest.fn(),
|
||||||
|
UpdateCheckResultNotAvailableReason: {
|
||||||
|
UPDATE_PREVIOUSLY_FAILED: 'updatePreviouslyFailed',
|
||||||
|
},
|
||||||
|
useUpdates: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('#/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('#/storage', () => ({
|
||||||
|
device: {
|
||||||
|
get: jest.fn(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const currentUpdate = {
|
||||||
|
channel: 'testflight',
|
||||||
|
emergencyLaunchReason: null,
|
||||||
|
isEmbeddedLaunch: false,
|
||||||
|
isEmergencyLaunch: false,
|
||||||
|
updateId: 'current-update',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
jest.mocked(useUpdates).mockReturnValue({
|
||||||
|
currentlyRunning: currentUpdate,
|
||||||
|
} as ReturnType<typeof useUpdates>)
|
||||||
|
jest.mocked(setExtraParamAsync).mockResolvedValue(undefined)
|
||||||
|
jest.mocked(reloadAsync).mockResolvedValue(undefined)
|
||||||
|
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useApplyPullRequestOTAUpdate', () => {
|
||||||
|
it('warns before applying an OTA built for a different app version', async () => {
|
||||||
|
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||||
|
isAvailable: true,
|
||||||
|
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||||
|
jest.mocked(fetchUpdateAsync).mockResolvedValue({
|
||||||
|
isNew: true,
|
||||||
|
isRollBackToEmbedded: false,
|
||||||
|
manifest: {id: 'mismatched-update'},
|
||||||
|
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
|
||||||
|
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||||
|
|
||||||
|
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
|
||||||
|
|
||||||
|
expect(checkForUpdateAsync).not.toHaveBeenCalled()
|
||||||
|
expect(Alert.alert).toHaveBeenCalledWith(
|
||||||
|
'App Version Mismatch',
|
||||||
|
expect.stringContaining('Applying it anyway may cause'),
|
||||||
|
expect.arrayContaining([expect.objectContaining({text: 'Apply Anyway'})]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||||
|
act(() => buttons?.[1].onPress?.())
|
||||||
|
|
||||||
|
await waitFor(() => expect(reloadAsync).toHaveBeenCalled())
|
||||||
|
expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], {
|
||||||
|
attemptedAt: expect.any(Number),
|
||||||
|
channel: 'pull-request-123',
|
||||||
|
updateId: 'mismatched-update',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('informs the user when checking for an OTA fails', async () => {
|
||||||
|
jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline'))
|
||||||
|
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||||
|
|
||||||
|
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||||
|
|
||||||
|
expect(Alert.alert).toHaveBeenCalledWith(
|
||||||
|
'Update Check Failed',
|
||||||
|
expect.stringContaining('Error: offline'),
|
||||||
|
)
|
||||||
|
expect(result.current.pending).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('informs the user when downloading an OTA fails', async () => {
|
||||||
|
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||||
|
isAvailable: true,
|
||||||
|
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||||
|
jest
|
||||||
|
.mocked(fetchUpdateAsync)
|
||||||
|
.mockRejectedValue(new Error('download failed'))
|
||||||
|
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||||
|
|
||||||
|
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||||
|
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||||
|
|
||||||
|
act(() => buttons?.[1].onPress?.())
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(Alert.alert).toHaveBeenLastCalledWith(
|
||||||
|
'Update Failed',
|
||||||
|
expect.stringContaining('Error: download failed'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(device.set).not.toHaveBeenCalled()
|
||||||
|
expect(result.current.pending).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the recovery marker and informs the user when reloading fails', async () => {
|
||||||
|
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>>)
|
||||||
|
jest.mocked(reloadAsync).mockRejectedValue(new Error('reload failed'))
|
||||||
|
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||||
|
|
||||||
|
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||||
|
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||||
|
|
||||||
|
act(() => buttons?.[1].onPress?.())
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(Alert.alert).toHaveBeenLastCalledWith(
|
||||||
|
'Update Failed',
|
||||||
|
expect.stringContaining('Error: reload failed'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], {
|
||||||
|
attemptedAt: expect.any(Number),
|
||||||
|
channel: 'pull-request-123',
|
||||||
|
updateId: 'new-update',
|
||||||
|
})
|
||||||
|
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||||
|
expect(result.current.pending).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useOTAUpdateRecovery', () => {
|
||||||
|
it('informs the user when Expo fell back from the attempted OTA', async () => {
|
||||||
|
jest.mocked(device.get).mockReturnValue({
|
||||||
|
attemptedAt: Date.now(),
|
||||||
|
channel: 'pull-request-123',
|
||||||
|
updateId: 'failed-update',
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useOTAUpdateRecovery())
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(Alert.alert).toHaveBeenCalledWith(
|
||||||
|
'Update Failed',
|
||||||
|
expect.stringContaining('PR #123 deployment could not start'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
'Custom OTA Update Failed to Launch',
|
||||||
|
expect.objectContaining({attemptedUpdateId: 'failed-update'}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('silently clears the marker when the attempted OTA launched', () => {
|
||||||
|
jest.mocked(device.get).mockReturnValue({
|
||||||
|
attemptedAt: Date.now(),
|
||||||
|
channel: 'pull-request-123',
|
||||||
|
updateId: currentUpdate.updateId,
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useOTAUpdateRecovery())
|
||||||
|
|
||||||
|
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||||
|
expect(Alert.alert).not.toHaveBeenCalled()
|
||||||
|
expect(logger.error).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('silently clears a stale marker from an older OTA bundle', () => {
|
||||||
|
jest.mocked(device.get).mockReturnValue({
|
||||||
|
attemptedAt: Date.now() - 10 * 60e3,
|
||||||
|
channel: 'pull-request-123',
|
||||||
|
updateId: 'older-update',
|
||||||
|
})
|
||||||
|
|
||||||
|
renderHook(() => useOTAUpdateRecovery())
|
||||||
|
|
||||||
|
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||||
|
expect(Alert.alert).not.toHaveBeenCalled()
|
||||||
|
expect(logger.error).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
+148
-21
@@ -7,14 +7,17 @@ import {
|
|||||||
isEnabled,
|
isEnabled,
|
||||||
reloadAsync,
|
reloadAsync,
|
||||||
setExtraParamAsync,
|
setExtraParamAsync,
|
||||||
|
UpdateCheckResultNotAvailableReason,
|
||||||
useUpdates,
|
useUpdates,
|
||||||
} from 'expo-updates'
|
} from 'expo-updates'
|
||||||
|
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {IS_IOS, IS_TESTFLIGHT} from '#/env'
|
import {APP_VERSION, IS_IOS, IS_TESTFLIGHT} from '#/env'
|
||||||
|
import {device} from '#/storage'
|
||||||
|
|
||||||
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
||||||
|
const OTA_RECOVERY_WINDOW = 5 * 60e3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The channel this native build is expected to receive updates from. Anything
|
* The channel this native build is expected to receive updates from. Anything
|
||||||
@@ -28,6 +31,11 @@ const DEFAULT_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
|||||||
*/
|
*/
|
||||||
const STANDARD_CHANNELS = ['production', 'testflight', 'development']
|
const STANDARD_CHANNELS = ['production', 'testflight', 'development']
|
||||||
|
|
||||||
|
function getDeploymentName(channel: string) {
|
||||||
|
const pullRequestNumber = channel.match(/^pull-request-(\d+)$/)?.[1]
|
||||||
|
return pullRequestNumber ? `PR #${pullRequestNumber}` : channel
|
||||||
|
}
|
||||||
|
|
||||||
async function setExtraParams() {
|
async function setExtraParams() {
|
||||||
await setExtraParamAsync(
|
await setExtraParamAsync(
|
||||||
IS_IOS ? 'ios-build-number' : 'android-build-number',
|
IS_IOS ? 'ios-build-number' : 'android-build-number',
|
||||||
@@ -89,36 +97,116 @@ export function useApplyPullRequestOTAUpdate() {
|
|||||||
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
|
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
|
||||||
)
|
)
|
||||||
|
|
||||||
const tryApplyUpdate = async (channel: string) => {
|
const tryApplyUpdate = async (
|
||||||
setPending(true)
|
channel: string,
|
||||||
await setExtraParamsPullRequest(channel)
|
declaredAppVersion?: string | null,
|
||||||
const res = await checkForUpdateAsync()
|
) => {
|
||||||
if (res.isAvailable) {
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declaredAppVersion && declaredAppVersion !== APP_VERSION) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
'Deployment Available',
|
'App Version Mismatch',
|
||||||
`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?`,
|
`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: 'No',
|
text: 'Cancel',
|
||||||
style: 'cancel',
|
style: 'cancel',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Relaunch',
|
text: 'Apply Anyway',
|
||||||
style: 'default',
|
style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: applyUpdate,
|
||||||
await fetchUpdateAsync()
|
|
||||||
await reloadAsync()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
} else {
|
return
|
||||||
Alert.alert(
|
}
|
||||||
'No Deployment Available',
|
|
||||||
`No new deployments of ${channel} are currently available for your current native build.`,
|
setPending(true)
|
||||||
)
|
try {
|
||||||
|
if (!(await checkForDeployment())) 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)
|
||||||
}
|
}
|
||||||
setPending(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,6 +249,45 @@ export function useApplyPullRequestOTAUpdate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 === currentlyRunning.updateId) 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() {
|
export function useOTAUpdates() {
|
||||||
const shouldReceiveUpdates = isEnabled && !__DEV__
|
const shouldReceiveUpdates = isEnabled && !__DEV__
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
export function useOTAUpdates() {}
|
export function useOTAUpdates() {}
|
||||||
|
export function useOTAUpdateRecovery() {}
|
||||||
export function useApplyPullRequestOTAUpdate() {
|
export function useApplyPullRequestOTAUpdate() {
|
||||||
return {
|
return {
|
||||||
tryApplyUpdate: async (_channel: string) => {},
|
tryApplyUpdate: async (
|
||||||
|
_channel: string,
|
||||||
|
_declaredAppVersion?: string | null,
|
||||||
|
) => {},
|
||||||
restoreDefaultChannel: async () => {},
|
restoreDefaultChannel: async () => {},
|
||||||
isCurrentlyRunningPullRequestDeployment: false,
|
isCurrentlyRunningPullRequestDeployment: false,
|
||||||
isCurrentlyRunningNonStandardChannel: false,
|
isCurrentlyRunningNonStandardChannel: false,
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ export type Device = {
|
|||||||
activitySubscriptionsNudged?: boolean
|
activitySubscriptionsNudged?: boolean
|
||||||
threadgateNudged?: boolean
|
threadgateNudged?: boolean
|
||||||
inviteFriendsFollowersPromoDismissed?: boolean
|
inviteFriendsFollowersPromoDismissed?: boolean
|
||||||
|
pendingOTAUpdate?: {
|
||||||
|
attemptedAt: number
|
||||||
|
channel: string
|
||||||
|
updateId: string
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Selected color theme for the Invite Friends QR card.
|
* Selected color theme for the Invite Friends QR card.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {useNavigation, useNavigationState} from '@react-navigation/native'
|
|||||||
import {useDedupe} from '#/lib/hooks/useDedupe'
|
import {useDedupe} from '#/lib/hooks/useDedupe'
|
||||||
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||||
import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
|
import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
|
||||||
|
import {useOTAUpdateRecovery} from '#/lib/hooks/useOTAUpdates'
|
||||||
import {useNotificationsRegistration} from '#/lib/notifications/notifications'
|
import {useNotificationsRegistration} from '#/lib/notifications/notifications'
|
||||||
import {isStateAtTabRoot} from '#/lib/routes/helpers'
|
import {isStateAtTabRoot} from '#/lib/routes/helpers'
|
||||||
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
|
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
|
||||||
@@ -216,6 +217,7 @@ export function Shell() {
|
|||||||
const fullyExpandedCount = useDialogFullyExpandedCountContext()
|
const fullyExpandedCount = useDialogFullyExpandedCountContext()
|
||||||
|
|
||||||
useIntentHandler()
|
useIntentHandler()
|
||||||
|
useOTAUpdateRecovery()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSystemUITheme('theme', t)
|
setSystemUITheme('theme', t)
|
||||||
|
|||||||
Reference in New Issue
Block a user