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
52 changes: 52 additions & 0 deletions src/components/mobile/MobileVideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type VideoSource,
} from '../../services/videoQuality';
import { ErrorBoundary } from '../common/ErrorBoundary';
import { positionStore } from '../../services/positionStore';

const AUTO_HIDE_MS = 3000;
const DEFAULT_ASPECT_RATIO = 16 / 9;
Expand Down Expand Up @@ -82,6 +83,8 @@ const MobileVideoPlayer = ({
const lastToggleRef = useRef(0);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | (() => void) | null>(null);
const resumeStatusRef = useRef<AVPlaybackStatusToSet | null>(null);
const positionSaveIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const sourceIdRef = useRef<string | null>(null);

const [networkType, setNetworkType] = useState<NetworkType>('unknown');
const [selectedQualityId, setSelectedQualityId] = useState(initialQualityId ?? AUTO_QUALITY_ID);
Expand Down Expand Up @@ -271,6 +274,55 @@ const MobileVideoPlayer = ({
setIsFullscreen(prev => !prev);
}, [playbackRate]);

// Persist position when activeSource or isPlaying changes
useEffect(() => {
if (activeSource) {
sourceIdRef.current = activeSource.id;
if (isPlaying) {
const interval = setInterval(async () => {
try {
const status = await videoRef.current?.getStatusAsync();
if (status?.isLoaded) {
await positionStore.saveVideoPosition(
activeSource.id,
status.positionMillis,
status.durationMillis ?? 0,
);
}
} catch {
// ignore
}
}, 5000);
positionSaveIntervalRef.current = interval;
return () => clearInterval(interval);
}
}
return () => {};
}, [activeSource, isPlaying]);

// On unmount, save final position
useEffect(() => {
return () => {
const sid = sourceIdRef.current;
if (sid) {
videoRef.current?.getStatusAsync().then(status => {
if (status?.isLoaded) {
positionStore.saveVideoPosition(sid, status.positionMillis, status.durationMillis ?? 0);
}
}).catch(() => {});
}
};
}, []);

// Restore saved position when activeSource resolves
useEffect(() => {
if (!activeSource || isPlaying) return;
positionStore.getVideoPosition(activeSource.id).then(saved => {
if (!saved || saved.positionMillis < 1000) return;
videoRef.current?.setPositionAsync(saved.positionMillis).catch(() => {});
});
}, [activeSource, isPlaying]);

const handlePlaybackStatusUpdate = useCallback(
(status: AVPlaybackStatus) => {
onPlaybackStatusUpdate?.(status);
Expand Down
31 changes: 17 additions & 14 deletions src/services/metricsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export interface DashboardSnapshot {
const METRICS_STORAGE_KEY = '@teachlink/dashboard_metrics';
const SESSION_START_KEY = '@teachlink/session_start_ts';

const perfNow = () => global.performance?.now() ?? Date.now();

// New buffer size constant (capped at 500 entries)
const MAX_METRICS_BUFFER = 500;

Expand All @@ -119,7 +121,7 @@ const DEFAULT_THRESHOLDS: AlertThresholds = {
// ─── Service ─────────────────────────────────────────────────────────────────

class MetricsService {
private sessionStartTs: number = Date.now();
private sessionStartTs: number = perfNow();
private screensViewedCount: number = 0;
private eventsTrackedCount: number = 0;
private apiResponseTimes: number[] = [];
Expand All @@ -136,8 +138,8 @@ class MetricsService {
try {
const storedStart = await AsyncStorage.getItem(SESSION_START_KEY);
if (!storedStart) {
this.sessionStartTs = Date.now();
await AsyncStorage.setItem(SESSION_START_KEY, String(this.sessionStartTs));
this.sessionStartTs = perfNow();
await AsyncStorage.setItem(SESSION_START_KEY, String(Math.round(this.sessionStartTs)));
} else {
this.sessionStartTs = parseInt(storedStart, 10);
}
Expand Down Expand Up @@ -183,24 +185,25 @@ class MetricsService {
}

public recordError(category: string = 'unknown'): void {
this.addToBuffer(this.errorTimestamps, Date.now());
this.addToBuffer(this.errorTimestamps, perfNow());
this.errorCategories[category] = (this.errorCategories[category] ?? 0) + 1;
// Keep category map reasonably sized – no explicit cap required
}

// ── Snapshot collection ────────────────────────────────────────────────────

public async collectSnapshot(): Promise<DashboardSnapshot> {
const now = Date.now();
const wallNow = Date.now();
const perfNowTime = perfNow();

const appHealth = this.collectAppHealth();
const performance = this.collectPerformance();
const errorRate = this.collectErrorRate(now);
const userMetrics = this.collectUserMetrics(now);
const errorRate = this.collectErrorRate(perfNowTime);
const userMetrics = this.collectUserMetrics(perfNowTime);
const alerts = this.generateAlerts(appHealth, performance, errorRate);

const snapshot: DashboardSnapshot = {
collectedAt: now,
collectedAt: wallNow,
appHealth,
performance,
errorRate,
Expand Down Expand Up @@ -343,15 +346,15 @@ class MetricsService {
thresholds: AlertThresholds = DEFAULT_THRESHOLDS,
): DashboardAlert[] {
const alerts: DashboardAlert[] = [];
const now = Date.now();
const wallNow = Date.now();

if (health.healthScore < thresholds.minHealthScore) {
alerts.push({
id: 'health_low',
severity: health.healthScore < 40 ? 'critical' : 'warning',
title: 'App health degraded',
message: `Health score is ${health.healthScore}/100. Check error logs.`,
timestamp: now,
timestamp: wallNow,
});
}

Expand All @@ -361,7 +364,7 @@ class MetricsService {
severity: 'critical',
title: 'High error rate',
message: `${errors.errorsPerMinute} errors/min (threshold: ${thresholds.maxErrorsPerMinute}).`,
timestamp: now,
timestamp: wallNow,
});
}

Expand All @@ -371,7 +374,7 @@ class MetricsService {
severity: 'warning',
title: 'High memory usage',
message: `Heap utilisation at ${perf.heapUtilPercent}% (threshold: ${thresholds.maxHeapUtilPercent}%).`,
timestamp: now,
timestamp: wallNow,
});
}

Expand All @@ -381,7 +384,7 @@ class MetricsService {
severity: 'warning',
title: 'Slow API responses',
message: `Average API time: ${perf.avgApiResponseMs}ms (threshold: ${thresholds.maxAvgApiResponseMs}ms).`,
timestamp: now,
timestamp: wallNow,
source: 'api',
});
}
Expand All @@ -392,7 +395,7 @@ class MetricsService {
severity: 'warning',
title: 'Potential memory leak',
message: 'Heap usage is growing monotonically. Investigate component subscriptions.',
timestamp: now,
timestamp: wallNow,
});
}

Expand Down
58 changes: 58 additions & 0 deletions src/services/positionStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import AsyncStorage from '@react-native-async-storage/async-storage';

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

const VIDEO_POSITION_PREFIX = '@teachlink_video_pos_';

export interface VideoPositionData {
positionMillis: number;
durationMillis: number;
updatedAt: number;
}

function storageKey(sourceId: string): string {
return `${VIDEO_POSITION_PREFIX}${sourceId}`;
}

export async function saveVideoPosition(
sourceId: string,
positionMillis: number,
durationMillis: number
): Promise<void> {
if (!sourceId || positionMillis < 0) return;
try {
const data: VideoPositionData = {
positionMillis,
durationMillis,
updatedAt: Date.now(),
};
await AsyncStorage.setItem(storageKey(sourceId), JSON.stringify(data));
} catch (error) {
logger.warn('positionStore: failed to save position', error);
}
}

export async function getVideoPosition(
sourceId: string
): Promise<VideoPositionData | null> {
if (!sourceId) return null;
try {
const raw = await AsyncStorage.getItem(storageKey(sourceId));
if (!raw) return null;
return JSON.parse(raw) as VideoPositionData;
} catch (error) {
logger.warn('positionStore: failed to get position', error);
return null;
}
}

export async function clearVideoPosition(sourceId: string): Promise<void> {
if (!sourceId) return;
try {
await AsyncStorage.removeItem(storageKey(sourceId));
} catch (error) {
logger.warn('positionStore: failed to clear position', error);
}
}

export const positionStore = { saveVideoPosition, getVideoPosition, clearVideoPosition };
54 changes: 53 additions & 1 deletion src/services/pushNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import Constants from 'expo-constants';
import { isDevice } from 'expo-device';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { AppState, AppStateStatus, Platform } from 'react-native';

import { featureCapabilities, FeatureStatus, FeatureType } from './featureCapabilities';
import { useDegradationStore } from '../store/degradationStore';
import { useNotificationStore } from '../store/notificationStore';
import { NotificationData, NotificationType } from '../types/notifications';
import logger from '../utils/logger';

Expand Down Expand Up @@ -332,6 +333,57 @@ export function removeNotificationListener(subscription: Notifications.Subscript
subscription.remove();
}

/**
* Get the last notification response (for handling app launch from notification)
*/
export async function getLastNotificationResponse(): Promise<Notifications.NotificationResponse | null> {
return await Notifications.getLastNotificationResponseAsync();
}

/**
* Fetch unread notification count from the server and sync badge.
* Falls back silently on network error.
*/
export async function syncBadgeFromServer(): Promise<void> {
try {
// TODO: Replace with actual API call when backend is ready
// const response = await apiClient.get('/api/notifications/unread-count');
// const unreadCount = response.data.count;
const unreadCount = useNotificationStore.getState().unreadCount;
useNotificationStore.getState().syncBadgeFromServer(unreadCount);
await Notifications.setBadgeCountAsync(unreadCount);
} catch (error) {
logger.warn('Failed to sync badge from server:', error);
}
}

let appStateSubscription: { remove: () => void } | null = 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();
}

const handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState === 'active') {
syncBadgeFromServer();
}
};

appStateSubscription = AppState.addEventListener('change', handleAppStateChange);

return () => {
if (appStateSubscription) {
appStateSubscription.remove();
appStateSubscription = null;
}
};
}

/**
* Get the last notification response (for handling app launch from notification)
*/
Expand Down
37 changes: 37 additions & 0 deletions src/services/secureStorage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import axios from 'axios';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
Expand Down Expand Up @@ -39,6 +40,7 @@ const KEYS = {
BIOMETRIC_ENABLED: 'teachlink_biometric_enabled',
REMEMBERED_EMAIL: 'teachlink_remembered_email',
REMEMBER_ME: 'teachlink_remember_me',
INSTALL_UUID: 'teachlink_install_uuid',
} as const;

// ─── Sensitive Keys (enforce Keychain/Keystore) ────────────────────────────────
Expand Down Expand Up @@ -326,6 +328,41 @@ export async function isBiometricEnabled(): Promise<boolean> {
return value === '1';
}

// ─── Install UUID & biometric reinstall guard ─────────────────────────────────

const INSTALL_UUID_KEY = '@teachlink/install_uuid';

function generateInstallUUID(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${Platform.OS}`;
}

async function checkHardwareBiometricEnrollment(): Promise<boolean> {
try {
const LocalAuthentication = require('expo-local-authentication');
const level = await LocalAuthentication.getEnrolledLevelAsync();
return level > 0;
} catch {
return true;
}
}

export async function verifyBiometricOnReinstall(): Promise<void> {
try {
const installUUID = await AsyncStorage.getItem(INSTALL_UUID_KEY);
if (installUUID) return;

const enrolled = await checkHardwareBiometricEnrollment();
if (!enrolled) {
await setBiometricEnabled(false);
logger.info('Biometric state reset on reinstall: no hardware enrollment');
}

await AsyncStorage.setItem(INSTALL_UUID_KEY, generateInstallUUID());
} catch (error) {
logger.error('Biometric reinstall verification failed:', error);
}
}

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

/**
Expand Down
Loading
Loading