about summary refs log tree commit diff
path: root/src/lib/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/hooks')
-rw-r--r--src/lib/hooks/useIntentHandler.ts23
-rw-r--r--src/lib/hooks/useOTAUpdates.ts166
2 files changed, 157 insertions, 32 deletions
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
index 6b1083aa4..f55217e56 100644
--- a/src/lib/hooks/useIntentHandler.ts
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -1,8 +1,9 @@
 import React from 'react'
+import {Alert} from 'react-native'
 import * as Linking from 'expo-linking'
 
 import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
-import {logEvent} from '#/lib/statsig/statsig'
+import {logger} from '#/logger'
 import {isNative} from '#/platform/detection'
 import {useSession} from '#/state/session'
 import {useCloseAllActiveElements} from '#/state/util'
@@ -12,8 +13,10 @@ import {
 } from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
 import {useIntentDialogs} from '#/components/intents/IntentDialogs'
 import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
+import {IS_TESTFLIGHT} from '../app-info.web'
+import {useApplyPullRequestOTAUpdate} from './useOTAUpdates'
 
-type IntentType = 'compose' | 'verify-email' | 'age-assurance'
+type IntentType = 'compose' | 'verify-email' | 'age-assurance' | 'apply-ota'
 
 const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
 
@@ -27,12 +30,13 @@ export function useIntentHandler() {
   const ageAssuranceRedirectDialogControl =
     useAgeAssuranceRedirectDialogControl()
   const {currentAccount} = useSession()
+  const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
 
   React.useEffect(() => {
     const handleIncomingURL = (url: string) => {
       const referrerInfo = Referrer.getReferrerInfo()
       if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
-        logEvent('deepLink:referrerReceived', {
+        logger.metric('deepLink:referrerReceived', {
           to: url,
           referrer: referrerInfo?.referrer,
           hostname: referrerInfo?.hostname,
@@ -92,6 +96,18 @@ export function useIntentHandler() {
           }
           return
         }
+        case 'apply-ota': {
+          if (!isNative || !IS_TESTFLIGHT) {
+            return
+          }
+
+          const channel = params.get('channel')
+          if (!channel) {
+            Alert.alert('Error', 'No channel provided to look for.')
+          } else {
+            tryApplyUpdate(channel)
+          }
+        }
         default: {
           return
         }
@@ -111,6 +127,7 @@ export function useIntentHandler() {
     verifyEmailIntent,
     ageAssuranceRedirectDialogControl,
     currentAccount,
+    tryApplyUpdate,
   ])
 }
 
diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts
index 731406dce..72f215fa9 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 tryApplyUpdate = 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 Available',
+          `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 Available',
+          `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 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?`,
+          [
+            {
+              text: 'No',
+              style: 'cancel',
+            },
+            {
+              text: 'Relaunch',
+              style: 'default',
+              onPress: async () => {
+                await reloadAsync()
+              },
+            },
+          ],
+        )
+      } else {
+        Alert.alert(
+          'No Deployment Available',
+          `No new deployments of ${channel} are currently available for your current native build.`,
+        )
+      }
+    }
+    setPending(false)
+  }
+
+  const revertToEmbedded = async () => {
+    try {
+      await updateTestflight()
+    } catch (e: any) {
+      logger.error('Internal OTA Update Error', {error: `${e}`})
+    }
+  }
+
+  return {
+    tryApplyUpdate,
+    revertToEmbedded,
+    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 current 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])
 }