diff --git a/app.json b/app.json index 9016a364..16061a24 100644 --- a/app.json +++ b/app.json @@ -5,6 +5,9 @@ "scheme": "bluesky", "owner": "blueskysocial", "version": "1.29.0", + "runtimeVersion": { + "policy": "appVersion" + }, "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -63,9 +66,15 @@ "web": { "favicon": "./assets/favicon.png" }, + "updates": { + "enabled": true, + "fallbackToCacheTimeout": 1000, + "url": "https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302" + }, "plugins": [ "expo-localization", "react-native-background-fetch", + "sentry-expo", [ "expo-build-properties", { @@ -79,8 +88,7 @@ { "username": "blueskysocial" } - ], - "sentry-expo" + ] ], "extra": { "eas": { diff --git a/docs/build.md b/docs/build.md index 715018ed..a4b03fc7 100644 --- a/docs/build.md +++ b/docs/build.md @@ -115,3 +115,8 @@ upload-sourcemaps \ --dist \ --rewrite \ dist/bundles/main.jsbundle dist/bundles/ios-.map` + +### OTA updates +To create OTA updates, run `eas update` along with the `--branch` flag to indicate which branch you want to push the update to, and the `--message` flag to indicate a message for yourself and your team that shows up on https://expo.dev. ALl the channels (which make up the options for the `--branch` flag) are given in `eas.json`. [See more here](https://docs.expo.dev/eas-update/getting-started/) + +The clients which can receive an OTA update is governed by the `runtimeVersion` property in `app.json`. Right now, it is set so that only apps with the same `appVersion` (same as `version` property in `app.json`) can receive the update and install it. However, we can manually set `"runtimeVersion": "1.34.0"` or anything along those lines as well. This is useful if very little native code changes from update-to-update. If we are manually setting `runtimeVersion`, we should increment the version each time native code is changed. [See more here](https://docs.expo.dev/eas-update/runtime-versions/) diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts index a365e7e9..3f026d3f 100644 --- a/src/lib/app-info.ts +++ b/src/lib/app-info.ts @@ -1,2 +1,5 @@ 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})` diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts new file mode 100644 index 00000000..ae603522 --- /dev/null +++ b/src/lib/hooks/useOTAUpdate.ts @@ -0,0 +1,74 @@ +import * as Updates from 'expo-updates' +import {useCallback, useEffect} from 'react' +import {AppState} from 'react-native' +import {useStores} from 'state/index' + +export function useOTAUpdate() { + const store = useStores() + + // HELPER FUNCTIONS + const showUpdatePopup = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Update Available', + message: + 'A new version of the app is available. Please update to continue using the app.', + onPressConfirm: async () => { + Updates.reloadAsync().catch(err => { + throw err + }) + }, + }) + }, [store.shell]) + const checkForUpdate = useCallback(async () => { + store.log.debug('useOTAUpdate: Checking for update...') + try { + // Check if new OTA update is available + const update = await Updates.checkForUpdateAsync() + // If updates aren't available stop the function execution + if (!update.isAvailable) { + return + } + // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch. + await Updates.fetchUpdateAsync() + // show a popup modal + showUpdatePopup() + } catch (e) { + console.error('useOTAUpdate: Error while checking for update', e) + store.log.error('useOTAUpdate: Error while checking for update', e) + } + }, [showUpdatePopup, store.log]) + const updateEventListener = useCallback( + (event: Updates.UpdateEvent) => { + store.log.debug('useOTAUpdate: Listening for update...') + if (event.type === Updates.UpdateEventType.ERROR) { + throw new Error(event.message) + } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) { + // Handle no update available + // do nothing + } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) { + // Handle update available + // open modal, ask for user confirmation, and reload the app + showUpdatePopup() + } + }, + [showUpdatePopup, store.log], + ) + + useEffect(() => { + // ADD EVENT LISTENERS + const updateEventSubscription = Updates.addListener(updateEventListener) + const appStateSubscription = AppState.addEventListener('change', state => { + if (state === 'active' && !__DEV__) { + checkForUpdate() + } + }) + + // REMOVE EVENT LISTENERS (CLEANUP) + return () => { + updateEventSubscription.remove() + appStateSubscription.remove() + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + // disable exhaustive deps because we don't want to run this effect again +} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 79889385..52be35a4 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -445,7 +445,7 @@ export const SettingsScreen = withAuthRequired( ) : null} - Build version {AppInfo.appVersion} + Build version {AppInfo.appVersion} {AppInfo.updateChannel} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index a6066b25..36f7442d 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -18,9 +18,11 @@ import {RoutesContainer, TabsNavigator} from '../../Navigation' import {isStateAtTabRoot} from 'lib/routes/helpers' import {isAndroid} from 'platform/detection' import {SafeAreaProvider} from 'react-native-safe-area-context' +import {useOTAUpdate} from 'lib/hooks/useOTAUpdate' const ShellInner = observer(() => { const store = useStores() + useOTAUpdate() // this hook polls for OTA updates every few seconds const winDim = useWindowDimensions() const safeAreaInsets = useSafeAreaInsets() const containerPadding = React.useMemo(