add client ability to apply an update

This commit is contained in:
Hailey
2025-07-24 13:12:26 -07:00
parent 6eff54b761
commit 64fca6f90a
3 changed files with 193 additions and 31 deletions
-1
View File
@@ -190,7 +190,6 @@ module.exports = function (_config) {
}
: undefined,
checkAutomatically: 'NEVER',
channel: UPDATES_CHANNEL,
},
plugins: [
'expo-video',
+137 -29
View File
@@ -1,5 +1,5 @@
import React from 'react'
import {Alert, AppState, AppStateStatus} from 'react-native'
import {Alert, AppState, type AppStateStatus} from 'react-native'
import {nativeBuildVersion} from 'expo-application'
import {
checkForUpdateAsync,
@@ -29,6 +29,128 @@ async function setExtraParams() {
)
}
async function setExtraParamsPullRequest(channel: string) {
await setExtraParamAsync(
isIOS ? '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', channel)
}
async function updateTestflight() {
await setExtraParams()
const res = await checkForUpdateAsync()
if (res.isAvailable) {
await fetchUpdateAsync()
Alert.alert(
'Update Available',
'A new version of the app is available. Relaunch now?',
[
{
text: 'No',
style: 'cancel',
},
{
text: 'Relaunch',
style: 'default',
onPress: async () => {
await reloadAsync()
},
},
],
)
}
}
export function useApplyPullRequestOTAUpdate() {
const {currentlyRunning} = useUpdates()
const [pending, setPending] = React.useState(false)
const currentChannel = currentlyRunning?.channel
const isCurrentlyRunningPullRequestDeployment =
currentChannel?.startsWith('pull-request')
const onTryApplyUpdate = async (channel: string) => {
setPending(true)
if (currentChannel === channel) {
const res = await checkForUpdateAsync()
if (res.isAvailable) {
logger.debug('Attempting to fetch update...')
await fetchUpdateAsync()
Alert.alert(
'Deployment Availalbe',
`A new deployment of ${channel} is availalble. Relaunch now?`,
[
{
text: 'No',
style: 'cancel',
},
{
text: 'Relaunch',
style: 'default',
onPress: async () => {
await reloadAsync()
},
},
],
)
} else {
Alert.alert(
'No Deployment Availalbe',
`No new deployments of ${channel} are currently available for your current native build.`,
)
}
} else {
setExtraParamsPullRequest(channel)
const res = await checkForUpdateAsync()
if (res.isAvailable) {
Alert.alert(
'Deployment Availalbe',
`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?`,
[
{
text: 'No',
style: 'cancel',
},
{
text: 'Relaunch',
style: 'default',
onPress: async () => {
await reloadAsync()
},
},
],
)
} else {
Alert.alert(
'No Deployment Availalbe',
`No new deployments of ${channel} are currently available for your current native build.`,
)
}
}
setPending(false)
}
const onRevertToEmbedded = async () => {
try {
await updateTestflight()
} catch (e: any) {
logger.error('Internal OTA Update Error', {error: `${e}`})
}
}
return {
onTryApplyUpdate,
onRevertToEmbedded,
currentChannel,
isCurrentlyRunningPullRequestDeployment,
pending,
}
}
export function useOTAUpdates() {
const shouldReceiveUpdates = isEnabled && !__DEV__
@@ -36,7 +158,8 @@ export function useOTAUpdates() {
const lastMinimize = React.useRef(0)
const ranInitialCheck = React.useRef(false)
const timeout = React.useRef<NodeJS.Timeout>()
const {isUpdatePending} = useUpdates()
const {currentlyRunning, isUpdatePending} = useUpdates()
const currentChannel = currentlyRunning?.channel
const setCheckTimeout = React.useCallback(() => {
timeout.current = setTimeout(async () => {
@@ -60,36 +183,18 @@ export function useOTAUpdates() {
const onIsTestFlight = React.useCallback(async () => {
try {
await setExtraParams()
const res = await checkForUpdateAsync()
if (res.isAvailable) {
await fetchUpdateAsync()
Alert.alert(
'Update Available',
'A new version of the app is available. Relaunch now?',
[
{
text: 'No',
style: 'cancel',
},
{
text: 'Relaunch',
style: 'default',
onPress: async () => {
await reloadAsync()
},
},
],
)
}
await updateTestflight()
} catch (e: any) {
logger.error('Internal OTA Update Error', {error: `${e}`})
}
}, [])
React.useEffect(() => {
// We don't need to check anything if the curernt update is a PR update
if (currentChannel?.startsWith('pull-request')) {
return
}
// We use this setTimeout to allow Statsig to initialize before we check for an update
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
@@ -103,12 +208,15 @@ export function useOTAUpdates() {
setCheckTimeout()
ranInitialCheck.current = true
}, [onIsTestFlight, setCheckTimeout, shouldReceiveUpdates])
}, [onIsTestFlight, currentChannel, 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.
React.useEffect(() => {
if (!isEnabled) return
// We also don't start this timeout if the user is on a pull request update
if (!isEnabled || currentChannel?.startsWith('pull-request')) {
return
}
const subscription = AppState.addEventListener(
'change',
@@ -138,5 +246,5 @@ export function useOTAUpdates() {
clearTimeout(timeout.current)
subscription.remove()
}
}, [isUpdatePending, setCheckTimeout])
}, [isUpdatePending, currentChannel, setCheckTimeout])
}
+56 -1
View File
@@ -1,5 +1,5 @@
import {useState} from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native'
import {Alert, LayoutAnimation, Pressable, View} from 'react-native'
import {Linking} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {type AppBskyActorDefs, moderateProfile} from '@atproto/api'
@@ -12,12 +12,14 @@ import {useActorStatus} from '#/lib/actor-status'
import {IS_INTERNAL} from '#/lib/app-info'
import {HELP_DESK_URL} from '#/lib/constants'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates'
import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import * as persisted from '#/state/persisted'
import {clearStorage} from '#/state/persisted'
@@ -392,6 +394,41 @@ function DevOptions() {
setActyNotifNudged(false)
}
const {
onTryApplyUpdate,
onRevertToEmbedded,
isCurrentlyRunningPullRequestDeployment,
currentChannel,
} = useApplyPullRequestOTAUpdate()
const onPressApplyPullRequest = async () => {
Alert.prompt(
'Apply Pull Request',
"Enter a channel name below to apply a pull request. Channel names usually look like 'pull-request-<PULL REQUEST ID>'",
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Apply',
style: 'default',
onPress: async channel => {
if (!channel) {
Alert.alert('Error', 'No channel provided to look for.')
return
}
await onTryApplyUpdate(channel ?? '')
},
},
],
'plain-text',
isCurrentlyRunningPullRequestDeployment
? currentChannel
: 'pull-request-',
)
}
return (
<>
<SettingsList.PressableItem
@@ -452,6 +489,24 @@ function DevOptions() {
<Trans>Clear all storage data (restart after this)</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
{isNative ? (
<SettingsList.PressableItem
onPress={onPressApplyPullRequest}
label={_(msg`Apply Pull Request`)}>
<SettingsList.ItemText>
<Trans>Apply Pull Request</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
) : null}
{isNative && isCurrentlyRunningPullRequestDeployment ? (
<SettingsList.PressableItem
onPress={onRevertToEmbedded}
label={_(msg`Unapply Pull Request`)}>
<SettingsList.ItemText>
<Trans>Unapply Pull Request {currentChannel}</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
) : null}
</>
)
}