handle pr ota failures
This commit is contained in:
@@ -275,6 +275,10 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
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:
|
||||
- name: ⬇️ Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -342,11 +346,18 @@ jobs:
|
||||
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
|
||||
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)
|
||||
run: pnpm use-build-number bash scripts/denisPublish.sh
|
||||
env:
|
||||
RUNTIME_VERSION: ''
|
||||
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:
|
||||
name: Comment PR OTA install link
|
||||
@@ -362,6 +373,6 @@ jobs:
|
||||
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:
|
||||
|
||||
<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
|
||||
},
|
||||
"typescript/no-misused-promises": {
|
||||
"count": 4
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/lib/hooks/usePermissions.ts": {
|
||||
|
||||
@@ -84,10 +84,18 @@ export function useIntentHandler() {
|
||||
}
|
||||
case 'apply-ota': {
|
||||
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) {
|
||||
Alert.alert('Error', 'No channel provided to look for.')
|
||||
} else {
|
||||
tryApplyUpdate(channel)
|
||||
tryApplyUpdate(channel, appVersion)
|
||||
}
|
||||
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,
|
||||
reloadAsync,
|
||||
setExtraParamAsync,
|
||||
UpdateCheckResultNotAvailableReason,
|
||||
useUpdates,
|
||||
} from 'expo-updates'
|
||||
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
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 OTA_RECOVERY_WINDOW = 5 * 60e3
|
||||
|
||||
/**
|
||||
* 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']
|
||||
|
||||
function getDeploymentName(channel: string) {
|
||||
const pullRequestNumber = channel.match(/^pull-request-(\d+)$/)?.[1]
|
||||
return pullRequestNumber ? `PR #${pullRequestNumber}` : channel
|
||||
}
|
||||
|
||||
async function setExtraParams() {
|
||||
await setExtraParamAsync(
|
||||
IS_IOS ? 'ios-build-number' : 'android-build-number',
|
||||
@@ -89,36 +97,116 @@ export function useApplyPullRequestOTAUpdate() {
|
||||
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
|
||||
)
|
||||
|
||||
const tryApplyUpdate = async (channel: string) => {
|
||||
setPending(true)
|
||||
await setExtraParamsPullRequest(channel)
|
||||
const res = await checkForUpdateAsync()
|
||||
if (res.isAvailable) {
|
||||
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 {
|
||||
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(
|
||||
'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?`,
|
||||
'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: 'No',
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Relaunch',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
await fetchUpdateAsync()
|
||||
await reloadAsync()
|
||||
},
|
||||
text: 'Apply Anyway',
|
||||
style: 'destructive',
|
||||
onPress: applyUpdate,
|
||||
},
|
||||
],
|
||||
)
|
||||
} else {
|
||||
Alert.alert(
|
||||
'No Deployment Available',
|
||||
`No new deployments of ${channel} are currently available for your current native build.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
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() {
|
||||
const shouldReceiveUpdates = isEnabled && !__DEV__
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
export function useOTAUpdates() {}
|
||||
export function useOTAUpdateRecovery() {}
|
||||
export function useApplyPullRequestOTAUpdate() {
|
||||
return {
|
||||
tryApplyUpdate: async (_channel: string) => {},
|
||||
tryApplyUpdate: async (
|
||||
_channel: string,
|
||||
_declaredAppVersion?: string | null,
|
||||
) => {},
|
||||
restoreDefaultChannel: async () => {},
|
||||
isCurrentlyRunningPullRequestDeployment: false,
|
||||
isCurrentlyRunningNonStandardChannel: false,
|
||||
|
||||
@@ -63,6 +63,11 @@ export type Device = {
|
||||
activitySubscriptionsNudged?: boolean
|
||||
threadgateNudged?: boolean
|
||||
inviteFriendsFollowersPromoDismissed?: boolean
|
||||
pendingOTAUpdate?: {
|
||||
attemptedAt: number
|
||||
channel: string
|
||||
updateId: string
|
||||
}
|
||||
/**
|
||||
* 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 {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||
import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler'
|
||||
import {useOTAUpdateRecovery} from '#/lib/hooks/useOTAUpdates'
|
||||
import {useNotificationsRegistration} from '#/lib/notifications/notifications'
|
||||
import {isStateAtTabRoot} from '#/lib/routes/helpers'
|
||||
import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
|
||||
@@ -216,6 +217,7 @@ export function Shell() {
|
||||
const fullyExpandedCount = useDialogFullyExpandedCountContext()
|
||||
|
||||
useIntentHandler()
|
||||
useOTAUpdateRecovery()
|
||||
|
||||
useEffect(() => {
|
||||
setSystemUITheme('theme', t)
|
||||
|
||||
Reference in New Issue
Block a user