forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {Alert, AppState, type AppStateStatus} from 'react-native'
3import {nativeBuildVersion} from 'expo-application'
4import {
5 checkForUpdateAsync,
6 fetchUpdateAsync,
7 isEnabled,
8 reloadAsync,
9 setExtraParamAsync,
10 useUpdates,
11} from 'expo-updates'
12
13import {isNetworkError} from '#/lib/strings/errors'
14import {logger} from '#/logger'
15import {isAndroid, isIOS} from '#/platform/detection'
16import {IS_TESTFLIGHT} from '#/env'
17
18const MINIMUM_MINIMIZE_TIME = 15 * 60e3
19
20async function setExtraParams() {
21 await setExtraParamAsync(
22 isIOS ? 'ios-build-number' : 'android-build-number',
23 // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
24 // This just ensures it gets passed as a string
25 `${nativeBuildVersion}`,
26 )
27 await setExtraParamAsync(
28 'channel',
29 IS_TESTFLIGHT ? 'testflight' : 'production',
30 )
31}
32
33async function setExtraParamsPullRequest(channel: string) {
34 await setExtraParamAsync(
35 isIOS ? 'ios-build-number' : 'android-build-number',
36 // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
37 // This just ensures it gets passed as a string
38 `${nativeBuildVersion}`,
39 )
40 await setExtraParamAsync('channel', channel)
41}
42
43async function updateTestflight() {
44 await setExtraParams()
45
46 const res = await checkForUpdateAsync()
47 if (res.isAvailable) {
48 await fetchUpdateAsync()
49 Alert.alert(
50 'Update Available',
51 'A new version of the app is available. Relaunch now?',
52 [
53 {
54 text: 'No',
55 style: 'cancel',
56 },
57 {
58 text: 'Relaunch',
59 style: 'default',
60 onPress: async () => {
61 await reloadAsync()
62 },
63 },
64 ],
65 )
66 }
67}
68
69export function useApplyPullRequestOTAUpdate() {
70 const {currentlyRunning} = useUpdates()
71 const [pending, setPending] = React.useState(false)
72 const currentChannel = currentlyRunning?.channel
73 const isCurrentlyRunningPullRequestDeployment =
74 currentChannel?.startsWith('pull-request')
75
76 const tryApplyUpdate = async (channel: string) => {
77 setPending(true)
78 await setExtraParamsPullRequest(channel)
79 const res = await checkForUpdateAsync()
80 if (res.isAvailable) {
81 Alert.alert(
82 'Deployment Available',
83 `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?`,
84 [
85 {
86 text: 'No',
87 style: 'cancel',
88 },
89 {
90 text: 'Relaunch',
91 style: 'default',
92 onPress: async () => {
93 await fetchUpdateAsync()
94 await reloadAsync()
95 },
96 },
97 ],
98 )
99 } else {
100 Alert.alert(
101 'No Deployment Available',
102 `No new deployments of ${channel} are currently available for your current native build.`,
103 )
104 }
105 setPending(false)
106 }
107
108 const revertToEmbedded = async () => {
109 try {
110 await updateTestflight()
111 } catch (e: any) {
112 logger.error('Internal OTA Update Error', {error: `${e}`})
113 }
114 }
115
116 return {
117 tryApplyUpdate,
118 revertToEmbedded,
119 isCurrentlyRunningPullRequestDeployment,
120 currentChannel,
121 pending,
122 }
123}
124
125export function useOTAUpdates() {
126 const shouldReceiveUpdates = isEnabled && !__DEV__
127
128 const appState = React.useRef<AppStateStatus>('active')
129 const lastMinimize = React.useRef(0)
130 const ranInitialCheck = React.useRef(false)
131 const timeout = React.useRef<NodeJS.Timeout>(undefined)
132 const {currentlyRunning, isUpdatePending} = useUpdates()
133 const currentChannel = currentlyRunning?.channel
134
135 const setCheckTimeout = React.useCallback(() => {
136 timeout.current = setTimeout(async () => {
137 try {
138 await setExtraParams()
139
140 logger.debug('Checking for update...')
141 const res = await checkForUpdateAsync()
142
143 if (res.isAvailable) {
144 logger.debug('Attempting to fetch update...')
145 await fetchUpdateAsync()
146 } else {
147 logger.debug('No update available.')
148 }
149 } catch (err) {
150 if (!isNetworkError(err)) {
151 logger.error('OTA Update Error', {safeMessage: err})
152 }
153 }
154 }, 10e3)
155 }, [])
156
157 const onIsTestFlight = React.useCallback(async () => {
158 try {
159 await updateTestflight()
160 } catch (err: any) {
161 if (!isNetworkError(err)) {
162 logger.error('Internal OTA Update Error', {safeMessage: err})
163 }
164 }
165 }, [])
166
167 React.useEffect(() => {
168 // We don't need to check anything if the current update is a PR update
169 if (currentChannel?.startsWith('pull-request')) {
170 return
171 }
172
173 // We use this setTimeout to allow Statsig to initialize before we check for an update
174 // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
175 // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
176 // immediately.
177 if (IS_TESTFLIGHT) {
178 onIsTestFlight()
179 return
180 } else if (!shouldReceiveUpdates || ranInitialCheck.current) {
181 return
182 }
183
184 setCheckTimeout()
185 ranInitialCheck.current = true
186 }, [onIsTestFlight, currentChannel, setCheckTimeout, shouldReceiveUpdates])
187
188 // After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available
189 // or B check for an update again.
190 React.useEffect(() => {
191 // We also don't start this timeout if the user is on a pull request update
192 if (!isEnabled || currentChannel?.startsWith('pull-request')) {
193 return
194 }
195
196 // TEMP: disable wake-from-background OTA loading on Android.
197 // This is causing a crash when the thread view is open due to
198 // `maintainVisibleContentPosition`. See repro repo for more details:
199 // https://github.com/mozzius/ota-crash-repro
200 // Old Arch only - re-enable once we're on the New Archictecture! -sfn
201 if (isAndroid) return
202
203 const subscription = AppState.addEventListener(
204 'change',
205 async nextAppState => {
206 if (
207 appState.current.match(/inactive|background/) &&
208 nextAppState === 'active'
209 ) {
210 // If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since
211 // chances are that there isn't anything important going on in the current session.
212 if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
213 if (isUpdatePending) {
214 await reloadAsync()
215 } else {
216 setCheckTimeout()
217 }
218 }
219 } else {
220 lastMinimize.current = Date.now()
221 }
222
223 appState.current = nextAppState
224 },
225 )
226
227 return () => {
228 clearTimeout(timeout.current)
229 subscription.remove()
230 }
231 }, [isUpdatePending, currentChannel, setCheckTimeout])
232}