diff --git a/src/components/common/OfflineBanner.tsx b/src/components/common/OfflineBanner.tsx new file mode 100644 index 0000000..d6dad29 --- /dev/null +++ b/src/components/common/OfflineBanner.tsx @@ -0,0 +1,126 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Animated, StyleSheet, Text, View } from 'react-native'; +import * as Network from 'expo-network'; + +import { requestQueue } from '../../services/api/requestQueue'; + +/** + * #824: Persistent offline indicator banner. + * + * Appears within 500 ms of going offline and shows the number of queued + * actions pending sync. Disappears automatically on reconnection. + * + * Usage: + * // In your root navigator or layout: + * + * + */ +const OfflineBanner: React.FC = () => { + const [isOffline, setIsOffline] = useState(false); + const [pendingCount, setPendingCount] = useState(0); + const slideAnim = useRef(new Animated.Value(-60)).current; + + // ── Network state subscription ────────────────────────────────────────────── + useEffect(() => { + let mounted = true; + + // Poll network state every 500 ms — expo-network does not provide a + // persistent event listener on all platforms, so polling is the safest + // cross-platform approach. + const interval = setInterval(async () => { + try { + const state = await Network.getNetworkStateAsync(); + const offline = !state.isConnected || !state.isInternetReachable; + if (mounted) setIsOffline(offline); + } catch { + // Treat network-check failure as offline + if (mounted) setIsOffline(true); + } + }, 500); + + return () => { + mounted = false; + clearInterval(interval); + }; + }, []); + + // ── Queue depth subscription ──────────────────────────────────────────────── + useEffect(() => { + const unsubscribe = requestQueue.onPendingCountChange(count => { + setPendingCount(count); + }); + + // Read initial count + requestQueue.getPendingCount().then(count => setPendingCount(count)); + + return unsubscribe; + }, []); + + // ── Slide animation ───────────────────────────────────────────────────────── + useEffect(() => { + Animated.timing(slideAnim, { + toValue: isOffline ? 0 : -60, + duration: 300, + useNativeDriver: true, + }).start(); + }, [isOffline, slideAnim]); + + const pendingText = + pendingCount > 0 + ? `${pendingCount} action${pendingCount === 1 ? '' : 's'} pending sync` + : 'No connection'; + + return ( + + + + + {isOffline ? `Offline — ${pendingText}` : ''} + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 9999, + backgroundColor: '#D32F2F', + paddingTop: 44, // Safe area top (status bar) + paddingBottom: 10, + paddingHorizontal: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 6, + }, + inner: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#FFCDD2', + }, + text: { + color: '#FFFFFF', + fontSize: 13, + fontWeight: '600', + letterSpacing: 0.2, + }, +}); + +export default OfflineBanner; diff --git a/src/services/api/requestQueue.ts b/src/services/api/requestQueue.ts index 519e57b..a8598b2 100644 --- a/src/services/api/requestQueue.ts +++ b/src/services/api/requestQueue.ts @@ -26,6 +26,11 @@ export interface QueuedRequest { entityType?: string; /** Entity ID for conflict resolution */ entityId?: string; + /** + * #813: Deterministic fingerprint of method+URL+body. + * Duplicate requests sharing the same fingerprint are suppressed in addToQueue. + */ + fingerprint?: string; } interface BatchGroup { @@ -83,6 +88,15 @@ class RequestQueue { const method = (config.method ?? 'GET').toUpperCase(); const endpoint = config.url ?? '/unknown'; + // #813: Suppress duplicate mutations queued during the same offline session. + // Two requests are duplicates if they share the same method, URL, and body. + const fp = this.fingerprint(config); + const existing = queue.find(r => r.fingerprint === fp); + if (existing) { + logger.info(`RequestQueue: duplicate ${method} ${endpoint} suppressed — returning existing id ${existing.id}`); + return existing.id; + } + const queuedRequest: QueuedRequest = { id: this.generateId(), config, @@ -96,6 +110,7 @@ class RequestQueue { clientTimestamp: Date.now(), entityType: versionMetadata?.entityType, entityId: versionMetadata?.entityId, + fingerprint: fp, }; queue.push(queuedRequest); @@ -547,6 +562,25 @@ class RequestQueue { return config; } + /** + * #813: Deterministic fingerprint for deduplication. + * Identical method + URL + serialized body → same fingerprint. + */ + private fingerprint(config: InternalAxiosRequestConfig): string { + const method = (config.method ?? 'GET').toUpperCase(); + const url = config.url ?? '/unknown'; + // Serialize body deterministically; fall back to empty string for GET/HEAD + let body = ''; + if (config.data !== undefined && config.data !== null) { + try { + body = typeof config.data === 'string' ? config.data : JSON.stringify(config.data); + } catch { + body = String(config.data); + } + } + return `${method}:${url}:${body}`; + } + private generateId(): string { return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } diff --git a/src/services/cacheWarming.ts b/src/services/cacheWarming.ts index 77e1b9f..f556e8e 100644 --- a/src/services/cacheWarming.ts +++ b/src/services/cacheWarming.ts @@ -1,3 +1,4 @@ +import * as Network from 'expo-network'; import { useAppStore } from '../store'; import { appLogger } from '../utils/logger'; import { courseApi } from './api/courseApi'; @@ -7,14 +8,51 @@ import { userApi } from './api/userApi'; * Warm critical caches in parallel during the splash screen so home screen * data is ready before the user sees it. * - * - User profile (requires authenticated userId) - * - Home feed / course list (always fetched) + * #814: Network-quality aware — skips or reduces prefetch on slow connections + * to prevent startup latency on 2G/3G and avoid exhausting mobile data budgets. * - * Failures are swallowed — warming is best-effort and must never block startup. + * Connection tiers: + * Offline → skip entirely + * Cellular → minimal prefetch (courses only, no user profile) + * WiFi / Other → full prefetch (courses + user profile) */ export async function warmCriticalCaches(): Promise { const start = Date.now(); + // ── Network quality gate ──────────────────────────────────────────────────── + let networkState: Network.NetworkState; + try { + networkState = await Network.getNetworkStateAsync(); + } catch { + // If we cannot determine network state, proceed with a minimal fetch + // to avoid blocking startup on a permissions/API error. + appLogger.warnSync('[CacheWarming] Could not determine network state — minimal prefetch'); + await courseApi.getCourses().catch(() => null); + return; + } + + if (!networkState.isConnected || !networkState.isInternetReachable) { + appLogger.infoSync('[CacheWarming] Skipped — device is offline'); + return; + } + + const type = networkState.type; + // WiFi and OTHER (Ethernet/VPN) are considered fast; cellular types are slow. + const isFastNetwork = + type === Network.NetworkStateType.WIFI || type === Network.NetworkStateType.OTHER; + + if (!isFastNetwork) { + // On cellular or unknown connections: prefetch only the most critical data + // (course list) and skip optional resources to conserve data. + appLogger.infoSync( + `[CacheWarming] Slow connection (${type}) — minimal prefetch (courses only)` + ); + await courseApi.getCourses().catch(() => null); + appLogger.infoSync(`[CacheWarming] Minimal prefetch completed in ${Date.now() - start}ms`); + return; + } + + // ── Full prefetch on fast connections ─────────────────────────────────────── const userId = useAppStore.getState().user?.id; const tasks: Promise[] = [ @@ -27,5 +65,5 @@ export async function warmCriticalCaches(): Promise { await Promise.all(tasks); - appLogger.infoSync(`[CacheWarming] Completed in ${Date.now() - start}ms`); + appLogger.infoSync(`[CacheWarming] Full prefetch completed in ${Date.now() - start}ms`); } diff --git a/src/services/pushNotifications.ts b/src/services/pushNotifications.ts index 1edc17c..42f0587 100644 --- a/src/services/pushNotifications.ts +++ b/src/services/pushNotifications.ts @@ -360,13 +360,61 @@ export async function syncBadgeFromServer(): Promise { let appStateSubscription: { remove: () => void } | null = null; +// #812: Module-level guard prevents token-refresh listeners from accumulating +// across multiple app launches / background restores. Only one listener is ever +// registered; subsequent calls return the existing cleanup without adding a new one. +let tokenExpirySubscription: import('expo-notifications').Subscription | null = null; + +/** + * Subscribe to Expo push token changes (token rotation / expiry). + * + * expo-notifications fires `addPushTokenListener` whenever the OS issues a new + * push token for the app — e.g. after a token reset or app reinstall. + * We re-register the fresh token with the backend automatically. + * + * Guards against accumulation: if called multiple times (re-renders, app + * restore) only one listener is ever active at a time. + */ +export function setupTokenExpiryListener(): () => void { + if (tokenExpirySubscription !== null) { + // Already registered — skip to prevent duplicate handlers on re-launch + return () => { + /* no-op: listener was already registered by an earlier call */ + }; + } + + const Notifications = require('expo-notifications') as typeof import('expo-notifications'); + tokenExpirySubscription = Notifications.addPushTokenListener(async tokenData => { + try { + await registerTokenWithBackend(tokenData.data); + logger.info('Push token rotated and re-registered with backend'); + } catch (error) { + logger.error('Failed to re-register rotated push token:', error); + } + }); + + return () => { + if (tokenExpirySubscription) { + tokenExpirySubscription.remove(); + tokenExpirySubscription = null; + } + }; +} + /** * Subscribe to AppState changes and sync badges on foreground. * Should be called once during app initialization. */ export function setupForegroundBadgeSync(): () => void { if (appStateSubscription) { - appStateSubscription.remove(); + // Already subscribed — return existing cleanup rather than removing and + // re-adding, which would cause double-fire on the next app restore. + return () => { + if (appStateSubscription) { + appStateSubscription.remove(); + appStateSubscription = null; + } + }; } const handleAppStateChange = (nextState: AppStateStatus) => {