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) => {