diff --git a/app.config.js b/app.config.js index cbe028273d..e065d0d737 100644 --- a/app.config.js +++ b/app.config.js @@ -190,7 +190,6 @@ module.exports = function (_config) { } : undefined, checkAutomatically: 'NEVER', - channel: UPDATES_CHANNEL, }, plugins: [ 'expo-video', diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts index 731406dcec..d57b00f78e 100644 --- a/src/lib/hooks/useOTAUpdates.ts +++ b/src/lib/hooks/useOTAUpdates.ts @@ -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() - 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]) } diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 4d10a9d0db..5d20aeffd5 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -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-'", + [ + { + 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 ( <> Clear all storage data (restart after this) + {isNative ? ( + + + Apply Pull Request + + + ) : null} + {isNative && isCurrentlyRunningPullRequestDeployment ? ( + + + Unapply Pull Request {currentChannel} + + + ) : null} ) }