diff --git a/src/components/mobile/MobileVideoPlayer.tsx b/src/components/mobile/MobileVideoPlayer.tsx index 013b50b..dda68de 100644 --- a/src/components/mobile/MobileVideoPlayer.tsx +++ b/src/components/mobile/MobileVideoPlayer.tsx @@ -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; @@ -82,6 +83,8 @@ const MobileVideoPlayer = ({ const lastToggleRef = useRef(0); const hideTimerRef = useRef | (() => void) | null>(null); const resumeStatusRef = useRef(null); + const positionSaveIntervalRef = useRef | null>(null); + const sourceIdRef = useRef(null); const [networkType, setNetworkType] = useState('unknown'); const [selectedQualityId, setSelectedQualityId] = useState(initialQualityId ?? AUTO_QUALITY_ID); @@ -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); diff --git a/src/services/metricsService.ts b/src/services/metricsService.ts index db0a27c..e01fb9c 100644 --- a/src/services/metricsService.ts +++ b/src/services/metricsService.ts @@ -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; @@ -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[] = []; @@ -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); } @@ -183,7 +185,7 @@ 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 } @@ -191,16 +193,17 @@ class MetricsService { // ── Snapshot collection ──────────────────────────────────────────────────── public async collectSnapshot(): Promise { - 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, @@ -343,7 +346,7 @@ 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({ @@ -351,7 +354,7 @@ class MetricsService { severity: health.healthScore < 40 ? 'critical' : 'warning', title: 'App health degraded', message: `Health score is ${health.healthScore}/100. Check error logs.`, - timestamp: now, + timestamp: wallNow, }); } @@ -361,7 +364,7 @@ class MetricsService { severity: 'critical', title: 'High error rate', message: `${errors.errorsPerMinute} errors/min (threshold: ${thresholds.maxErrorsPerMinute}).`, - timestamp: now, + timestamp: wallNow, }); } @@ -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, }); } @@ -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', }); } @@ -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, }); } diff --git a/src/services/positionStore.ts b/src/services/positionStore.ts new file mode 100644 index 0000000..04dab12 --- /dev/null +++ b/src/services/positionStore.ts @@ -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 { + 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 { + 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 { + 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 }; diff --git a/src/services/pushNotifications.ts b/src/services/pushNotifications.ts index 4684fc1..0d6484a 100644 --- a/src/services/pushNotifications.ts +++ b/src/services/pushNotifications.ts @@ -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'; @@ -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 { + 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 { + 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) */ diff --git a/src/services/secureStorage.ts b/src/services/secureStorage.ts index 41e2e83..bcb83e6 100644 --- a/src/services/secureStorage.ts +++ b/src/services/secureStorage.ts @@ -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'; @@ -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) ──────────────────────────────── @@ -326,6 +328,41 @@ export async function isBiometricEnabled(): Promise { 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 { + try { + const LocalAuthentication = require('expo-local-authentication'); + const level = await LocalAuthentication.getEnrolledLevelAsync(); + return level > 0; + } catch { + return true; + } +} + +export async function verifyBiometricOnReinstall(): Promise { + 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 ────────────────────────────────────────────────────────────── /** diff --git a/src/store/notificationStore.ts b/src/store/notificationStore.ts index 559c00e..b8f950e 100644 --- a/src/store/notificationStore.ts +++ b/src/store/notificationStore.ts @@ -10,6 +10,7 @@ import { NotificationType, StoredNotification, } from '../types/notifications'; +import { appLogger } from '../utils/logger'; interface NotificationState { // Push token state @@ -59,6 +60,10 @@ interface NotificationState { // Helpers isNotificationTypeEnabled: (type: NotificationType) => boolean; + + // Badge sync + syncBadgeFromServer: (unreadCount: number) => void; + getLastBadgeSyncAt: () => number | null; } const createInitialNotificationState = () => ({ @@ -74,6 +79,7 @@ const createInitialNotificationState = () => ({ notificationHistory: [], lastEngagedAt: null, lastNotificationSentAtByType: {}, + lastBadgeSyncAt: null as number | null, }); let resetNotificationStoreAfterHydrationError = () => {}; @@ -290,6 +296,13 @@ export const useNotificationStore = create()( return true; } }, + + // Badge sync + syncBadgeFromServer: (unreadCount: number) => { + set({ unreadCount, lastBadgeSyncAt: Date.now() }); + appLogger.infoSync('Badge count synced from server', { unreadCount }); + }, + getLastBadgeSyncAt: () => get().lastBadgeSyncAt, }; }, {