Skip to content
Open
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
40 changes: 13 additions & 27 deletions src/hooks/useNetworkStatus.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
import { Network } from 'expo-network';
import { useState, useEffect, useCallback } from 'react';

export type ConnectionType = 'wifi' | 'cellular' | 'none' | 'unknown';
import { networkMonitor, type ConnectionType, type NetworkStatus } from '../services/networkMonitor';

export interface NetworkStatus {
isConnected: boolean;
isInternetReachable: boolean;
type: ConnectionType;
}
export type { ConnectionType, NetworkStatus };

export interface ConnectionQuality {
quality: 'slow-3g' | 'fast-3g' | '4g' | '5g' | 'wifi' | 'unknown';
isFast: boolean;
}

export function useNetworkStatus() {
const [networkStatus, setNetworkStatus] = useState<NetworkStatus>({
isConnected: false,
isInternetReachable: false,
type: 'unknown',
});
const [networkStatus, setNetworkStatus] = useState<NetworkStatus>(networkMonitor.getStatus());
const [connectionQuality, setConnectionQuality] = useState<ConnectionQuality>({
quality: 'unknown',
isFast: false,
Expand All @@ -29,14 +21,10 @@ export function useNetworkStatus() {
const fetchNetworkState = useCallback(async () => {
setIsChecking(true);
try {
const probeStatus = await networkMonitor.probe();
setNetworkStatus(probeStatus);

const networkState = await Network.getNetworkStateAsync();

// Update network status
setNetworkStatus({
isConnected: networkState.isConnected,
isInternetReachable: networkState.isInternetReachable ?? true, // Assume true if not provided
type: networkState.type,
});

// Determine connection quality
let quality: ConnectionQuality['quality'] = 'unknown';
Expand All @@ -61,7 +49,6 @@ export function useNetworkStatus() {
quality = '4g';
isFast = true;
} else if (generation === '3g') {
// Consider 3G with signal strength >= 50 as fast-3G
if (signalStrength >= 50) {
quality = 'fast-3g';
isFast = true;
Expand All @@ -70,18 +57,15 @@ export function useNetworkStatus() {
isFast = false;
}
} else {
// 2g or unknown generation
quality = 'slow-3g';
isFast = false;
}
} catch (error) {
console.warn('Failed to get cellular state', error);
// Fallback to treating cellular as unknown quality
quality = 'unknown';
isFast = false;
}
} else {
// unknown or none (but we already checked isConnected)
quality = 'unknown';
isFast = false;
}
Expand All @@ -104,14 +88,16 @@ export function useNetworkStatus() {
}, []);

useEffect(() => {
// Fetch initial state
fetchNetworkState();
void networkMonitor.init();

const unsubMonitor = networkMonitor.subscribe(setNetworkStatus);

// Subscribe to network state changes
const subscription = Network.addNetworkStateListener(fetchNetworkState);
const subscription = Network.addNetworkStateListener(() => {
fetchNetworkState();
});

// Cleanup
return () => {
unsubMonitor();
subscription.remove();
};
}, [fetchNetworkState]);
Expand Down
125 changes: 125 additions & 0 deletions src/services/networkMonitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Network from 'expo-network';

import logger from '../utils/logger';

export type ConnectionType = 'wifi' | 'cellular' | 'none' | 'unknown';

export interface NetworkStatus {
isConnected: boolean;
isInternetReachable: boolean;
type: ConnectionType;
}

const PROBE_URL = 'https://clients3.google.com/generate_204';
const PROBE_INTERVAL_MS = 30_000;
const PROBE_TIMEOUT_MS = 5_000;
const STORAGE_KEY = '@teachlink_network_status';

type Listener = (status: NetworkStatus) => void;

class NetworkMonitor {
private status: NetworkStatus = {
isConnected: false,
isInternetReachable: false,
type: 'unknown',
};
private listeners: Set<Listener> = new Set();
private probeInterval: ReturnType<typeof setInterval> | null = null;
private networkSubscription: (() => void) | null = null;
private initialized = false;

getStatus(): NetworkStatus {
return { ...this.status };
}

subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}

private notify() {
this.listeners.forEach(fn => fn(this.status));
}

async init(): Promise<void> {
if (this.initialized) return;
this.initialized = true;

// Load cached status
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
this.status = JSON.parse(raw);
}
} catch {
// Ignore
}

// Subscribe to OS-level network changes
const sub = Network.addNetworkStateListener(state => {
this.status.isConnected = state.isConnected ?? false;
this.status.type = state.type as ConnectionType;
this.probe();
});
this.networkSubscription = () => sub.remove();

// Start periodic probing
this.probeInterval = setInterval(() => this.probe(), PROBE_INTERVAL_MS);

// Initial probe
await this.probe();
}

async probe(): Promise<NetworkStatus> {
const start = Date.now();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);

const response = await fetch(PROBE_URL, {
method: 'HEAD',
signal: controller.signal,
});
clearTimeout(timeout);

this.status.isInternetReachable = response.ok || response.status === 204;
} catch {
this.status.isInternetReachable = false;
}

this.notify();
await this.persist();

return { ...this.status };
}

private async persist(): Promise<void> {
try {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(this.status));
} catch {
// Ignore
}
}

async refresh(): Promise<NetworkStatus> {
return this.probe();
}

destroy(): void {
if (this.probeInterval) {
clearInterval(this.probeInterval);
this.probeInterval = null;
}
if (this.networkSubscription) {
this.networkSubscription();
this.networkSubscription = null;
}
this.listeners.clear();
this.initialized = false;
}
}

export const networkMonitor = new NetworkMonitor();
102 changes: 102 additions & 0 deletions src/services/secureStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,108 @@ export async function isBiometricEnabled(): Promise<boolean> {
return value === '1';
}

// ─── Token Cache ──────────────────────────────────────────────────────────────

const TOKEN_CACHE_KEY = '@teachlink_token_cache';
const DEFAULT_TTL_MS = 5 * 60 * 1_000; // 5 minutes

interface CacheEntry {
value: string;
expiresAt: number;
createdAt: number;
}

class TokenCache {
private memory: Map<string, CacheEntry> = new Map();
private initialized = false;

private isExpired(entry: CacheEntry): boolean {
return Date.now() > entry.expiresAt;
}

private async persist(): Promise<void> {
try {
const obj: Record<string, CacheEntry> = {};
this.memory.forEach((entry, key) => {
if (!this.isExpired(entry)) {
obj[key] = entry;
}
});
await AsyncStorage.setItem(TOKEN_CACHE_KEY, JSON.stringify(obj));
} catch {
// Non-critical; cache will warm from SecureStore on next read
}
}

async init(): Promise<void> {
if (this.initialized) return;
try {
const raw = await AsyncStorage.getItem(TOKEN_CACHE_KEY);
if (raw) {
const parsed: Record<string, CacheEntry> = JSON.parse(raw);
for (const [key, entry] of Object.entries(parsed)) {
if (!this.isExpired(entry)) {
this.memory.set(key, entry);
}
}
}
} catch {
// Ignore
}
this.initialized = true;
}

get(key: string): string | null {
const entry = this.memory.get(key);
if (!entry) return null;
if (this.isExpired(entry)) {
this.memory.delete(key);
this.persist();
return null;
}
return entry.value;
}

async set(key: string, value: string, ttlMs: number = DEFAULT_TTL_MS): Promise<void> {
const entry: CacheEntry = {
value,
expiresAt: Date.now() + ttlMs,
createdAt: Date.now(),
};
this.memory.set(key, entry);
await this.persist();
}

invalidate(key: string): void {
this.memory.delete(key);
this.persist();
}

async clear(): Promise<void> {
this.memory.clear();
try {
await AsyncStorage.removeItem(TOKEN_CACHE_KEY);
} catch {
// Ignore
}
}

get size(): number {
this.evictExpired();
return this.memory.size;
}

private evictExpired(): void {
for (const [key, entry] of this.memory) {
if (this.isExpired(entry)) {
this.memory.delete(key);
}
}
}
}

export const tokenCache = new TokenCache();

// ─── Remember Me ──────────────────────────────────────────────────────────────

/**
Expand Down
35 changes: 22 additions & 13 deletions src/services/syncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,18 @@ export class SyncService {
this.stopAutoSync();
this.startAutoSync();
}
}
});

// If the app goes to background, stop auto-sync to conserve CPU/battery.
if (state.isInBackground !== prevState.isInBackground) {
logger.info(`SyncService: App background state changed to ${state.isInBackground}`);
if (state.isInBackground) {
this.stopAutoSync();
// also clear listeners to reduce memory usage while backgrounded
this.removeAllEventListeners();
} else {
// resumed to foreground
this.startAutoSync();
}
// Subscribe to app foreground/background state changes
useDeviceStore.subscribe((state: any, prevState: any) => {
if (state.isInBackground !== prevState.isInBackground) {
logger.info(`SyncService: App background state changed to ${state.isInBackground}`);
if (state.isInBackground) {
this.stopAutoSync();
this.removeAllEventListeners();
} else {
this.startAutoSync();
}
}
});
Expand Down Expand Up @@ -547,10 +547,12 @@ export class SyncService {
}

/**
* Add event listener
* Add event listener. Duplicate listeners are ignored.
*/
addEventListener(listener: (event: SyncEvent) => void): void {
this.eventListeners.push(listener);
if (!this.eventListeners.includes(listener)) {
this.eventListeners.push(listener);
}
}

/**
Expand All @@ -563,6 +565,13 @@ export class SyncService {
}
}

/**
* Check if a listener is already registered
*/
hasEventListener(listener: (event: SyncEvent) => void): boolean {
return this.eventListeners.includes(listener);
}

removeAllEventListeners(): void {
if (this.eventListeners.length > 0) {
this.eventListeners = [];
Expand Down
Loading
Loading