From 3bbce8b3712ac4e0bb1898cb9265a739aa53d81a Mon Sep 17 00:00:00 2001 From: oche2920 Date: Fri, 29 May 2026 19:22:32 +0100 Subject: [PATCH] feat: implement OTA in-app update system with one-click install #356 - Add expo-updates dependency and configure update channels in app.json - Add UpdateService wrapping expo-updates (check/download/apply + analytics) - Add useUpdateStore (Zustand) persisting check/dismiss timestamps - Add useAppUpdate hook with 30-min check throttle and 24-hr dismiss cooldown - Add UpdatePrompt bottom-sheet modal (matches BiometricPrompt style) - Add UpdateProvider wired into root layout inside AnalyticsProvider - Add 9 update lifecycle events to AnalyticsEvent enum for adoption tracking - Add app-updates Android notification channel - Closes #356, relates to #50 #73 --- app.json | 9 + app/_layout.tsx | 44 +++-- package.json | 1 + src/components/mobile/UpdatePrompt.tsx | 233 +++++++++++++++++++++++ src/components/mobile/UpdateProvider.tsx | 37 ++++ src/components/mobile/index.ts | 3 +- src/hooks/useAppUpdate.ts | 114 +++++++++++ src/services/pushNotifications.ts | 9 + src/services/updateService.ts | 178 +++++++++++++++++ src/store/updateStore.ts | 70 +++++++ src/utils/trackingEvents.ts | 11 ++ 11 files changed, 688 insertions(+), 21 deletions(-) create mode 100644 src/components/mobile/UpdatePrompt.tsx create mode 100644 src/components/mobile/UpdateProvider.tsx create mode 100644 src/hooks/useAppUpdate.ts create mode 100644 src/services/updateService.ts create mode 100644 src/store/updateStore.ts diff --git a/app.json b/app.json index 03b270c..5929d43 100644 --- a/app.json +++ b/app.json @@ -35,6 +35,15 @@ "output": "static", "favicon": "./assets/images/favicon.png" }, + "updates": { + "enabled": true, + "checkAutomatically": "ON_LOAD", + "fallbackToCacheTimeout": 0, + "url": "https://u.expo.dev/your-project-id" + }, + "runtimeVersion": { + "policy": "appVersion" + }, "plugins": [ "expo-router", [ diff --git a/app/_layout.tsx b/app/_layout.tsx index 16e584f..6f7e8ac 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -7,31 +7,35 @@ import { ErrorBoundary } from '../src/components/common/ErrorBoundary'; import { AnalyticsProvider } from "../src/components/mobile/AnalyticsProvider"; import { OfflineIndicatorProvider } from "../src/components/mobile/OfflineIndicatorProvider"; import { SwipeableNavigation } from '../src/components/mobile/SwipeableNavigation'; +import { UpdateProvider } from "../src/components/mobile/UpdateProvider"; export default function RootLayout() { return ( - - - - - - - - - - - - - - + {/* UpdateProvider sits inside AnalyticsProvider so events are tracked */} + + + + + + + + + + + + + + + + ); } diff --git a/package.json b/package.json index 0a048f9..cc3433f 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "expo-speech-recognition": "^3.1.0", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", + "expo-updates": "~0.28.13", "expo-symbols": "~1.0.8", "expo-system-ui": "~6.0.9", "expo-web-browser": "~15.0.10", diff --git a/src/components/mobile/UpdatePrompt.tsx b/src/components/mobile/UpdatePrompt.tsx new file mode 100644 index 0000000..4e48f4a --- /dev/null +++ b/src/components/mobile/UpdatePrompt.tsx @@ -0,0 +1,233 @@ +import React from 'react'; +import { + ActivityIndicator, + Modal, + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { ErrorBoundary } from '../common/ErrorBoundary'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface UpdatePromptProps { + /** Whether the modal is visible */ + visible: boolean; + /** Whether the update is currently downloading */ + isDownloading: boolean; + /** Called when the user taps "Update Now" */ + onUpdate: () => void; + /** Called when the user taps "Later" */ + onDismiss: () => void; + /** Whether to use dark mode styling */ + isDark?: boolean; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export const UpdatePrompt: React.FC = ({ + visible, + isDownloading, + onUpdate, + onDismiss, + isDark = false, +}) => { + const bg = isDark ? '#1e293b' : '#ffffff'; + const overlay = isDark ? 'rgba(0,0,0,0.75)' : 'rgba(0,0,0,0.5)'; + const textPrimary = isDark ? '#f1f5f9' : '#1e293b'; + const textSecondary = isDark ? '#94a3b8' : '#64748b'; + const accentColor = '#19c3e6'; + const iconBg = isDark ? '#0f172a' : '#f0fbff'; + const divider = isDark ? '#334155' : '#e2e8f0'; + + return ( + + + + {/* Stop tap-through on the sheet itself */} + void }) => e.stopPropagation()} + > + {/* Icon */} + + {isDownloading ? ( + + ) : ( + 🚀 + )} + + + {/* Title */} + + {isDownloading ? 'Updating…' : 'Update Available'} + + + {/* Body */} + + {isDownloading + ? 'Downloading the latest version. The app will restart automatically.' + : 'A new version of TeachLink is ready. Update now for the latest features and security improvements.'} + + + {/* What's new bullets */} + {!isDownloading && ( + + + + + + )} + + {/* CTA */} + {!isDownloading && ( + + Update Now + + )} + + {/* Dismiss */} + {!isDownloading && ( + + + Remind Me Later + + + )} + + + + + ); +}; + +// ─── Bullet row helper ──────────────────────────────────────────────────────── + +function BulletRow({ + icon, + text, + textColor, +}: { + icon: string; + text: string; + textColor: string; +}) { + return ( + + {icon} + {text} + + ); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'flex-end', + }, + sheet: { + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + paddingHorizontal: 28, + paddingTop: 32, + paddingBottom: 48, + alignItems: 'center', + gap: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: -4 }, + shadowOpacity: 0.12, + shadowRadius: 24, + elevation: 20, + }, + iconContainer: { + width: 88, + height: 88, + borderRadius: 44, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 4, + }, + iconEmoji: { + fontSize: 40, + }, + title: { + fontSize: 20, + fontWeight: '800', + textAlign: 'center', + }, + body: { + fontSize: 14, + textAlign: 'center', + lineHeight: 21, + maxWidth: 300, + }, + bulletContainer: { + width: '100%', + borderWidth: 1, + borderRadius: 14, + paddingVertical: 12, + paddingHorizontal: 16, + gap: 8, + marginTop: 4, + }, + bulletRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + bulletIcon: { + fontSize: 16, + width: 24, + textAlign: 'center', + }, + bulletText: { + fontSize: 13, + fontWeight: '500', + flex: 1, + }, + primaryBtn: { + width: '100%', + paddingVertical: 15, + borderRadius: 14, + alignItems: 'center', + justifyContent: 'center', + marginTop: 4, + }, + primaryBtnText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '700', + }, + secondaryBtn: { + paddingVertical: 10, + paddingHorizontal: 16, + }, + secondaryBtnText: { + fontSize: 14, + fontWeight: '500', + }, +}); + +export default UpdatePrompt; diff --git a/src/components/mobile/UpdateProvider.tsx b/src/components/mobile/UpdateProvider.tsx new file mode 100644 index 0000000..a64a735 --- /dev/null +++ b/src/components/mobile/UpdateProvider.tsx @@ -0,0 +1,37 @@ +import React, { ReactNode } from 'react'; +import { useColorScheme } from 'react-native'; +import { useAppUpdate } from '../../hooks/useAppUpdate'; +import { ErrorBoundary } from '../common/ErrorBoundary'; +import { UpdatePrompt } from './UpdatePrompt'; + +interface UpdateProviderProps { + children: ReactNode; +} + +/** + * UpdateProvider mounts at the root of the app and: + * 1. Checks for OTA updates on launch (via useAppUpdate) + * 2. Renders the UpdatePrompt modal when an update is available + * + * Wrap this inside AnalyticsProvider so analytics are ready before + * the first update check fires. + */ +export const UpdateProvider: React.FC = ({ children }) => { + const colorScheme = useColorScheme(); + const { isPromptVisible, status, applyUpdate, dismissUpdate } = useAppUpdate(); + + return ( + + {children} + + + ); +}; + +export default UpdateProvider; diff --git a/src/components/mobile/index.ts b/src/components/mobile/index.ts index db39e13..e6ce1af 100644 --- a/src/components/mobile/index.ts +++ b/src/components/mobile/index.ts @@ -15,5 +15,6 @@ export * from "./SearchResultCard"; export * from "./SettingsPicker"; export * from "./SettingsSection"; export * from "./StatisticsDisplay"; +export * from "./UpdatePrompt"; +export * from "./UpdateProvider"; export * from "./VoiceSearch"; - diff --git a/src/hooks/useAppUpdate.ts b/src/hooks/useAppUpdate.ts new file mode 100644 index 0000000..ae6a185 --- /dev/null +++ b/src/hooks/useAppUpdate.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect } from 'react'; +import updateService from '../services/updateService'; +import { useUpdateStore } from '../store/updateStore'; +import logger from '../utils/logger'; + +/** Re-prompt cooldown: don't show the prompt again within 24 hours of a dismiss */ +const DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000; + +/** Minimum interval between automatic checks: 30 minutes */ +const CHECK_INTERVAL_MS = 30 * 60 * 1000; + +export function useAppUpdate() { + const { + status, + updateInfo, + error, + isPromptVisible, + lastCheckedAt, + lastDismissedAt, + setStatus, + setUpdateInfo, + setError, + showPrompt, + hidePrompt, + setLastCheckedAt, + setLastDismissedAt, + } = useUpdateStore(); + + /** + * Run the full update check. Shows the prompt if an update is found + * and the user hasn't recently dismissed it. + */ + const checkForUpdate = useCallback(async () => { + // Throttle: skip if checked recently + if (lastCheckedAt) { + const elapsed = Date.now() - new Date(lastCheckedAt).getTime(); + if (elapsed < CHECK_INTERVAL_MS) { + logger.debug('[useAppUpdate] Skipping check — checked recently'); + return; + } + } + + setStatus('checking'); + setError(null); + + const result = await updateService.checkForUpdate(); + setLastCheckedAt(new Date().toISOString()); + + if (result.status === 'available' && result.info) { + setStatus('available'); + setUpdateInfo(result.info); + + // Respect dismiss cooldown + if (lastDismissedAt) { + const elapsed = Date.now() - new Date(lastDismissedAt).getTime(); + if (elapsed < DISMISS_COOLDOWN_MS) { + logger.info('[useAppUpdate] Update available but prompt suppressed (cooldown)'); + return; + } + } + + showPrompt(); + } else if (result.status === 'error') { + setStatus('error'); + setError(result.error ?? 'Update check failed'); + } else { + setStatus('up-to-date'); + } + }, [lastCheckedAt, lastDismissedAt, setStatus, setError, setUpdateInfo, setLastCheckedAt, showPrompt]); + + /** + * Download and apply the update. The app will reload automatically. + */ + const applyUpdate = useCallback(async () => { + setStatus('downloading'); + hidePrompt(); + + const downloaded = await updateService.downloadUpdate(); + if (!downloaded) { + setStatus('error'); + setError('Failed to download update. Please try again later.'); + return; + } + + setStatus('ready'); + await updateService.applyUpdate(); + // App reloads here — code below won't execute + }, [setStatus, setError, hidePrompt]); + + /** + * User dismissed the prompt. + */ + const dismissUpdate = useCallback(() => { + updateService.trackDismissed(); + setLastDismissedAt(new Date().toISOString()); + hidePrompt(); + }, [setLastDismissedAt, hidePrompt]); + + // Auto-check on mount (app launch) + useEffect(() => { + checkForUpdate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + status, + updateInfo, + error, + isPromptVisible, + checkForUpdate, + applyUpdate, + dismissUpdate, + }; +} diff --git a/src/services/pushNotifications.ts b/src/services/pushNotifications.ts index 5da00d6..f4edd14 100644 --- a/src/services/pushNotifications.ts +++ b/src/services/pushNotifications.ts @@ -115,6 +115,15 @@ async function setupAndroidNotificationChannels(): Promise { vibrationPattern: [0, 250], lightColor: '#EC4899', }); + + // App updates channel + await Notifications.setNotificationChannelAsync('app-updates', { + name: 'App Updates', + description: 'Notifications about new app versions and updates', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#19c3e6', + }); } /** diff --git a/src/services/updateService.ts b/src/services/updateService.ts new file mode 100644 index 0000000..7283f5f --- /dev/null +++ b/src/services/updateService.ts @@ -0,0 +1,178 @@ +import * as Updates from 'expo-updates'; +import { Platform } from 'react-native'; +import logger from '../utils/logger'; +import { AnalyticsEvent } from '../utils/trackingEvents'; +import { mobileAnalyticsService } from './mobileAnalytics'; + +export type UpdateStatus = + | 'idle' + | 'checking' + | 'available' + | 'downloading' + | 'ready' + | 'error' + | 'up-to-date'; + +export interface UpdateInfo { + updateId: string | null; + manifest: Updates.UpdateManifest | null; + isEmergency: boolean; + checkedAt: string; +} + +export interface UpdateCheckResult { + status: UpdateStatus; + info?: UpdateInfo; + error?: string; +} + +/** + * UpdateService wraps expo-updates to provide a clean API for checking, + * downloading, and applying OTA updates with analytics tracking. + */ +class UpdateService { + /** + * Whether OTA updates are supported in the current environment. + * Updates are not available when running the embedded (development) bundle. + */ + get isSupported(): boolean { + // isEmbeddedLaunch is true in Expo Go and dev client without a published update + return !Updates.isEmbeddedLaunch; + } + + /** + * Returns metadata about the currently running update. + */ + get currentUpdateInfo() { + return { + updateId: Updates.updateId ?? null, + channel: Updates.channel ?? null, + runtimeVersion: Updates.runtimeVersion ?? null, + isEmbedded: Updates.isEmbeddedLaunch, + platform: Platform.OS, + }; + } + + /** + * Check for an available OTA update. + * Tracks the check attempt and result via analytics. + */ + async checkForUpdate(): Promise { + if (!this.isSupported) { + logger.info('[UpdateService] Skipping update check (dev/unsupported env)'); + return { status: 'idle' }; + } + + logger.info('[UpdateService] Checking for updates...'); + mobileAnalyticsService.trackEvent(AnalyticsEvent.UPDATE_CHECK_STARTED, { + channel: Updates.channel ?? 'unknown', + runtimeVersion: Updates.runtimeVersion ?? 'unknown', + }); + + try { + const result = await Updates.checkForUpdateAsync(); + + if (result.isAvailable) { + const info: UpdateInfo = { + updateId: result.manifest?.id ?? null, + manifest: result.manifest ?? null, + isEmergency: result.isRollBackToEmbedded === false && !!result.manifest, + checkedAt: new Date().toISOString(), + }; + + logger.info('[UpdateService] Update available:', info.updateId); + mobileAnalyticsService.trackEvent(AnalyticsEvent.UPDATE_AVAILABLE, { + updateId: info.updateId ?? 'unknown', + channel: Updates.channel ?? 'unknown', + }); + + return { status: 'available', info }; + } + + logger.info('[UpdateService] App is up to date'); + mobileAnalyticsService.trackEvent(AnalyticsEvent.UPDATE_NOT_AVAILABLE, { + channel: Updates.channel ?? 'unknown', + }); + + return { status: 'up-to-date' }; + } catch (error: any) { + const message = error?.message ?? 'Unknown error during update check'; + logger.error('[UpdateService] Update check failed:', message); + mobileAnalyticsService.trackEvent(AnalyticsEvent.UPDATE_CHECK_FAILED, { + error: message, + }); + return { status: 'error', error: message }; + } + } + + /** + * Download the available update bundle. + */ + async downloadUpdate(): Promise { + if (!this.isSupported) return false; + + logger.info('[UpdateService] Downloading update...'); + mobileAnalyticsService.trackEvent(AnalyticsEvent.UPDATE_DOWNLOAD_STARTED, { + channel: Updates.channel ?? 'unknown', + }); + + try { + await Updates.fetchUpdateAsync(); + + logger.info('[UpdateService] Update downloaded successfully'); + mobileAnalyticsService.trackEvent(AnalyticsEvent.UPDATE_DOWNLOAD_COMPLETE, { + channel: Updates.channel ?? 'unknown', + }); + + return true; + } catch (error: any) { + const message = error?.message ?? 'Unknown error during download'; + logger.error('[UpdateService] Download failed:', message); + mobileAnalyticsService.trackEvent(AnalyticsEvent.UPDATE_DOWNLOAD_FAILED, { + error: message, + }); + return false; + } + } + + /** + * Reload the app to apply the downloaded update. + */ + async applyUpdate(): Promise { + if (!this.isSupported) return; + + logger.info('[UpdateService] Applying update — reloading app'); + mobileAnalyticsService.trackEvent(AnalyticsEvent.UPDATE_APPLIED, { + channel: Updates.channel ?? 'unknown', + }); + + await Updates.reloadAsync(); + } + + /** + * Convenience: check, download, and apply in one call. + * Returns true if an update was applied (app will reload). + */ + async checkAndApply(): Promise { + const result = await this.checkForUpdate(); + if (result.status !== 'available') return false; + + const downloaded = await this.downloadUpdate(); + if (!downloaded) return false; + + await this.applyUpdate(); + return true; + } + + /** + * Track when the user dismisses the update prompt. + */ + trackDismissed(): void { + mobileAnalyticsService.trackEvent(AnalyticsEvent.UPDATE_DISMISSED, { + channel: Updates.channel ?? 'unknown', + }); + } +} + +export const updateService = new UpdateService(); +export default updateService; diff --git a/src/store/updateStore.ts b/src/store/updateStore.ts new file mode 100644 index 0000000..ec685e4 --- /dev/null +++ b/src/store/updateStore.ts @@ -0,0 +1,70 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { UpdateInfo, UpdateStatus } from '../services/updateService'; + +interface UpdateState { + // Current update lifecycle status + status: UpdateStatus; + + // Info about the available update (populated when status === 'available' | 'downloading' | 'ready') + updateInfo: UpdateInfo | null; + + // Error message when status === 'error' + error: string | null; + + // Whether the prompt is visible to the user + isPromptVisible: boolean; + + // ISO timestamp of the last successful check + lastCheckedAt: string | null; + + // ISO timestamp of the last time the user dismissed the prompt + // Used to avoid re-prompting too frequently + lastDismissedAt: string | null; + + // Actions + setStatus: (status: UpdateStatus) => void; + setUpdateInfo: (info: UpdateInfo | null) => void; + setError: (error: string | null) => void; + showPrompt: () => void; + hidePrompt: () => void; + setLastCheckedAt: (ts: string) => void; + setLastDismissedAt: (ts: string) => void; + reset: () => void; +} + +const INITIAL_STATE = { + status: 'idle' as UpdateStatus, + updateInfo: null, + error: null, + isPromptVisible: false, + lastCheckedAt: null, + lastDismissedAt: null, +}; + +export const useUpdateStore = create()( + persist( + (set) => ({ + ...INITIAL_STATE, + + setStatus: (status) => set({ status }), + setUpdateInfo: (updateInfo) => set({ updateInfo }), + setError: (error) => set({ error }), + showPrompt: () => set({ isPromptVisible: true }), + hidePrompt: () => set({ isPromptVisible: false }), + setLastCheckedAt: (ts) => set({ lastCheckedAt: ts }), + setLastDismissedAt: (ts) => set({ lastDismissedAt: ts }), + reset: () => set(INITIAL_STATE), + }), + { + name: 'update-store', + storage: createJSONStorage(() => AsyncStorage), + // Only persist the timestamps — runtime state is always re-derived on launch + partialize: (state: UpdateState) => ({ + lastCheckedAt: state.lastCheckedAt, + lastDismissedAt: state.lastDismissedAt, + }), + }, + ), +); diff --git a/src/utils/trackingEvents.ts b/src/utils/trackingEvents.ts index 5cb08a5..2e06aa9 100644 --- a/src/utils/trackingEvents.ts +++ b/src/utils/trackingEvents.ts @@ -27,6 +27,17 @@ export enum AnalyticsEvent { PERFORMANCE_METRIC = 'performance_metric', API_ERROR = 'api_error', CRASH_REPORT = 'crash_report', + + // OTA Update Events + UPDATE_CHECK_STARTED = 'update_check_started', + UPDATE_AVAILABLE = 'update_available', + UPDATE_NOT_AVAILABLE = 'update_not_available', + UPDATE_DOWNLOAD_STARTED = 'update_download_started', + UPDATE_DOWNLOAD_COMPLETE = 'update_download_complete', + UPDATE_DOWNLOAD_FAILED = 'update_download_failed', + UPDATE_APPLIED = 'update_applied', + UPDATE_DISMISSED = 'update_dismissed', + UPDATE_CHECK_FAILED = 'update_check_failed', } /**