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
4 changes: 2 additions & 2 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/config/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
101 changes: 21 additions & 80 deletions mobile/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> _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<void> _initializeFirebase() async {
if (Firebase.apps.isNotEmpty) {
AppLogger.i('🛡️ [FIREBASE SHIELD] Reusing existing Firebase app');
Expand All @@ -110,6 +60,8 @@ Future<void> _initializeFirebase() async {
}
}

Future<void>? firebaseInitFuture;

void main() async {
SentryWidgetsFlutterBinding.ensureInitialized();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -219,18 +172,6 @@ class MyApp extends ConsumerStatefulWidget {
}

class _MyAppState extends ConsumerState<MyApp> {
@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);
Expand Down
112 changes: 91 additions & 21 deletions mobile/lib/providers/auth_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -414,6 +415,30 @@ class AuthNotifier extends AsyncNotifier<AuthenticatedUser?>
}
}

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<String, dynamic>? 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<void> _handleSecurityLockdown(Map<String, String> data) async {
AppLogger.e('AuthNotifier: SECURITY LOCKDOWN TRIGGERED');

Expand Down Expand Up @@ -563,6 +588,7 @@ class AuthNotifier extends AsyncNotifier<AuthenticatedUser?>
cachedUser,
supabaseToken: token,
sync: true,
force: true,
);
_lastRefresh = DateTime.now();

Expand Down Expand Up @@ -624,12 +650,17 @@ class AuthNotifier extends AsyncNotifier<AuthenticatedUser?>
if (bridgeResponse.statusCode != 200 &&
bridgeResponse.statusCode != 201) {
final data = bridgeResponse.data as Map<String, dynamic>?;
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,
);
Expand Down Expand Up @@ -776,6 +807,9 @@ class AuthNotifier extends AsyncNotifier<AuthenticatedUser?>
try {
await AnalyticsService.instance.logLogin(method: 'ezygo');
} on Object catch (_) {}
ref
.read(startupFlowServiceProvider)
.markPostLoginFastPath(supabaseUser.id);
return;
}

Expand Down Expand Up @@ -835,6 +869,10 @@ class AuthNotifier extends AsyncNotifier<AuthenticatedUser?>
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);
Expand Down Expand Up @@ -1157,30 +1195,56 @@ class AuthNotifier extends AsyncNotifier<AuthenticatedUser?>
UserSettings? settingsFallback,
}) async {
final storage = ref.read(secureStorageProvider);
final storedSupabaseUserId = await storage.getSupabaseUserId();
final storedEzygoUserId = await storage.getEzygoUserId();

final identityReads = await Future.wait<String?>([
storage.getSupabaseUserId(),
storage.getEzygoUserId(),
]);
final storedSupabaseUserId = identityReads[0];
final storedEzygoUserId = identityReads[1];

final matchesIdentity =
storedSupabaseUserId == null ||
storedSupabaseUserId == supabaseUserId ||
(ezygoIdOverride != null && storedEzygoUserId == ezygoIdOverride);

Future<String?> usernameFuture() async =>
matchesIdentity ? storage.getUsername() : null;

Future<String?> termsVersionFuture() async =>
matchesIdentity ? storage.getTermsVersion() : null;

Future<UserSettings> settingsFuture() async {
if (!matchesIdentity) {
return settingsFallback ?? UserSettings.defaults();
}
return await storage.getSettings() ??
settingsFallback ??
UserSettings.defaults();
}

Future<UserProfile?> profileFuture() async =>
matchesIdentity ? storage.getUserProfile() : null;

final hydrationReads = await Future.wait<dynamic>([
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,
);
}

Expand Down Expand Up @@ -1209,9 +1273,14 @@ class AuthNotifier extends AsyncNotifier<AuthenticatedUser?>

if (response.statusCode == 401) {
final data = response.data as Map<String, dynamic>?;
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,
);
Expand Down Expand Up @@ -1326,7 +1395,8 @@ class AuthNotifier extends AsyncNotifier<AuthenticatedUser?>
// 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',
);
Expand Down
5 changes: 4 additions & 1 deletion mobile/lib/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ final routerProvider = Provider<GoRouter>((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';
Expand Down
Loading
Loading