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',
}
/**