Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions src/components/common/OfflineBanner.tsx
Original file line number Diff line number Diff line change
@@ -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:
* <OfflineBanner />
* <RootNavigator />
*/
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 (
<Animated.View
style={[styles.container, { transform: [{ translateY: slideAnim }] }]}
accessibilityRole="alert"
accessibilityLiveRegion="assertive"
accessibilityLabel={`Offline mode. ${pendingText}.`}
>
<View style={styles.inner}>
<View style={styles.dot} />
<Text style={styles.text}>
{isOffline ? `Offline — ${pendingText}` : ''}
</Text>
</View>
</Animated.View>
);
};

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;
34 changes: 34 additions & 0 deletions src/services/api/requestQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -96,6 +110,7 @@ class RequestQueue {
clientTimestamp: Date.now(),
entityType: versionMetadata?.entityType,
entityId: versionMetadata?.entityId,
fingerprint: fp,
};

queue.push(queuedRequest);
Expand Down Expand Up @@ -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)}`;
}
Expand Down
46 changes: 42 additions & 4 deletions src/services/cacheWarming.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Network from 'expo-network';
import { useAppStore } from '../store';
import { appLogger } from '../utils/logger';
import { courseApi } from './api/courseApi';
Expand All @@ -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<void> {
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<unknown>[] = [
Expand All @@ -27,5 +65,5 @@ export async function warmCriticalCaches(): Promise<void> {

await Promise.all(tasks);

appLogger.infoSync(`[CacheWarming] Completed in ${Date.now() - start}ms`);
appLogger.infoSync(`[CacheWarming] Full prefetch completed in ${Date.now() - start}ms`);
}
50 changes: 49 additions & 1 deletion src/services/pushNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,61 @@ export async function syncBadgeFromServer(): Promise<void> {

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) => {
Expand Down
Loading