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
7 changes: 7 additions & 0 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized();
AppConfig.validate();
final sharedPreferences = await SharedPreferences.getInstance();

// Read auth state before runApp() via the encapsulated helper so the router
// can set initialLocation correctly on the first frame for authenticated
// users. Never throws — see AuthLocalDataSource.checkCachedLogin().
final isLoggedIn = await AuthLocalDataSource.checkCachedLogin();

runApp(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(sharedPreferences),
cachedAuthFlagProvider.overrideWithValue(isLoggedIn),
],
child: const CortexAppRoot(),
),
Expand Down
1 change: 1 addition & 0 deletions app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies:
http: ^1.6.0
shared_preferences: ^2.3.0


dev_dependencies:
flutter_test:
sdk: flutter
Expand Down
14 changes: 14 additions & 0 deletions packages/core/lib/data/auth/auth_local_data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'types/auth_exception.dart';

class AuthLocalDataSource {
/// Pre-boot helper: called in main() before runApp() to obtain a fast
/// synchronous-equivalent auth signal without duplicating storage logic.
///
/// Never throws — a bad Android Keystore (e.g. after an OS/backup restore)
/// returns false so the app starts in a graceful unauthenticated state
/// instead of crashing before runApp().
static Future<bool> checkCachedLogin() async {
try {
return await AuthLocalDataSource().isUserLoggedIn();
} catch (_) {
return false;
}
}

static const _authTokenKey = 'auth_token';

final FlutterSecureStorage _storage;
Expand Down
10 changes: 10 additions & 0 deletions packages/core/lib/data/auth/auth_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
/// Non-null = show the SessionExpiredDialog with this message.
final sessionExpiredProvider = StateProvider<String?>((ref) => null);

/// Cached pre-boot auth signal set in main() before runApp().
///
/// This is the synchronous routing hint used by [goRouterProvider] to set
/// [initialLocation] correctly on cold start — read from [AuthLocalDataSource]
/// before [runApp] via [AuthLocalDataSource.checkCachedLogin].
///
/// [authProvider] still performs full async verification after launch.
/// Must be overridden in [ProviderScope] with the result of that pre-boot check.
final cachedAuthFlagProvider = Provider<bool>((ref) => false);

@Riverpod(keepAlive: true)
class Auth extends _$Auth {
AuthRepository get _repository => ref.read(authRepositoryProvider);
Expand Down
1 change: 1 addition & 0 deletions packages/core/lib/data/data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export 'db/database_provider.dart';

// Auth
export 'auth/auth_provider.dart';
export 'auth/auth_local_data_source.dart';
export 'auth/types/auth_exception.dart';

// Sources
Expand Down
1 change: 0 additions & 1 deletion packages/core/test/data/auth/auth_repository_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:core/data/data.dart';
import 'package:core/data/auth/auth_api_service.dart';
import 'package:core/data/auth/auth_local_data_source.dart';
import 'package:core/data/auth/auth_repository.dart';

@GenerateNiceMocks([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import 'dart:io' show Platform;
import 'package:flutter/widgets.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:core/core.dart';
import 'package:core/data/auth/auth_local_data_source.dart';
import 'package:core/data/data.dart';

/// A WebView-based viewer for HTML and Embedded lesson content.
Expand Down
9 changes: 8 additions & 1 deletion packages/testpress/lib/navigation/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,16 @@ final _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
final goRouterProvider = Provider<GoRouter>((ref) {
const allTabs = NavTab.values;

// Use the pre-boot token check (read in main() before runApp()) to set
// initialLocation on the first frame. For authenticated users this means
// the router starts directly at /home — OnboardingScreen is never mounted.
// authProvider still performs full async verification after launch.
final isLoggedIn = ref.read(cachedAuthFlagProvider);
final initialLocation = isLoggedIn ? '/home' : '/onboarding';

final router = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/onboarding',
initialLocation: initialLocation,
observers: [SentryService.createNavigatorObserver()],
redirect: (context, state) => AuthRoutes.redirect(context, state),
routes: [
Expand Down
16 changes: 12 additions & 4 deletions packages/testpress/lib/navigation/routes/auth_routes.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:core/core.dart';
import 'package:core/data/data.dart';
import 'package:profile/profile.dart';
import '../bootstrap_provider.dart';
import '../page_transitions/slide_transition_page.dart';
Expand All @@ -17,15 +18,22 @@ class AuthRoutes {
};

static String? redirect(BuildContext context, GoRouterState state) {
final bootstrapState = ProviderScope.containerOf(
context,
listen: false,
).read(bootstrapProvider);
final container = ProviderScope.containerOf(context, listen: false);
final bootstrapState = container.read(bootstrapProvider);
final path = state.uri.path;
final isAuthRoute = _authPaths.contains(path);

if (bootstrapState == BootstrapState.loading) {
if (path == '/onboarding') return null;

// Respect the pre-boot cached auth signal so the loading gate doesn't
// immediately bounce initialLocation='/home' back to /onboarding before
// authProvider has a chance to resolve. authProvider still performs full
// async verification in the background; once bootstrapProvider transitions
// out of loading, router.refresh() fires and the correct redirect applies.
final cachedIsLoggedIn = container.read(cachedAuthFlagProvider);
if (cachedIsLoggedIn && !isAuthRoute) return null;

return '/onboarding';
}

Expand Down
94 changes: 93 additions & 1 deletion packages/testpress/test/navigation/app_router_test.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import 'dart:async';

import 'package:core/core.dart';
import 'package:core/data/data.dart';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:testpress/navigation/app_router.dart';
import 'package:testpress/navigation/bootstrap_provider.dart';

// ---------------------------------------------------------------------------
// Minimal Auth mocks (same pattern as bootstrap_provider_test.dart)
// ---------------------------------------------------------------------------
class _AuthLoading extends Auth {
@override
FutureOr<bool> build() => Completer<bool>().future; // never resolves
}

void main() {
// -------------------------------------------------------------------------
// buildPrimaryNavigationItems
// -------------------------------------------------------------------------
group('buildPrimaryNavigationItems', () {
test('keeps Profile as the last destination', () {
final defaultSettings = InstituteSettings.fromJson({
Expand Down Expand Up @@ -37,4 +51,82 @@ void main() {
skip: !AppConfig.showInfoTab,
);
});

// -------------------------------------------------------------------------
// goRouterProvider — initialLocation driven by cachedAuthFlagProvider
// -------------------------------------------------------------------------
group('goRouterProvider initialLocation', () {
test('is /home when cachedAuthFlagProvider is true', () {
final container = ProviderContainer(
overrides: [
cachedAuthFlagProvider.overrideWithValue(true),
authProvider.overrideWith(_AuthLoading.new),
instituteSettingsProvider.overrideWith((ref) => null),
],
);
addTearDown(container.dispose);

final router = container.read(goRouterProvider);
expect(router.routeInformationProvider.value.uri.path, '/home');
});

test('is /onboarding when cachedAuthFlagProvider is false', () {
final container = ProviderContainer(
overrides: [
cachedAuthFlagProvider.overrideWithValue(false),
authProvider.overrideWith(_AuthLoading.new),
instituteSettingsProvider.overrideWith((ref) => null),
],
);
addTearDown(container.dispose);

final router = container.read(goRouterProvider);
expect(router.routeInformationProvider.value.uri.path, '/onboarding');
});
});

// -------------------------------------------------------------------------
// AuthRoutes.redirect — loading gate respects cachedAuthFlagProvider
// -------------------------------------------------------------------------
group('AuthRoutes.redirect loading gate', () {
test('does NOT redirect /home → /onboarding when bootstrapState=loading '
'and cachedAuthFlagProvider=true', () async {
final container = ProviderContainer(
overrides: [
cachedAuthFlagProvider.overrideWithValue(true),
authProvider.overrideWith(_AuthLoading.new),
instituteSettingsProvider.overrideWith((ref) => null),
],
);
addTearDown(container.dispose);

// bootstrapState must be loading (auth never resolves in this test)
final bootstrap = container.read(bootstrapProvider);
expect(bootstrap, BootstrapState.loading);

// With cachedAuthFlagProvider=true the router starts at /home.
// Verify the router is indeed at /home rather than having been
// bounced back to /onboarding by the loading gate.
final router = container.read(goRouterProvider);
expect(router.routeInformationProvider.value.uri.path, '/home');
});

test(
'still redirects unrecognised paths → /onboarding when bootstrapState=loading '
'and cachedAuthFlagProvider=false',
() {
final container = ProviderContainer(
overrides: [
cachedAuthFlagProvider.overrideWithValue(false),
authProvider.overrideWith(_AuthLoading.new),
instituteSettingsProvider.overrideWith((ref) => null),
],
);
addTearDown(container.dispose);

final router = container.read(goRouterProvider);
expect(router.routeInformationProvider.value.uri.path, '/onboarding');
},
);
});
}