Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/mobile/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { Drawers } from './Drawers'
import ErrorBoundary from './ErrorBoundary'
import { ThemeProvider } from './ThemeProvider'
import { initSentry, navigationIntegration } from './sentry'
import { useOtaStartupRestart } from './useOtaStartupRestart'

initSentry()

Expand All @@ -52,6 +53,8 @@ if (Platform.OS === 'android') {
incrementSessionCount()

const App = () => {
useOtaStartupRestart()

useEffectOnce(() => {
subscribeToNetworkStatusUpdates()
TrackPlayer.setupPlayer({ autoHandleInterruptions: true })
Expand Down
124 changes: 124 additions & 0 deletions packages/mobile/src/app/useOtaStartupRestart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Downloads and applies OTA updates on cold start.
*
* On every app launch (including from a push notification tap):
* 1. If a previously-downloaded update is pending → restart immediately
* 2. Otherwise, sync with the server to check/download/install a new update.
* If the sync completes within the timeout, restart to apply it. If it
* takes too long, the update is staged as pending and will be applied on
* the next cold start (or via the banner's Restart button).
*
* The splash screen waits on `otaStartupComplete` before dismissing, so any
* restart happens while the splash is still visible. Push-notification deep
* links survive the restart because `getInitialNotification()` is held at the
* native layer and persists across JS-only CodePush restarts.
*/

import { useEffect, useRef } from 'react'

import CodePush from '@bravemobile/react-native-code-push'

import { isOtaEnabled } from './ota-updates'

/** Max time (ms) to wait for an OTA download before giving up and loading normally. */
const OTA_STARTUP_TIMEOUT_MS = 10_000

let _resolveStartup: () => void

/**
* Resolves when the cold-start OTA check is complete (no update, update
* applied, or timeout). The splash screen gates on this so it stays
* visible during any OTA restart.
*/
export const otaStartupComplete = new Promise<void>((resolve) => {
_resolveStartup = resolve
})

// Safety net: always resolve even if the hook never executes (e.g. crash
// during provider initialization). Prevents the app from hanging forever.
setTimeout(_resolveStartup, OTA_STARTUP_TIMEOUT_MS + 5_000)

export function useOtaStartupRestart() {
const didRunRef = useRef(false)

useEffect(() => {
if (didRunRef.current) return
didRunRef.current = true

if (!isOtaEnabled()) {
_resolveStartup()
return
}

let expired = false
const timer = setTimeout(() => {
expired = true
_resolveStartup()
console.warn(
`[OTA] Cold-start sync exceeded ${OTA_STARTUP_TIMEOUT_MS}ms — update will apply on next restart`
)
}, OTA_STARTUP_TIMEOUT_MS)

;(async () => {
try {
// 1. Check for an already-downloaded pending update (instant, no network)
const pending = await CodePush.getUpdateMetadata(
CodePush.UpdateState.PENDING
)
if (pending) {
clearTimeout(timer)
console.warn(
`[OTA] Pending update on cold start — restarting (appVersion=${pending.appVersion})`
)
CodePush.allowRestart()
CodePush.restartApp(true)
// If restart didn't take effect, resolve so the app isn't stuck
_resolveStartup()
return
}

// 2. No pending update — sync to check/download/install new updates.
// Use ON_NEXT_RESTART so CodePush doesn't auto-restart at an
// unpredictable time; we control the restart timing below.
const status = await CodePush.sync(
{
installMode: CodePush.InstallMode.ON_NEXT_RESTART,
mandatoryInstallMode: CodePush.InstallMode.ON_NEXT_RESTART
},
(syncStatus) => {
if (
syncStatus !== CodePush.SyncStatus.UP_TO_DATE &&
syncStatus !== CodePush.SyncStatus.SYNC_IN_PROGRESS
) {
console.warn(`[OTA] Cold-start sync status: ${syncStatus}`)
}
}
)

clearTimeout(timer)

// 3. If the update was downloaded+installed before the timeout,
// restart now (splash screen is held open via otaStartupComplete).
if (status === CodePush.SyncStatus.UPDATE_INSTALLED && !expired) {
console.warn(
'[OTA] Update installed on cold start within timeout — restarting'
)
CodePush.allowRestart()
CodePush.restartApp(true)
// If restart didn't take effect, resolve so the app isn't stuck
_resolveStartup()
return
}

// No update found or timeout already expired — let the app load
_resolveStartup()
} catch (e) {
clearTimeout(timer)
_resolveStartup()
console.warn('[OTA] Cold-start sync failed', e)
}
})()

return () => clearTimeout(timer)
}, [])
}
17 changes: 16 additions & 1 deletion packages/mobile/src/screens/root-screen/RootScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { Platform } from 'react-native'
import { useDispatch, useSelector } from 'react-redux'

import { otaStartupComplete } from 'app/app/useOtaStartupRestart'
import useAppState from 'app/hooks/useAppState'
import { useDrawer } from 'app/hooks/useDrawer'
import { useNavigation } from 'app/hooks/useNavigation'
Expand Down Expand Up @@ -76,6 +77,7 @@ export const RootScreen = () => {
const welcomeModalShown = useSelector(getWelcomeModalShown)
const isAndroid = Platform.OS === MobileOS.ANDROID

const [isOtaReady, setIsOtaReady] = useState(false)
const [isLoaded, setIsLoaded] = useState(false)
const [isSplashScreenDismissed, setIsSplashScreenDismissed] = useState(false)
const { navigate } = useNavigation()
Expand All @@ -90,17 +92,30 @@ export const RootScreen = () => {

useResetNotificationBadgeCount()

// Wait for the cold-start OTA check to finish (or time out) so any
// restart happens while the splash screen is still visible.
useEffect(() => {
let cancelled = false
otaStartupComplete.then(() => {
if (!cancelled) setIsOtaReady(true)
})
return () => {
cancelled = true
}
}, [])

useEffect(() => {
if (
!isLoaded &&
isOtaReady &&
(accountStatus === Status.SUCCESS || accountStatus === Status.ERROR)
) {
// Reset the player when the app is loaded for the first time. Fixes an issue
// where after a crash, the player would persist the previous state. PAY-1412.
dispatch(reset({ shouldAutoplay: false }))
setIsLoaded(true)
}
}, [accountStatus, setIsLoaded, isLoaded, dispatch])
}, [accountStatus, setIsLoaded, isLoaded, isOtaReady, dispatch])

// Connect to chats websockets and prefetch chats
useEffect(() => {
Expand Down