From 83bfb1e4adf4bfa7083ca554d19e1fa85d80ac8c Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Sat, 23 May 2026 19:01:01 +0000 Subject: [PATCH] refactor: migrate startup logic to StartupFlowService and implement background Firebase/AppCheck initialization --- .example.env | 4 +- mobile/lib/config/app_config.dart | 2 +- mobile/lib/main.dart | 101 +---- mobile/lib/providers/auth_provider.dart | 112 ++++- mobile/lib/router/app_router.dart | 5 +- mobile/lib/screens/splash_screen.dart | 427 ++++++++++++++---- mobile/lib/services/analytics_service.dart | 47 ++ mobile/lib/services/api_service.dart | 50 +- mobile/lib/services/dio_service.dart | 70 ++- .../services/push_notification_service.dart | 39 +- mobile/lib/services/security_service.dart | 171 +++++-- mobile/lib/services/startup_flow_service.dart | 36 ++ mobile/pubspec.yaml | 2 +- mobile/test/coverage_booster_test.dart | 16 + mobile/test/coverage_shallow_test.dart | 19 + .../navigation_shell_interaction_test.dart | 19 +- package-lock.json | 4 +- package.json | 2 +- public/openapi/openapi.yaml | 2 +- 19 files changed, 874 insertions(+), 254 deletions(-) create mode 100644 mobile/lib/services/startup_flow_service.dart diff --git a/.example.env b/.example.env index aa94d094..fe70aac8 100644 --- a/.example.env +++ b/.example.env @@ -44,7 +44,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass # âš ī¸ App version displayed in footer and health checks # 🔨 Build-time (Infisical `/build-time` folder) -NEXT_PUBLIC_APP_VERSION=4.4.2 +NEXT_PUBLIC_APP_VERSION=4.4.3 # âš ī¸ Your production domain WITHOUT https:// # All URL-based variables are derived from this. @@ -359,7 +359,7 @@ JWE_PRIVATE_KEY= # âš ī¸ Minimum supported app version required to bypass forced update # 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) -MIN_APP_VERSION=4.4.2 +MIN_APP_VERSION=4.4.3 # â„šī¸ Enforce Firebase App Check for all mobile clients in production # Valid: "true", "false" (default: false in dev, true recommended in prod) diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart index c860c918..f080edb8 100644 --- a/mobile/lib/config/app_config.dart +++ b/mobile/lib/config/app_config.dart @@ -75,7 +75,7 @@ class AppConfig { /// Current application version (derived from Infisical compilation injection). static String get appVersion => - const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.2'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.3'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 7aa83dd7..be85f76b 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -9,13 +9,11 @@ import 'package:ghostclass/config/app_config.dart'; import 'package:ghostclass/firebase_options.dart'; import 'package:ghostclass/logic/network_utils.dart'; import 'package:ghostclass/logic/security_initializer.dart'; -import 'package:ghostclass/logic/security_utils.dart'; import 'package:ghostclass/providers/theme_provider.dart'; import 'package:ghostclass/router/app_router.dart'; import 'package:ghostclass/services/analytics_service.dart'; import 'package:ghostclass/services/jwe_service.dart'; import 'package:ghostclass/services/logger.dart'; -import 'package:ghostclass/services/push_notification_service.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/security_lockdown_listener.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -44,54 +42,6 @@ class MyHttpOverrides extends HttpOverrides { } } -class _SecurityFailureApp extends StatelessWidget { - const _SecurityFailureApp({ - required this.friendlyMessage, - required this.technicalDetails, - }); - final String friendlyMessage; - final String technicalDetails; - - @override - Widget build(BuildContext context) { - return MaterialApp( - debugShowCheckedModeBanner: false, - theme: ThemeData.dark(), - home: Builder( - builder: (context) { - WidgetsBinding.instance.addPostFrameCallback((_) { - final _ = SecurityUtils.showSecurityFailureDialog( - context, - title: 'Security Handshake Failed', - message: friendlyMessage, - technicalDetails: technicalDetails, - retryLabel: Platform.isAndroid ? 'Close App' : null, - onRetry: Platform.isAndroid ? () => exit(0) : null, - ); - }); - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); - }, - ), - ); - } -} - -Future _handleSecurityFailure(Object error) async { - final errorMessage = error.toString(); - final friendlyMessage = errorMessage.contains('-3') - ? 'GhostClass encountered a network security issue while verifying your device. This often happens on restricted WiFi or with custom DNS settings.' - : "We couldn't verify the integrity of this app. To protect your data, GhostClass requires a secure, unmodified environment."; - - runApp( - _SecurityFailureApp( - friendlyMessage: friendlyMessage, - technicalDetails: errorMessage, - ), - ); -} - Future _initializeFirebase() async { if (Firebase.apps.isNotEmpty) { AppLogger.i('đŸ›Ąī¸ [FIREBASE SHIELD] Reusing existing Firebase app'); @@ -110,6 +60,8 @@ Future _initializeFirebase() async { } } +Future? firebaseInitFuture; + void main() async { SentryWidgetsFlutterBinding.ensureInitialized(); @@ -138,26 +90,27 @@ void main() async { return true; }; - // Initialize Firebase & App Check - try { - await _initializeFirebase(); + // Initialize Firebase & App Check asynchronously in the background + firebaseInitFuture = () async { + try { + await _initializeFirebase(); - // Initialize Analytics after Firebase is ready — do not block startup - AppLogger.safeUnawait( - AnalyticsService.initialize().catchError( - (Object e, StackTrace st) => - AppLogger.e('Analytics initialization failed', e, st), - ), - 'Analytics init', - ); + // Initialize Analytics after Firebase is ready — do not block startup + AppLogger.safeUnawait( + AnalyticsService.initialize().catchError( + (Object e, StackTrace st) => + AppLogger.e('Analytics initialization failed', e, st), + ), + 'Analytics init', + ); - AppLogger.i('đŸ›Ąī¸ [FIREBASE SHIELD] Initializing App Check...'); - await SecurityInitializer.initialize(); - } on Object catch (e) { - AppLogger.e('đŸ›Ąī¸ [FIREBASE SHIELD] CRITICAL FAILURE', e); - await _handleSecurityFailure(e); - return; - } + AppLogger.i('đŸ›Ąī¸ [FIREBASE SHIELD] Initializing App Check...'); + await SecurityInitializer.initialize(); + } on Object catch (e) { + AppLogger.e('đŸ›Ąī¸ [FIREBASE SHIELD] CRITICAL FAILURE', e); + rethrow; + } + }(); // Initialize Supabase final sUrl = AppConfig.supabaseUrl; @@ -219,18 +172,6 @@ class MyApp extends ConsumerStatefulWidget { } class _MyAppState extends ConsumerState { - @override - void initState() { - super.initState(); - // Initialize push notification listeners and tokens after layout mounts - WidgetsBinding.instance.addPostFrameCallback((_) { - AppLogger.safeUnawait( - ref.read(pushNotificationServiceProvider).initialize(), - 'Push init', - ); - }); - } - @override Widget build(BuildContext context) { final router = ref.watch(routerProvider); diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index f35cc6e1..95826493 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -20,6 +20,7 @@ import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/services/profile_service.dart'; import 'package:ghostclass/services/secure_storage.dart'; import 'package:ghostclass/services/settings_service.dart'; +import 'package:ghostclass/services/startup_flow_service.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; class LoginException implements Exception { @@ -414,6 +415,30 @@ class AuthNotifier extends AsyncNotifier } } + bool _isTransientAppCheckFailureText(String? text) { + final msg = (text ?? '').toLowerCase(); + if (msg.isEmpty) return false; + return msg.contains('too_many_attempts') || + msg.contains('timeout') || + msg.contains('network') || + msg.contains('connection') || + msg.contains('unavailable') || + msg.contains('rate limit') || + msg.contains('internal google server error') || + msg.contains('google_server_unavailable') || + msg.contains('-12'); + } + + bool _isTransientSecurityPayload(Map? data) { + if (data == null) return false; + final reason = data['reason'] as String?; + final error = data['error'] as String?; + final appCheckError = data['appCheckError'] as String?; + return _isTransientAppCheckFailureText(reason) || + _isTransientAppCheckFailureText(error) || + _isTransientAppCheckFailureText(appCheckError); + } + Future _handleSecurityLockdown(Map data) async { AppLogger.e('AuthNotifier: SECURITY LOCKDOWN TRIGGERED'); @@ -563,6 +588,7 @@ class AuthNotifier extends AsyncNotifier cachedUser, supabaseToken: token, sync: true, + force: true, ); _lastRefresh = DateTime.now(); @@ -624,12 +650,17 @@ class AuthNotifier extends AsyncNotifier if (bridgeResponse.statusCode != 200 && bridgeResponse.statusCode != 201) { final data = bridgeResponse.data as Map?; + final isTransientSecurity = _isTransientSecurityPayload(data); final errorMsg = formatApiError(data, 'Secure Session'); throw AppException( - message: errorMsg, - type: bridgeResponse.statusCode == 401 - ? AppExceptionType.unauthorized - : AppExceptionType.server, + message: isTransientSecurity + ? 'Device verification is temporarily unavailable. Please retry in a few moments.' + : errorMsg, + type: isTransientSecurity + ? AppExceptionType.network + : (bridgeResponse.statusCode == 401 + ? AppExceptionType.unauthorized + : AppExceptionType.server), statusCode: bridgeResponse.statusCode, details: data, ); @@ -776,6 +807,9 @@ class AuthNotifier extends AsyncNotifier try { await AnalyticsService.instance.logLogin(method: 'ezygo'); } on Object catch (_) {} + ref + .read(startupFlowServiceProvider) + .markPostLoginFastPath(supabaseUser.id); return; } @@ -835,6 +869,10 @@ class AuthNotifier extends AsyncNotifier await AnalyticsService.instance.logLogin(method: 'ezygo'); } on Object catch (_) {} } + + ref + .read(startupFlowServiceProvider) + .markPostLoginFastPath(supabaseUser.id); } on AuthException catch (e, st) { AppLogger.e('AuthNotifier: SUPABASE AUTH ERROR', e); state = AsyncValue.error(e, st); @@ -1157,30 +1195,56 @@ class AuthNotifier extends AsyncNotifier UserSettings? settingsFallback, }) async { final storage = ref.read(secureStorageProvider); - final storedSupabaseUserId = await storage.getSupabaseUserId(); - final storedEzygoUserId = await storage.getEzygoUserId(); + + final identityReads = await Future.wait([ + storage.getSupabaseUserId(), + storage.getEzygoUserId(), + ]); + final storedSupabaseUserId = identityReads[0]; + final storedEzygoUserId = identityReads[1]; final matchesIdentity = storedSupabaseUserId == null || storedSupabaseUserId == supabaseUserId || (ezygoIdOverride != null && storedEzygoUserId == ezygoIdOverride); + Future usernameFuture() async => + matchesIdentity ? storage.getUsername() : null; + + Future termsVersionFuture() async => + matchesIdentity ? storage.getTermsVersion() : null; + + Future settingsFuture() async { + if (!matchesIdentity) { + return settingsFallback ?? UserSettings.defaults(); + } + return await storage.getSettings() ?? + settingsFallback ?? + UserSettings.defaults(); + } + + Future profileFuture() async => + matchesIdentity ? storage.getUserProfile() : null; + + final hydrationReads = await Future.wait([ + usernameFuture(), + termsVersionFuture(), + settingsFuture(), + profileFuture(), + ]); + final storedUsername = hydrationReads[0] as String?; + final storedTermsVersion = hydrationReads[1] as String?; + final hydratedSettings = hydrationReads[2] as UserSettings; + final hydratedProfile = hydrationReads[3] as UserProfile?; + return AuthenticatedUser( supabaseUserId: supabaseUserId, ezygoToken: EncryptedValue.fromPlaintext(ezygoToken), ezygoId: ezygoIdOverride ?? (matchesIdentity ? storedEzygoUserId : null), - username: - usernameOverride ?? - (matchesIdentity ? await storage.getUsername() : null), - termsVersion: - termsVersionOverride ?? - (matchesIdentity ? await storage.getTermsVersion() : null), - settings: matchesIdentity - ? await storage.getSettings() ?? - settingsFallback ?? - UserSettings.defaults() - : settingsFallback ?? UserSettings.defaults(), - profile: matchesIdentity ? await storage.getUserProfile() : null, + username: usernameOverride ?? storedUsername, + termsVersion: termsVersionOverride ?? storedTermsVersion, + settings: hydratedSettings, + profile: hydratedProfile, ); } @@ -1209,9 +1273,14 @@ class AuthNotifier extends AsyncNotifier if (response.statusCode == 401) { final data = response.data as Map?; + final isTransientSecurity = _isTransientSecurityPayload(data); throw AppException( - message: formatApiError(data, 'Security Verification'), - type: AppExceptionType.unauthorized, + message: isTransientSecurity + ? 'Device verification is temporarily unavailable. Please retry in a few moments.' + : formatApiError(data, 'Security Verification'), + type: isTransientSecurity + ? AppExceptionType.network + : AppExceptionType.unauthorized, statusCode: 401, details: data, ); @@ -1326,7 +1395,8 @@ class AuthNotifier extends AsyncNotifier // If the user has logged out while this refresh was in-flight, skip // persisting any profile or token changes to avoid reintroducing // sensitive data after a forced logout. - if (state.value == null) { + final currentSession = ref.read(supabaseClientProvider).auth.currentSession; + if ((state.value == null && !state.isLoading) || currentSession == null) { AppLogger.i( 'AuthNotifier: Skipping profile apply because user logged out during refresh', ); diff --git a/mobile/lib/router/app_router.dart b/mobile/lib/router/app_router.dart index e44fb810..6d61d6a0 100644 --- a/mobile/lib/router/app_router.dart +++ b/mobile/lib/router/app_router.dart @@ -99,7 +99,10 @@ final routerProvider = Provider((ref) { refreshStream, authRefreshNotifier, ]), - observers: [SentryNavigatorObserver(), AnalyticsService.instance.observer], + observers: [ + SentryNavigatorObserver(), + AnalyticsService.instance.appObserver, + ], redirect: (context, state) { final path = state.uri.path; final isSplash = path == '/splash'; diff --git a/mobile/lib/screens/splash_screen.dart b/mobile/lib/screens/splash_screen.dart index 9aea123e..e8a1dfe3 100644 --- a/mobile/lib/screens/splash_screen.dart +++ b/mobile/lib/screens/splash_screen.dart @@ -12,6 +12,7 @@ import 'package:ghostclass/logic/app_exception.dart'; import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/logic/security_utils.dart'; import 'package:ghostclass/logic/support_helper.dart'; +import 'package:ghostclass/main.dart'; import 'package:ghostclass/providers/app_update_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; @@ -22,12 +23,24 @@ import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/jwe_service.dart'; import 'package:ghostclass/services/logger.dart'; +import 'package:ghostclass/services/push_notification_service.dart'; import 'package:ghostclass/services/secure_storage.dart'; import 'package:ghostclass/services/security_service.dart'; +import 'package:ghostclass/services/startup_flow_service.dart'; import 'package:ghostclass/widgets/app_update_dialog.dart'; import 'package:ghostclass/widgets/service_error_dialog.dart'; import 'package:go_router/go_router.dart'; +class _StartupSnapshot { + const _StartupSnapshot({ + required this.user, + required this.versionResult, + }); + + final AuthenticatedUser? user; + final AppVersionCheckResult? versionResult; +} + class SplashScreen extends ConsumerStatefulWidget { const SplashScreen({super.key}); @@ -36,82 +49,70 @@ class SplashScreen extends ConsumerStatefulWidget { } class _SplashScreenState extends ConsumerState { - void _prewarmAppData() { - void prewarm(Future future, String label) { - AppLogger.safeUnawait( - future.catchError((Object e, StackTrace st) { - AppLogger.e('SplashScreen: $label prewarm failed', e, st); - }), - 'SplashScreen: $label prewarm', - ); - } - - prewarm(ref.read(dashboardProvider.future), 'dashboard'); - prewarm(ref.read(trackingProvider.future), 'tracking'); - prewarm(ref.read(leaveProvider.future), 'leave'); - prewarm(ref.read(scoreProvider.future), 'scores'); - prewarm(ref.read(notificationsProvider.future), 'notifications'); + static Future<_StartupSnapshot>? _startupInFlight; + static _StartupSnapshot? _startupCache; + static DateTime? _startupCacheAt; + static String? _startupCacheSessionKey; + static const Duration _startupCacheTtl = Duration(seconds: 20); + + bool _pushInitTriggered = false; + Future? _initializeInFlight; + + String _currentSessionKey() { + final session = ref.read(supabaseClientProvider).auth.currentSession; + return session?.user.id ?? 'anon'; } - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - final _ = _initializeApp(); - }); + bool _canUseStartupCache(String sessionKey) { + final cachedAt = _startupCacheAt; + if (_startupCache == null || cachedAt == null) return false; + if (_startupCacheSessionKey != sessionKey) return false; + return DateTime.now().difference(cachedAt) <= _startupCacheTtl; } - Future _initializeApp() async { - // 1. Proactively pre-warm security layers while logo is showing - // Keep the splash visible for 2s to improve perceived startup time - final splashHold = Future.delayed( - const Duration(milliseconds: 2000), - () { - AppLogger.i('SplashScreen: 2s delay completed'); - }, - ); - - // Kick off non-critical pre-warms in the background so they do not block - AppLogger.safeUnawait( - JweService.instance.preWarm().catchError( - (Object e, StackTrace st) => - AppLogger.e('SplashScreen: JWE pre-warm failed', e, st), - ), - 'SplashScreen: JWE pre-warm', - ); - - AppLogger.safeUnawait( - ref - .read(apiServiceProvider) - .preWarm() - .catchError( - (Object e, StackTrace st) => - AppLogger.e('SplashScreen: API pre-warm failed', e, st), - ), - 'SplashScreen: API pre-warm', - ); + Future<_StartupSnapshot> _runStartupChecksSingleFlight() { + final sessionKey = _currentSessionKey(); + if (_canUseStartupCache(sessionKey)) { + return Future<_StartupSnapshot>.value(_startupCache); + } - // 2. Critical Security Check First - try { - final api = ref.read(apiServiceProvider); + final inFlight = _startupInFlight; + if (inFlight != null && _startupCacheSessionKey == sessionKey) { + return inFlight; + } - AppLogger.i('SplashScreen: Starting parallel initialization tasks...'); + final api = ref.read(apiServiceProvider); + AppLogger.i('SplashScreen: Starting parallel initialization tasks...'); + final skipIntegrityForPostLogin = ref + .read(startupFlowServiceProvider) + .consumePostLoginFastPath(sessionKey); + if (skipIntegrityForPostLogin) { + AppLogger.i( + 'SplashScreen: Post-login fast-path active. Skipping integrity check for this pass.', + ); + } + final future = () async { AppVersionCheckResult? versionResult; Object? integrityError; StackTrace? integrityStack; - final integrityTask = api - .verifyIntegrity() - .then((res) { - AppLogger.i('SplashScreen: integrityTask completed'); - versionResult = res; - }) - .catchError((Object e, StackTrace st) { - AppLogger.e('SplashScreen: integrityTask failed', e, st); - integrityError = e; - integrityStack = st; - }); + Future integrityTask; + if (skipIntegrityForPostLogin) { + integrityTask = Future.value(); + } else { + integrityTask = api + .verifyIntegrity() + .then((res) { + AppLogger.i('SplashScreen: integrityTask completed'); + versionResult = res; + }) + .catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: integrityTask failed', e, st); + integrityError = e; + integrityStack = st; + }); + } AuthenticatedUser? user; Object? authError; @@ -129,31 +130,186 @@ class _SplashScreenState extends ConsumerState { authStack = st; }); - // CLEAR ALL previous app-open caches for a truly fresh start api.clearCaches(); - AppLogger.i('SplashScreen: Awaiting Future.wait...'); - // Wait for both critical security attestation & profile sync to resolve (success or failure) await Future.wait([ integrityTask, authTask, ]); - AppLogger.i('SplashScreen: Future.wait completed'); - // Prioritize attestation/security error over auth/profile sync error if (integrityError != null) { Error.throwWithStackTrace( integrityError!, integrityStack ?? StackTrace.current, ); } - if (authError != null) { Error.throwWithStackTrace(authError!, authStack ?? StackTrace.current); } - final finalVersionResult = versionResult; + return _StartupSnapshot(user: user, versionResult: versionResult); + }(); + + _startupCacheSessionKey = sessionKey; + _startupInFlight = future; + + return future + .then((snapshot) { + _startupCache = snapshot; + _startupCacheAt = DateTime.now(); + return snapshot; + }) + .whenComplete(() { + if (identical(_startupInFlight, future)) { + _startupInFlight = null; + } + }); + } + + void _beginInitializeIfIdle() { + if (_initializeInFlight != null) return; + final future = _initializeApp(); + _initializeInFlight = future.whenComplete(() { + if (identical(_initializeInFlight, future)) { + _initializeInFlight = null; + } + }); + } + + void _startPushInitInBackgroundAfterSplash( + PushNotificationService pushService, + ) { + if (_pushInitTriggered) return; + _pushInitTriggered = true; + + AppLogger.safeUnawait( + Future.delayed(const Duration(seconds: 2), () async { + await pushService.initialize(); + }).catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: Deferred push init failed', e, st); + }), + 'SplashScreen: deferred push init', + ); + } + + void _prewarmAppData({ + required Future dashboardFuture, + required Future trackingFuture, + required Future leaveFuture, + required Future scoreFuture, + required Future notificationsFuture, + }) { + void prewarm(Future future, String label) { + AppLogger.safeUnawait( + future.catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: $label prewarm failed', e, st); + }), + 'SplashScreen: $label prewarm', + ); + } + + prewarm(dashboardFuture, 'dashboard'); + prewarm(trackingFuture, 'tracking'); + prewarm(leaveFuture, 'leave'); + prewarm(scoreFuture, 'scores'); + prewarm(notificationsFuture, 'notifications'); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _beginInitializeIfIdle(); + }); + } + + void _startPostNavigationPreloads({ + required bool isDashboard, + required ApiService apiService, + required Future dashboardFuture, + required Future trackingFuture, + required Future leaveFuture, + required Future scoreFuture, + required Future notificationsFuture, + }) { + AppLogger.safeUnawait( + JweService.instance.preWarm().catchError( + (Object e, StackTrace st) => + AppLogger.e('SplashScreen: post-nav JWE pre-warm failed', e, st), + ), + 'SplashScreen: post-nav JWE pre-warm', + ); + + AppLogger.safeUnawait( + apiService.preWarm().catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: post-nav API pre-warm failed', e, st); + }), + 'SplashScreen: post-nav API pre-warm', + ); + + if (isDashboard) { + _prewarmAppData( + dashboardFuture: dashboardFuture, + trackingFuture: trackingFuture, + leaveFuture: leaveFuture, + scoreFuture: scoreFuture, + notificationsFuture: notificationsFuture, + ); + } + } + + Future _initializeApp() async { + // Start JWE key warm-up early so attestation requests can reuse prepared + // key material, but do not block startup on this. + AppLogger.safeUnawait( + JweService.instance.preWarm().catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: early JWE pre-warm failed', e, st); + }), + 'SplashScreen: early JWE pre-warm', + ); + + // Keep the splash visible for 1.5s to improve perceived startup time + final splashHold = Future.delayed( + const Duration(milliseconds: 1500), + () { + AppLogger.i('SplashScreen: 1.5s delay completed'); + }, + ); + + // 2. Critical Security Check First + try { + if (firebaseInitFuture != null) { + AppLogger.i( + 'SplashScreen: Awaiting Firebase & App Check initialization...', + ); + try { + await firebaseInitFuture!; + AppLogger.i( + 'SplashScreen: Firebase & App Check initialization completed.', + ); + } on Object catch (initErr) { + AppLogger.e( + 'SplashScreen: Firebase & App Check initialization failed', + initErr, + ); + throw AppException( + message: 'Security subsystem initialization failed.', + type: AppExceptionType.unauthorized, + details: { + 'type': 'security', + 'reason': 'Device security verification setup failed.', + 'action': + 'Please ensure Google Play Services are enabled and update the app.', + 'criticalRisk': true, + 'appCheckError': initErr.toString(), + }, + ); + } + } + final snapshot = await _runStartupChecksSingleFlight(); + if (!mounted) return; + final finalVersionResult = snapshot.versionResult; if (finalVersionResult != null && finalVersionResult.hasUpdate) { ref.read(appUpdateProvider.notifier).setCheckResult(finalVersionResult); @@ -171,8 +327,9 @@ class _SplashScreenState extends ConsumerState { } await splashHold; + if (!mounted) return; - final finalUser = user; + final finalUser = snapshot.user; AppLogger.i( 'SplashScreen: Initialized user: ${finalUser?.supabaseUserId ?? "null"} (syncing: ${finalUser?.isSyncing ?? "false"})', ); @@ -270,7 +427,7 @@ class _SplashScreenState extends ConsumerState { : (isCritical ? null : 'Retry'), onRetry: Platform.isAndroid ? SystemNavigator.pop - : (isCritical ? null : _initializeApp), + : (isCritical ? null : _beginInitializeIfIdle), ); return; } @@ -301,9 +458,9 @@ class _SplashScreenState extends ConsumerState { 'Technical Details: $technicalDetails\n', ), onRetry: () { - // Trigger a fresh build of the Ref which will re-run _initializeApp + // Trigger a fresh build of the Ref which will re-run initialization. ref.invalidate(authProvider); - final _ = _initializeApp(); + _beginInitializeIfIdle(); }, ); return; @@ -314,36 +471,122 @@ class _SplashScreenState extends ConsumerState { if (!mounted) return; final finalUser = ref.read(authProvider).value; + // Capture services and futures synchronously while mounted is guaranteed true + final pushService = ref.read(pushNotificationServiceProvider); + final apiService = ref.read(apiServiceProvider); + final supabaseClient = ref.read(supabaseClientProvider); + final token = supabaseClient.auth.currentSession?.accessToken; + + final dashboardFuture = ref.read(dashboardProvider.future); + final trackingFuture = ref.read(trackingProvider.future); + final leaveFuture = ref.read(leaveProvider.future); + final scoreFuture = ref.read(scoreProvider.future); + final notificationsFuture = ref.read(notificationsProvider.future); + if (finalUser != null) { if (finalUser.termsAccepted) { - _triggerCronSyncAndPrewarm(finalUser); + _startPushInitInBackgroundAfterSplash(pushService); context.go('/dashboard'); + AppLogger.safeUnawait( + Future.microtask(() async { + _triggerCronSyncAndPrewarm( + user: finalUser, + apiService: apiService, + token: token, + dashboardFuture: dashboardFuture, + trackingFuture: trackingFuture, + leaveFuture: leaveFuture, + scoreFuture: scoreFuture, + notificationsFuture: notificationsFuture, + ); + _startPostNavigationPreloads( + isDashboard: true, + apiService: apiService, + dashboardFuture: dashboardFuture, + trackingFuture: trackingFuture, + leaveFuture: leaveFuture, + scoreFuture: scoreFuture, + notificationsFuture: notificationsFuture, + ); + }).catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: post-dashboard preloads failed', e, st); + }), + 'SplashScreen: post-dashboard preloads', + ); } else { + _startPushInitInBackgroundAfterSplash(pushService); context.go('/accept-terms'); + AppLogger.safeUnawait( + Future.microtask(() async { + _startPostNavigationPreloads( + isDashboard: false, + apiService: apiService, + dashboardFuture: dashboardFuture, + trackingFuture: trackingFuture, + leaveFuture: leaveFuture, + scoreFuture: scoreFuture, + notificationsFuture: notificationsFuture, + ); + }).catchError((Object e, StackTrace st) { + AppLogger.e( + 'SplashScreen: post-accept-terms preloads failed', + e, + st, + ); + }), + 'SplashScreen: post-accept-terms preloads', + ); } } else { + _startPushInitInBackgroundAfterSplash(pushService); context.go('/login'); + AppLogger.safeUnawait( + Future.microtask(() async { + _startPostNavigationPreloads( + isDashboard: false, + apiService: apiService, + dashboardFuture: dashboardFuture, + trackingFuture: trackingFuture, + leaveFuture: leaveFuture, + scoreFuture: scoreFuture, + notificationsFuture: notificationsFuture, + ); + }).catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: post-login preloads failed', e, st); + }), + 'SplashScreen: post-login preloads', + ); } } - void _triggerCronSyncAndPrewarm(AuthenticatedUser user) { + void _triggerCronSyncAndPrewarm({ + required AuthenticatedUser user, + required ApiService apiService, + required String? token, + required Future dashboardFuture, + required Future trackingFuture, + required Future leaveFuture, + required Future scoreFuture, + required Future notificationsFuture, + }) { // 1. Trigger Cron Sync in parallel (fire-and-forget) - AppLogger.safeUnawait( - () async { - final token = ref - .read(supabaseClientProvider) - .auth - .currentSession - ?.accessToken; - if (token != null) { - await ref.read(apiServiceProvider).scheduleSync(token); - } - }(), - 'SplashScreen: Cron Sync', - ); + if (token != null) { + AppLogger.safeUnawait( + apiService.scheduleSync(token).catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: Cron Sync failed', e, st); + }), + 'SplashScreen: Cron Sync', + ); + } // 2. Prewarm all other screen queries - _prewarmAppData(); + _prewarmAppData( + dashboardFuture: dashboardFuture, + trackingFuture: trackingFuture, + leaveFuture: leaveFuture, + scoreFuture: scoreFuture, + notificationsFuture: notificationsFuture, + ); } @override diff --git a/mobile/lib/services/analytics_service.dart b/mobile/lib/services/analytics_service.dart index 99c841ec..1b863e74 100644 --- a/mobile/lib/services/analytics_service.dart +++ b/mobile/lib/services/analytics_service.dart @@ -1,5 +1,6 @@ import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; /// AnalyticsService /// Centralized wrapper around `FirebaseAnalytics` that exposes common @@ -13,6 +14,8 @@ class AnalyticsService { FirebaseAnalytics? _analytics; FirebaseAnalyticsObserver? _observer; + bool get isInitialized => _analytics != null && _observer != null; + static Future initialize({FirebaseAnalytics? analyticsInstance}) async { final svc = AnalyticsService.instance; svc @@ -171,4 +174,48 @@ class AnalyticsService { ); } on Object catch (_) {} } + + NavigatorObserver get appObserver => DelegatingAnalyticsObserver(); +} + +class DelegatingAnalyticsObserver extends NavigatorObserver { + NavigatorObserver? get _delegate { + if (!AnalyticsService.instance.isInitialized) { + return null; + } + return AnalyticsService.instance.observer; + } + + @override + void didPush(Route route, Route? previousRoute) { + _delegate?.didPush(route, previousRoute); + } + + @override + void didPop(Route route, Route? previousRoute) { + _delegate?.didPop(route, previousRoute); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _delegate?.didRemove(route, previousRoute); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + _delegate?.didReplace(newRoute: newRoute, oldRoute: oldRoute); + } + + @override + void didStartUserGesture( + Route route, + Route? previousRoute, + ) { + _delegate?.didStartUserGesture(route, previousRoute); + } + + @override + void didStopUserGesture() { + _delegate?.didStopUserGesture(); + } } diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index dc949e30..7ab3d099 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -238,18 +238,57 @@ class ApiService { } // --- Error Handling --- + bool _isTransientAppCheckFailure(String? text) { + final msg = (text ?? '').toLowerCase(); + if (msg.isEmpty) return false; + return msg.contains('too_many_attempts') || + msg.contains('timeout') || + msg.contains('network') || + msg.contains('connection') || + msg.contains('unavailable') || + msg.contains('rate limit') || + msg.contains('internal google server error') || + msg.contains('google_server_unavailable') || + msg.contains('-12'); + } + AppException mapDioError(DioException e) { final status = e.response?.statusCode; var type = AppExceptionType.network; var message = formatApiError(e.response?.data, 'ApiService.Dio'); + final appCheckError = e.requestOptions.extra['appCheckError'] as String?; + final requestMarkedTransient = + e.requestOptions.extra['appCheckTransient'] == true; + final data = e.response?.data; + final backendReason = data is Map + ? (data['reason'] as String?) ?? + (data['error'] as String?) ?? + (data['appCheckError'] as String?) + : null; + final transientAppCheck = + requestMarkedTransient || + _isTransientAppCheckFailure(appCheckError) || + _isTransientAppCheckFailure(backendReason); if (status != null) { if (status == 401) { - message = 'Session expired. Please log in again.'; - type = AppExceptionType.unauthorized; + if (transientAppCheck) { + message = + 'Device verification is temporarily unavailable. Please retry in a few moments.'; + type = AppExceptionType.network; + } else { + message = 'Session expired. Please log in again.'; + type = AppExceptionType.unauthorized; + } } else if (status == 403) { - message = 'Access denied. Bridge security attestation failed.'; - type = AppExceptionType.forbidden; + if (transientAppCheck) { + message = + 'Device verification service is temporarily unavailable. Please retry.'; + type = AppExceptionType.network; + } else { + message = 'Access denied. Bridge security attestation failed.'; + type = AppExceptionType.forbidden; + } } else if (status == 429) { message = 'Woah, slow down! EzyGo rate limited your request. Please wait a minute before trying again.'; @@ -268,9 +307,6 @@ class ApiService { } } - final appCheckError = e.requestOptions.extra['appCheckError']; - final data = e.response?.data; - return AppException( message: message, type: type, diff --git a/mobile/lib/services/dio_service.dart b/mobile/lib/services/dio_service.dart index 7befdb4d..717c12a4 100644 --- a/mobile/lib/services/dio_service.dart +++ b/mobile/lib/services/dio_service.dart @@ -111,6 +111,7 @@ class DioService { Future? _limitedTokenFetchInFlight; // Instrumentation: count how many times we requested a limited-use token static int _limitedTokenRequestCount = 0; + static const int _maxAppCheckAttempts = 3; void _handle401(RequestOptions options) { if (suppress401) return; @@ -126,6 +127,62 @@ class DioService { _unauthorizedController.add(null); } + bool _isTransientAppCheckFailure(Object error) { + final msg = error.toString().toLowerCase(); + return msg.contains('too_many_attempts') || + msg.contains('timeout') || + msg.contains('network') || + msg.contains('connection') || + msg.contains('unavailable') || + msg.contains('rate limit') || + msg.contains('internal google server error') || + msg.contains('google_server_unavailable') || + msg.contains('-12'); + } + + Duration _retryDelayForAttempt(int attempt) { + switch (attempt) { + case 1: + return const Duration(milliseconds: 400); + case 2: + return const Duration(milliseconds: 1200); + default: + return const Duration(milliseconds: 2500); + } + } + + Future _fetchAppCheckTokenWithRetry({ + required bool limited, + }) async { + Object? lastError; + + for (var attempt = 1; attempt <= _maxAppCheckAttempts; attempt++) { + try { + final tokenFuture = limited + ? _appCheck.getLimitedUseToken() + : _appCheck.getToken(); + + return await tokenFuture.timeout(const Duration(seconds: 10)); + } on Object catch (e, st) { + lastError = e; + final isTransient = _isTransientAppCheckFailure(e); + AppLogger.e( + 'DioService: App Check token fetch failed (limited: $limited, attempt: $attempt/$_maxAppCheckAttempts, transient: $isTransient)', + e, + st, + ); + + if (!isTransient || attempt >= _maxAppCheckAttempts) { + rethrow; + } + + await Future.delayed(_retryDelayForAttempt(attempt)); + } + } + + throw Exception('App Check token fetch failed: $lastError'); + } + Future _addSecurityHeaders(RequestOptions options) async { try { final useLimited = options.extra['useLimitedToken'] == true; @@ -139,11 +196,13 @@ class DioService { AppLogger.d( 'DioService: getLimitedUseToken requested (count: $_limitedTokenRequestCount)', ); - _limitedTokenFetchInFlight = _appCheck.getLimitedUseToken(); + _limitedTokenFetchInFlight = _fetchAppCheckTokenWithRetry( + limited: true, + ); isNew = true; } appCheckToken = await _limitedTokenFetchInFlight!.timeout( - const Duration(seconds: 30), + const Duration(seconds: 10), ); if (isNew) { AppLogger.safeUnawait( @@ -164,11 +223,13 @@ class DioService { } else { var isNew = false; if (_tokenFetchInFlight == null) { - _tokenFetchInFlight = _appCheck.getToken(); + _tokenFetchInFlight = _fetchAppCheckTokenWithRetry( + limited: false, + ); isNew = true; } appCheckToken = await _tokenFetchInFlight!.timeout( - const Duration(seconds: 30), + const Duration(seconds: 10), ); if (isNew) { AppLogger.safeUnawait( @@ -206,6 +267,7 @@ class DioService { AppLogger.e('DioService: Security headers failed: $e'); options.extra['appCheckError'] = e.toString(); + options.extra['appCheckTransient'] = _isTransientAppCheckFailure(e); } } diff --git a/mobile/lib/services/push_notification_service.dart b/mobile/lib/services/push_notification_service.dart index 7ac528ab..7e9eb5d8 100644 --- a/mobile/lib/services/push_notification_service.dart +++ b/mobile/lib/services/push_notification_service.dart @@ -2,6 +2,7 @@ // ignore_for_file: unreachable_from_main import 'dart:async'; +import 'dart:io'; import 'package:dio/dio.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -180,7 +181,25 @@ class PushNotificationService { // Retrieve token and execute initial synchronization final token = await _messaging.getToken(); if (token != null) { - await _syncTokenWithBackend(token); + // Defer backend sync slightly to avoid competing with critical + // startup/login security handshakes. + AppLogger.safeUnawait( + Future.delayed( + Platform.environment.containsKey('FLUTTER_TEST') + ? Duration.zero + : const Duration(seconds: 8), + () async { + await _syncTokenWithBackend(token); + }, + ).catchError((Object e, StackTrace st) { + AppLogger.e( + 'PushNotificationService: deferred initial FCM sync failed', + e, + st, + ); + }), + 'PushNotificationService: deferred initial FCM sync', + ); try { AppLogger.safeUnawait( AnalyticsService.instance @@ -220,12 +239,14 @@ class PushNotificationService { /// Synchronises the secure push token with the backend storage route. Future _syncTokenWithBackend(String token) async { try { + if (!_ref.mounted) return; final cachedToken = await _storage.getNormalizedFcmToken(); if (cachedToken == token) { return; } + if (!_ref.mounted) return; final supabase = _ref.read(supabaseClientProvider); final currentSession = supabase.auth.currentSession; @@ -385,15 +406,19 @@ class PushNotificationService { } } - Future dispose() async { - await _tokenSub?.cancel(); - await _messageSub?.cancel(); - await _messageOpenedSub?.cancel(); - await _deferredAuthSub?.cancel(); + void dispose() { + unawaited(_tokenSub?.cancel()); + unawaited(_messageSub?.cancel()); + unawaited(_messageOpenedSub?.cancel()); + unawaited(_deferredAuthSub?.cancel()); _deferredAuthTimer?.cancel(); } } final pushNotificationServiceProvider = Provider( - PushNotificationService.new, + (ref) { + final service = PushNotificationService(ref); + ref.onDispose(service.dispose); + return service; + }, ); diff --git a/mobile/lib/services/security_service.dart b/mobile/lib/services/security_service.dart index 2434cd7c..7fd63c84 100644 --- a/mobile/lib/services/security_service.dart +++ b/mobile/lib/services/security_service.dart @@ -30,12 +30,25 @@ class AppVersionCheckResult { /// --------------- /// Manages device integrity checks and security attestation with the GhostClass backend. class SecurityService { - SecurityService(this._ref); + SecurityService(this._ref) { + _ref.onDispose(() { + _disposed = true; + }); + } final Ref _ref; + bool _disposed = false; static final String _ghostclassBaseUrl = AppConfig.ghostclassApiUrl; static const Duration _cachedAttestationMaxAge = Duration(hours: 6); + static const Duration _blockingAttestationMaxAge = Duration(days: 7); - Dio get _dio => _ref.read(dioServiceProvider).dio; + Dio get _dio { + if (_disposed) { + throw StateError( + 'Cannot use SecurityService after it has been disposed.', + ); + } + return _ref.read(dioServiceProvider).dio; + } bool _isVersionOlder(String current, String target) { final currentParts = current @@ -57,25 +70,84 @@ class SecurityService { return false; } + bool _isTransientAppCheckFailureText(String text) { + final msg = text.toLowerCase(); + return msg.contains('quota') || + msg.contains('connection') || + msg.contains('timeout') || + msg.contains('too_many_attempts') || + msg.contains('network') || + msg.contains('rate limit') || + msg.contains('server') || + msg.contains('internal error') || + msg.contains('-12') || + msg.contains('unavailable'); + } + + bool _isTransientErrorForFallback(Object e) { + if (e is DioException) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.sendTimeout || + e.type == DioExceptionType.receiveTimeout || + e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.badCertificate) { + return true; + } + final statusCode = e.response?.statusCode; + if (statusCode != null && statusCode >= 500) { + return true; + } + } + + if (e is AppException) { + if (e.type == AppExceptionType.network) { + return true; + } + final reason = (e.details?['reason'] as String?) ?? e.message; + final appCheckError = e.details?['appCheckError'] as String?; + final isConnectionOrQuota = + (appCheckError != null && + _isTransientAppCheckFailureText(appCheckError)) || + _isTransientAppCheckFailureText(reason); + if (isConnectionOrQuota) { + return true; + } + } + + final msg = e.toString().toLowerCase(); + if (msg.contains('socketexception') || + msg.contains('handshakeexception') || + msg.contains('network') || + msg.contains('connection') || + msg.contains('timeout')) { + return true; + } + + return false; + } + Future verifyIntegrity() async { + if (_disposed) return null; final storage = _ref.read(secureStorageProvider); final cachedRaw = await storage.getAttestationResult(); + if (_disposed) return null; + Map? cachedMap; if (cachedRaw != null) { try { - final map = jsonDecode(cachedRaw) as Map; - final latestVersion = map['latestVersion'] as String; - final minVersion = map['minVersion'] as String; - final cachedAt = DateTime.tryParse(map['cachedAt'] as String? ?? ''); + cachedMap = jsonDecode(cachedRaw) as Map; + final latestVersion = cachedMap['latestVersion'] as String; + final minVersion = cachedMap['minVersion'] as String; + final cachedAt = DateTime.tryParse( + cachedMap['cachedAt'] as String? ?? '', + ); final currentVersion = AppConfig.appVersion; - final isFreshCache = + + final isUnderBlockingLimit = cachedAt != null && - DateTime.now().difference(cachedAt) <= _cachedAttestationMaxAge; + DateTime.now().difference(cachedAt) <= _blockingAttestationMaxAge; - if (isFreshCache) { - // Dynamically recompute update flags based on the currently running app version. - // This prevents showing stale/incorrect update dialogs (e.g. v4.3.4 -> v4.3.4) - // on the first open after an update. + if (isUnderBlockingLimit) { final hasUpdate = _isVersionOlder(currentVersion, latestVersion); final isForceUpdate = _isVersionOlder(currentVersion, minVersion); @@ -86,42 +158,75 @@ class SecurityService { isForceUpdate: isForceUpdate, ); - // Run background verification asynchronously - AppLogger.safeUnawait( - _runBackgroundIntegrityCheck().catchError( - (Object e, StackTrace st) => AppLogger.e( - 'SecurityService: Background integrity check failed', - e, - st, + final isFreshCache = + DateTime.now().difference(cachedAt) <= _cachedAttestationMaxAge; + + if (!isFreshCache) { + // Cache is stale but under blocking limit: return cached result instantly to avoid blocking, + // but trigger background integrity check asynchronously to refresh the cache. + AppLogger.safeUnawait( + _runBackgroundIntegrityCheck().catchError( + (Object e, StackTrace st) => AppLogger.e( + 'SecurityService: Background integrity check failed', + e, + st, + ), ), - ), - 'SecurityService: background integrity check', - ); + 'SecurityService: background integrity check', + ); + } else { + AppLogger.d( + 'SecurityService: Returned fresh cached attestation check.', + ); + } - AppLogger.d('SecurityService: Returned cached attestation check.'); return cachedResult; } AppLogger.i( - 'SecurityService: Cached attestation is stale; refreshing.', + 'SecurityService: Cached attestation is stale and exceeds blocking limit; refreshing.', ); } on Object catch (e) { AppLogger.e('SecurityService: Failed to parse cached attestation', e); } } - // Cache miss / first run: Blocking network attestation + // Cache miss / stale cache: Blocking network attestation try { final result = await _performNetworkVerify(); + if (_disposed) return result; if (result != null) { await _cacheAttestationResult(result); } return result; - } on Object { + } on Object catch (e) { + if (cachedMap != null) { + final isTransient = _isTransientErrorForFallback(e); + if (isTransient) { + AppLogger.w( + 'SecurityService: Network attestation failed transiently ($e). Falling back to stale cached attestation to permit offline access.', + ); + final latestVersion = cachedMap['latestVersion'] as String; + final minVersion = cachedMap['minVersion'] as String; + final currentVersion = AppConfig.appVersion; + final hasUpdate = _isVersionOlder(currentVersion, latestVersion); + final isForceUpdate = _isVersionOlder(currentVersion, minVersion); + + return AppVersionCheckResult( + latestVersion: latestVersion, + minVersion: minVersion, + hasUpdate: hasUpdate, + isForceUpdate: isForceUpdate, + ); + } + } + try { - await _ref.read(secureStorageProvider).clearAttestationResult(); - } on Object catch (e) { - AppLogger.e('SecurityService: Failed to clear attestation cache', e); + if (!_disposed) { + await _ref.read(secureStorageProvider).clearAttestationResult(); + } + } on Object catch (ex) { + AppLogger.e('SecurityService: Failed to clear attestation cache', ex); } rethrow; } @@ -129,6 +234,7 @@ class SecurityService { Future _cacheAttestationResult(AppVersionCheckResult result) async { try { + if (_disposed) return; final storage = _ref.read(secureStorageProvider); final map = { 'latestVersion': result.latestVersion, @@ -147,13 +253,16 @@ class SecurityService { Future _runBackgroundIntegrityCheck() async { try { final result = await _performNetworkVerify(); + if (_disposed) return; if (result != null) { await _cacheAttestationResult(result); + if (_disposed) return; // Update the reactive update state _ref.read(appUpdateProvider.notifier).setCheckResult(result); } } on AppException catch (e) { + if (_disposed) return; if (e.details?['type'] == 'security') { AppLogger.e( 'SecurityService: Background attestation failure detected.', @@ -196,6 +305,7 @@ class SecurityService { ex, ); } + if (_disposed) return; // Force logout to wipe active session & user state try { @@ -203,6 +313,7 @@ class SecurityService { } on Object catch (ex) { AppLogger.e('SecurityService: Forced logout failed', ex); } + if (_disposed) return; _ref .read(securityFailureProvider.notifier) @@ -268,7 +379,7 @@ class SecurityService { 'reason': reason, 'action': action, 'criticalRisk': criticalRisk, - 'appCheckError': ?appCheckError, + 'appCheckError': appCheckError, }, ); } diff --git a/mobile/lib/services/startup_flow_service.dart b/mobile/lib/services/startup_flow_service.dart new file mode 100644 index 00000000..2c0e6414 --- /dev/null +++ b/mobile/lib/services/startup_flow_service.dart @@ -0,0 +1,36 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// In-memory startup flow hints used to coordinate one-shot navigation paths +/// without persisting security decisions across app restarts. +class StartupFlowService { + String? _postLoginSessionKey; + DateTime? _postLoginMarkedAt; + static const Duration _postLoginFastPathTtl = Duration(seconds: 45); + + void markPostLoginFastPath(String sessionKey) { + _postLoginSessionKey = sessionKey; + _postLoginMarkedAt = DateTime.now(); + } + + bool consumePostLoginFastPath(String sessionKey) { + final markedAt = _postLoginMarkedAt; + if (_postLoginSessionKey == null || markedAt == null) { + return false; + } + + final isMatch = _postLoginSessionKey == sessionKey; + final isFresh = + DateTime.now().difference(markedAt) <= _postLoginFastPathTtl; + final canUse = isMatch && isFresh; + + // One-shot consume semantics. + _postLoginSessionKey = null; + _postLoginMarkedAt = null; + + return canUse; + } +} + +final startupFlowServiceProvider = Provider( + (ref) => StartupFlowService(), +); diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0e9f9312..9df748b8 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 4.4.2+1 +version: 4.4.3+1 environment: sdk: ^3.11.4 diff --git a/mobile/test/coverage_booster_test.dart b/mobile/test/coverage_booster_test.dart index 6ee7c218..6f962c56 100644 --- a/mobile/test/coverage_booster_test.dart +++ b/mobile/test/coverage_booster_test.dart @@ -17,11 +17,16 @@ import 'package:ghostclass/widgets/service_error_dialog.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'coverage_helper.dart'; class MockSecurityService extends Mock implements SecurityService {} +class MockSupabaseClient extends Mock implements SupabaseClient {} + +class MockGoTrueClient extends Mock implements GoTrueClient {} + void main() { final mockDashboard = createMockDashboardData(); final mockUser = createMockUser(); @@ -206,6 +211,11 @@ void main() { 'ghostclass_jwks_time': DateTime.now().toIso8601String(), }); + final mockSupabase = MockSupabaseClient(); + final mockAuth = MockGoTrueClient(); + when(() => mockSupabase.auth).thenReturn(mockAuth); + when(() => mockAuth.currentSession).thenReturn(null); + final mockSecurity = MockSecurityService(); when(mockSecurity.verifyIntegrity).thenAnswer( (_) async => AppVersionCheckResult( @@ -229,6 +239,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + supabaseClientProvider.overrideWithValue(mockSupabase), securityServiceProvider.overrideWithValue(mockSecurity), dashboardProvider.overrideWith( () => MockDashboardNotifier(mockDashboard), @@ -252,6 +263,7 @@ void main() { // Touch routerProvider for coverage final container = ProviderContainer( overrides: [ + supabaseClientProvider.overrideWithValue(mockSupabase), authProvider.overrideWith(() => MockAuthNotifier(mockUser)), ], ); @@ -260,5 +272,9 @@ void main() { // Accessing configuration to exercise provider code paths r.configuration.routes.length; } on Object catch (_) {} + + // Unmount widget tree to cancel repeating animation timers + await tester.pumpWidget(const SizedBox()); + await tester.pump(); }); } diff --git a/mobile/test/coverage_shallow_test.dart b/mobile/test/coverage_shallow_test.dart index 11c5dee2..e2da4719 100644 --- a/mobile/test/coverage_shallow_test.dart +++ b/mobile/test/coverage_shallow_test.dart @@ -14,10 +14,16 @@ import 'package:ghostclass/widgets/add_attendance_dialog.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:ghostclass/widgets/service_error_dialog.dart'; import 'package:ghostclass/widgets/tracking/tracking_subject_picker.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'coverage_helper.dart'; +class MockSupabaseClient extends Mock implements SupabaseClient {} + +class MockGoTrueClient extends Mock implements GoTrueClient {} + void main() { final mockDashboard = createMockDashboardData(); final mockUser = createMockUser(); @@ -52,6 +58,15 @@ void main() { // Common ProviderScope overrides final overrides = [ + supabaseClientProvider.overrideWithValue( + (() { + final mockSupabase = MockSupabaseClient(); + final mockAuth = MockGoTrueClient(); + when(() => mockSupabase.auth).thenReturn(mockAuth); + when(() => mockAuth.currentSession).thenReturn(null); + return mockSupabase; + })(), + ), dashboardProvider.overrideWith( () => MockDashboardNotifier(mockDashboard), ), @@ -175,5 +190,9 @@ void main() { ); await tester.pump(const Duration(seconds: 3)); await tester.pump(); + + // Unmount widget tree to cancel repeating animation timers + await tester.pumpWidget(const SizedBox()); + await tester.pump(); }); } diff --git a/mobile/test/navigation_shell_interaction_test.dart b/mobile/test/navigation_shell_interaction_test.dart index 1a814d21..15015659 100644 --- a/mobile/test/navigation_shell_interaction_test.dart +++ b/mobile/test/navigation_shell_interaction_test.dart @@ -9,12 +9,16 @@ import 'package:ghostclass/providers/outage_provider.dart'; import 'package:ghostclass/providers/security_provider.dart'; import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/screens/navigation_shell.dart'; +import 'package:ghostclass/screens/notifications_screen.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'coverage_helper.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final mockDashboard = createMockDashboardData(); final mockUser = createMockUser(); final mockTracking = TrackingState( @@ -92,13 +96,20 @@ void main() { // Dashboard content present expect(find.text('dashboard'), findsOneWidget); - // Programmatic navigation: go to calendar and verify content updates - router.go('/calendar'); + // Open and close the notifications sheet through the shell action. + await tester.tap(find.byIcon(LucideIcons.bell)); + await tester.pumpAndSettle(); + expect(find.byType(NotificationsScreen), findsOneWidget); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + // Switch tabs through the bottom navigation and verify route updates. + await tester.tap(find.byIcon(LucideIcons.calendar)); await tester.pumpAndSettle(); expect(find.text('calendar'), findsOneWidget); - // Navigate back to dashboard - router.go('/dashboard'); + await tester.tap(find.byIcon(LucideIcons.layoutDashboard)); await tester.pumpAndSettle(); expect(find.text('dashboard'), findsOneWidget); }); diff --git a/package-lock.json b/package-lock.json index ebf4fb76..01b79ff8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.4.2", + "version": "4.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.4.2", + "version": "4.4.3", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/package.json b/package.json index 5db1c868..01bf7a60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.4.2", + "version": "4.4.3", "private": true, "engines": { "node": ">=22.12.0", diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index 1d58824d..5d9999c8 100644 --- a/public/openapi/openapi.yaml +++ b/public/openapi/openapi.yaml @@ -6,7 +6,7 @@ openapi: 3.1.0 info: title: GhostClass API - version: 4.4.2 + version: 4.4.3 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance.