import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { Alert, NativeEventSubscription, AppState, Platform, Linking, } from 'react-native'; import { Pushy, Cresc, sharedState } from './client'; import { currentVersion, packageVersion, getCurrentVersionInfo, currentVersionInfo, } from './core'; import { CheckResult, ProgressData, UpdateTestPayload, } from './type'; import { ProgressContext, UpdateContext } from './context'; import { URL } from 'react-native-url-polyfill'; import { resolveCheckResult } from './resolveCheckResult'; import { assertWeb, log, noop } from './utils'; export const UpdateProvider = ({ client, children, }: { client: Pushy | Cresc; children: ReactNode; }) => { client = useRef(client).current; const { options } = client; const stateListener = useRef(undefined); const [updateInfo, setUpdateInfo] = useState(); const updateInfoRef = useRef(updateInfo); const [progress, setProgress] = useState(); const [lastError, setLastError] = useState(); const dismissError = useCallback(() => { setLastError(undefined); }, []); const alertUpdate = useCallback( (...args: Parameters) => { if ( options.updateStrategy === 'alwaysAlert' || options.updateStrategy === 'alertUpdateAndIgnoreError' ) { Alert.alert(...args); } }, [options.updateStrategy], ); const alertError = useCallback( (...args: Parameters) => { if (options.updateStrategy === 'alwaysAlert') { Alert.alert(...args); } }, [options.updateStrategy], ); // All client errors flow through this single subscription (regardless of // throwError), so the catches below only handle flow control and never // duplicate the lastError/Alert surfacing. useEffect( () => client.onError((e, eventType) => { setLastError(e); alertError( client.t( eventType === 'errorChecking' ? 'error_update_check_failed' : 'update_failed', ), e.message, ); }), [client, alertError], ); const switchVersion = useCallback( async (info: CheckResult | undefined = updateInfoRef.current) => { if (info && info.hash) { return client.switchVersion(info.hash); } }, [client], ); const switchVersionLater = useCallback( async (info: CheckResult | undefined = updateInfoRef.current) => { if (info && info.hash) { return client.switchVersionLater(info.hash); } }, [client], ); const downloadUpdate = useCallback( async (info: CheckResult | undefined = updateInfoRef.current) => { if (!info || !info.update) { return false; } try { const hash = await client.downloadUpdate(info, setProgress); if (!hash) { return false; } if ( options.afterDownloadUpdate && (await options.afterDownloadUpdate(info)) === false ) { log('afterDownloadUpdate returned false, skipping'); return false; } if (options.updateStrategy === 'silentAndNow') { // Failures are surfaced via the onError subscription above. client.switchVersion(hash).catch(noop); return true; } else if (options.updateStrategy === 'silentAndLater') { client.switchVersionLater(hash).catch(noop); return true; } alertUpdate(client.t('alert_title'), client.t('alert_update_ready'), [ { text: client.t('alert_next_time'), style: 'cancel', onPress: () => { client.switchVersionLater(hash).catch(noop); }, }, { text: client.t('alert_update_now'), style: 'default', onPress: () => { client.switchVersion(hash).catch(noop); }, }, ]); return true; } catch (e: any) { // Client pipeline errors were already surfaced via the onError // subscription; errors thrown by user hooks (afterDownloadUpdate) // bypass the pipeline and are surfaced here. Asking the client // instead of checking `e.code` matters: axios/system errors carry // their own code without ever entering the pipeline. if (!client.wasEmitted(e)) { setLastError(e); alertError(client.t('update_failed'), e.message); } if (options.throwError) { throw e; } return false; } }, [client, options, alertUpdate, alertError], ); const downloadAndInstallApk = useCallback( async (downloadUrl: string) => { if (Platform.OS === 'android' && downloadUrl) { await client.downloadAndInstallApk(downloadUrl, setProgress); } }, [client], ); const checkUpdate = useCallback( async ({ extra }: { extra?: Partial<{ toHash: string }> } = {}) => { // No throttle here: the client already dedupes checks via its 5s // response cache, and a second throttle layer silently returned // undefined, indistinguishable from a failed check. let rootInfo: CheckResult | undefined; try { rootInfo = await client.checkUpdate(extra); } catch (e: any) { // Client pipeline errors were already surfaced via the onError // subscription; errors thrown by user hooks (beforeCheckUpdate) // bypass the pipeline and are surfaced here (see wasEmitted). if (!client.wasEmitted(e)) { setLastError(e); alertError(client.t('error_update_check_failed'), e.message); } if (options.throwError) { throw e; } return; } if (!rootInfo) { // Check was skipped or failed with no cached result; keep the last // known updateInfo instead of overwriting it with an empty object. return; } const info = resolveCheckResult(rootInfo); if (info.update) { info.description = info.description ?? ''; } updateInfoRef.current = info; setUpdateInfo(info); if (info.expired) { if ( options.onPackageExpired && (await options.onPackageExpired(info)) === false ) { log('onPackageExpired returned false, skipping'); return; } const { downloadUrl } = info; if (downloadUrl && sharedState.apkStatus === null) { if (options.updateStrategy === 'silentAndNow') { if (Platform.OS === 'android' && downloadUrl.endsWith('.apk')) { downloadAndInstallApk(downloadUrl).catch(noop); } else { Linking.openURL(downloadUrl); } return info; } alertUpdate( client.t('alert_title'), client.t('alert_app_updated'), [ { text: client.t('alert_update_button'), onPress: () => { if ( Platform.OS === 'android' && downloadUrl.endsWith('.apk') ) { downloadAndInstallApk(downloadUrl).catch(noop); } else { Linking.openURL(downloadUrl); } }, }, ], ); } } else if (info.update) { if ( options.updateStrategy === 'silentAndNow' || options.updateStrategy === 'silentAndLater' ) { downloadUpdate(info).catch(noop); return info; } alertUpdate( client.t('alert_title'), client.t('alert_new_version_found', { name: info.name!, description: info.description!, }), [ { text: client.t('alert_cancel'), style: 'cancel' }, { text: client.t('alert_confirm'), style: 'default', onPress: () => { downloadUpdate().catch(noop); }, }, ], ); } return info; }, [ client, options, alertUpdate, alertError, downloadAndInstallApk, downloadUpdate, ], ); const markSuccess = client.markSuccess; useEffect(() => { if (!client.assertDebug('checkUpdate()')) { return; } if (!assertWeb()) { return; } const { checkStrategy, autoMarkSuccess } = options; let markSuccessTimer: ReturnType | undefined; if (autoMarkSuccess) { markSuccessTimer = setTimeout(() => { // Failures are reported and surfaced via the onError subscription. Promise.resolve(markSuccess()).catch(noop); }, 1000); } if (checkStrategy === 'both' || checkStrategy === 'onAppResume') { stateListener.current = AppState.addEventListener( 'change', nextAppState => { if (nextAppState === 'active') { checkUpdate().catch(noop); } }, ); } if (checkStrategy === 'both' || checkStrategy === 'onAppStart') { checkUpdate().catch(noop); } return () => { if (markSuccessTimer) { clearTimeout(markSuccessTimer); } stateListener.current && stateListener.current.remove(); }; }, [checkUpdate, options, dismissError, markSuccess, client]); useEffect(() => { const { dismissErrorAfter } = options; if ( lastError && typeof dismissErrorAfter === 'number' && dismissErrorAfter > 0 ) { const dismissErrorTimer = setTimeout(() => { dismissError(); }, dismissErrorAfter); return () => { clearTimeout(dismissErrorTimer); }; } }, [lastError, options, dismissError]); const parseTestPayload = useCallback( (payload: UpdateTestPayload) => { if (payload && payload.type && payload.type.startsWith('__rnPushy')) { const logger = options.logger || (() => {}); options.logger = ({ type, data }) => { logger({ type, data }); Alert.alert(type, JSON.stringify(data)); }; if (payload.type === '__rnPushyVersionHash') { const toHash = payload.data; sharedState.toHash = toHash; checkUpdate({ extra: { toHash } }) .then(() => { if (updateInfoRef.current && updateInfoRef.current.upToDate) { Alert.alert( client.t('alert_info'), client.t('alert_no_update_wait'), ); } }) .catch(noop) .finally(() => { options.logger = logger; }); } return true; } return false; }, [checkUpdate, options, client], ); const parseTestQrCode = useCallback( (code: string | UpdateTestPayload) => { try { const payload = typeof code === 'string' ? JSON.parse(code) : code; return parseTestPayload(payload); } catch (e: any) { log('parseTestQrCode: invalid payload', e?.message || e); return false; } }, [parseTestPayload], ); const restartApp = useCallback(async () => { return client.restartApp(); }, [client]); const resetToPackagedBundle = useCallback( async (resetOptions?: { restart?: boolean }) => { return client.resetToPackagedBundle(resetOptions); }, [client], ); useEffect(() => { if (!assertWeb()) { return; } const parseLinking = (url: string | null) => { if (!url) { return; } try { const params = new URL(url).searchParams; const payload = { type: params.get('type'), data: params.get('data'), }; parseTestPayload(payload); } catch (e: any) { // A malformed deep link (new URL throws) must not become an // unhandled rejection / a throw inside the 'url' event handler. log('parseLinking: invalid url', e?.message || e); } }; Linking.getInitialURL().then(parseLinking).catch(noop); const linkingHandler = ({ url }: { url: string }) => { parseLinking(url); }; const linkingListener = Linking.addEventListener('url', linkingHandler); return () => { if ('removeEventListener' in Linking) { (Linking as any).removeEventListener('url', linkingHandler); } else { linkingListener.remove(); } }; }, [parseTestPayload]); // progress lives in its own context (see context.ts), so this value only // changes when the update state itself changes, not on every progress tick. const contextValue = useMemo( () => ({ checkUpdate, switchVersion, switchVersionLater, dismissError, updateInfo, lastError, markSuccess, client, downloadUpdate, packageVersion, currentHash: currentVersion, downloadAndInstallApk, getCurrentVersionInfo, currentVersionInfo, parseTestQrCode, restartApp, resetToPackagedBundle, }), [ checkUpdate, switchVersion, switchVersionLater, dismissError, updateInfo, lastError, markSuccess, client, downloadUpdate, downloadAndInstallApk, parseTestQrCode, restartApp, resetToPackagedBundle, ], ); return ( {children} ); }; /** @deprecated Please use `UpdateProvider` instead */ export const PushyProvider = UpdateProvider;