about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/app-info.ts10
-rw-r--r--src/lib/hooks/useOTAUpdates.ts142
2 files changed, 149 insertions, 3 deletions
diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts
index 3f026d3fe..3071e031b 100644
--- a/src/lib/app-info.ts
+++ b/src/lib/app-info.ts
@@ -1,5 +1,9 @@
 import VersionNumber from 'react-native-version-number'
-import * as Updates from 'expo-updates'
-export const updateChannel = Updates.channel
 
-export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})`
+export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
+export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
+
+const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
+export const appVersion = `${VersionNumber.appVersion} (${
+  VersionNumber.buildVersion
+}, ${IS_DEV ? 'development' : UPDATES_CHANNEL})`
diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts
new file mode 100644
index 000000000..181f0b2c6
--- /dev/null
+++ b/src/lib/hooks/useOTAUpdates.ts
@@ -0,0 +1,142 @@
+import React from 'react'
+import {Alert, AppState, AppStateStatus} from 'react-native'
+import app from 'react-native-version-number'
+import {
+  checkForUpdateAsync,
+  fetchUpdateAsync,
+  isEnabled,
+  reloadAsync,
+  setExtraParamAsync,
+  useUpdates,
+} from 'expo-updates'
+
+import {logger} from '#/logger'
+import {IS_TESTFLIGHT} from 'lib/app-info'
+import {isIOS} from 'platform/detection'
+
+const MINIMUM_MINIMIZE_TIME = 15 * 60e3
+
+async function setExtraParams() {
+  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
+    `${app.buildVersion}`,
+  )
+  await setExtraParamAsync(
+    'channel',
+    IS_TESTFLIGHT ? 'testflight' : 'production',
+  )
+}
+
+export function useOTAUpdates() {
+  const appState = React.useRef<AppStateStatus>('active')
+  const lastMinimize = React.useRef(0)
+  const ranInitialCheck = React.useRef(false)
+  const timeout = React.useRef<NodeJS.Timeout>()
+  const {isUpdatePending} = useUpdates()
+
+  const setCheckTimeout = React.useCallback(() => {
+    timeout.current = setTimeout(async () => {
+      try {
+        await setExtraParams()
+
+        logger.debug('Checking for update...')
+        const res = await checkForUpdateAsync()
+
+        if (res.isAvailable) {
+          logger.debug('Attempting to fetch update...')
+          await fetchUpdateAsync()
+        } else {
+          logger.debug('No update available.')
+        }
+      } catch (e) {
+        logger.warn('OTA Update Error', {error: `${e}`})
+      }
+    }, 10e3)
+  }, [])
+
+  const onIsTestFlight = React.useCallback(() => {
+    setTimeout(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()
+                },
+              },
+            ],
+          )
+        }
+      } catch (e: any) {
+        // No need to handle
+      }
+    }, 3e3)
+  }, [])
+
+  React.useEffect(() => {
+    // 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
+    // immediately.
+    if (IS_TESTFLIGHT) {
+      onIsTestFlight()
+      return
+    } else if (!isEnabled || __DEV__ || ranInitialCheck.current) {
+      // Development client shouldn't check for updates at all, so we skip that here.
+      return
+    }
+
+    setCheckTimeout()
+    ranInitialCheck.current = true
+  }, [onIsTestFlight, setCheckTimeout])
+
+  // After the app has been minimized for 30 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
+
+    const subscription = AppState.addEventListener(
+      'change',
+      async nextAppState => {
+        if (
+          appState.current.match(/inactive|background/) &&
+          nextAppState === 'active'
+        ) {
+          // If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since
+          // chances are that there isn't anything important going on in the current session.
+          if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
+            if (isUpdatePending) {
+              await reloadAsync()
+            } else {
+              setCheckTimeout()
+            }
+          }
+        } else {
+          lastMinimize.current = Date.now()
+        }
+
+        appState.current = nextAppState
+      },
+    )
+
+    return () => {
+      clearTimeout(timeout.current)
+      subscription.remove()
+    }
+  }, [isUpdatePending, setCheckTimeout])
+}