diff --git a/lib/core/cache/local_cache_maintenance_service.dart b/lib/core/cache/local_cache_maintenance_service.dart new file mode 100644 index 0000000..b79ddb2 --- /dev/null +++ b/lib/core/cache/local_cache_maintenance_service.dart @@ -0,0 +1,36 @@ +import 'package:flutter/widgets.dart'; +import 'package:lazurite/core/cache/lazurite_image_cache.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/objectbox/embedded_post.dart'; +import 'package:lazurite/core/objectbox/objectbox_store.dart'; +import 'package:lazurite/objectbox.g.dart'; + +class LocalCacheMaintenanceService { + LocalCacheMaintenanceService({ + required AppDatabase database, + required ObjectBoxStore objectBoxStore, + Future Function()? clearImageDiskCache, + VoidCallback? clearImageMemoryCache, + }) : _database = database, + _objectBoxStore = objectBoxStore, + _clearImageDiskCache = clearImageDiskCache ?? LazuriteImageCacheManager.instance.emptyCache, + _clearImageMemoryCache = clearImageMemoryCache ?? _defaultClearImageMemoryCache; + + final AppDatabase _database; + final ObjectBoxStore _objectBoxStore; + final Future Function() _clearImageDiskCache; + final VoidCallback _clearImageMemoryCache; + + Future clearCaches() async { + await _database.clearLocalCaches(); + Box(_objectBoxStore.store).removeAll(); + await _clearImageDiskCache(); + _clearImageMemoryCache(); + } + + static void _defaultClearImageMemoryCache() { + final imageCache = PaintingBinding.instance.imageCache; + imageCache.clear(); + imageCache.clearLiveImages(); + } +} diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 58c093d..fbb34ee 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -266,6 +266,18 @@ class AppDatabase extends _$AppDatabase { Future deleteSetting(String key) => (delete(settings)..where((s) => s.key.equals(key))).go(); + Future clearLocalCaches() async { + await transaction(() async { + await delete(cachedProfiles).go(); + await delete(cachedPosts).go(); + await delete(cachedFeedPages).go(); + await delete(cachedFeedPosts).go(); + await delete(cachedThreadRoots).go(); + await delete(labelerCache).go(); + await customStatement("DELETE FROM settings WHERE key LIKE 'moderation_preferences::%'"); + }); + } + Future> getSavedFeeds(String accountDid) => (select(savedFeeds) ..where((f) => f.accountDid.equals(accountDid)) diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 60e3541..53be44e 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -53,9 +53,9 @@ import 'package:lazurite/features/profile/data/profile_context_repository.dart'; import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; +import 'package:lazurite/features/search/bloc/search_bloc.dart'; import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; import 'package:lazurite/features/search/cubit/topic_cubit.dart'; -import 'package:lazurite/features/search/bloc/search_bloc.dart'; import 'package:lazurite/features/search/data/hashtag_utils.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; @@ -102,7 +102,13 @@ class AppRouter { redirect: (context, state) { final isAuthenticated = authBloc.state.isAuthenticated; final path = state.uri.path; - final publicPaths = {'/login', '/terms', '/privacy', OAuthCallbackScreen.routePath}; + final publicPaths = { + '/login', + '/terms', + '/privacy', + OAuthCallbackScreen.routePath, + OAuthCallbackScreen.compatibilityRoutePath, + }; final isLoggingIn = path == '/login'; final isReauthLogin = state.uri.queryParameters['reauth'] == '1'; final isPublicPath = publicPaths.contains(path); @@ -124,6 +130,11 @@ class AppRouter { parentNavigatorKey: _rootNavigatorKey, pageBuilder: (context, state) => _page(context, state, OAuthCallbackScreen(callbackUri: state.uri)), ), + GoRoute( + path: OAuthCallbackScreen.compatibilityRoutePath, + parentNavigatorKey: _rootNavigatorKey, + pageBuilder: (context, state) => _page(context, state, OAuthCallbackScreen(callbackUri: state.uri)), + ), GoRoute(path: '/terms', pageBuilder: (context, state) => _page(context, state, const TermsOfServiceScreen())), GoRoute(path: '/privacy', pageBuilder: (context, state) => _page(context, state, const PrivacyPolicyScreen())), GoRoute(path: '/notifications', redirect: (_, _) => '/alerts'), diff --git a/lib/features/auth/bloc/auth_bloc.dart b/lib/features/auth/bloc/auth_bloc.dart index a885d00..ac216d1 100644 --- a/lib/features/auth/bloc/auth_bloc.dart +++ b/lib/features/auth/bloc/auth_bloc.dart @@ -13,6 +13,7 @@ class AuthBloc extends Bloc { on(_onLoginRequested); on(_onOAuthLoginRequested); on(_onLogoutRequested); + on(_onLocalAuthDataClearRequested); on(_onSessionRestored); on(_onCheckSessionRequested); on(_onSessionCleared); @@ -65,6 +66,15 @@ class AuthBloc extends Bloc { } } + Future _onLocalAuthDataClearRequested(LocalAuthDataClearRequested event, Emitter emit) async { + try { + await _authRepository.clearSession(); + emit(const AuthState.unauthenticated()); + } catch (error) { + emit(AuthState.authError('Failed to clear sign-in data: $error')); + } + } + Future _onSessionRestored(SessionRestored event, Emitter emit) async { emit(AuthState.authenticated(event.tokens)); } diff --git a/lib/features/auth/bloc/auth_event.dart b/lib/features/auth/bloc/auth_event.dart index 3b678c9..4eb9fe9 100644 --- a/lib/features/auth/bloc/auth_event.dart +++ b/lib/features/auth/bloc/auth_event.dart @@ -28,6 +28,10 @@ class LogoutRequested extends AuthEvent { const LogoutRequested(); } +class LocalAuthDataClearRequested extends AuthEvent { + const LocalAuthDataClearRequested(); +} + class SessionRestored extends AuthEvent { const SessionRestored({required this.tokens}); final AuthTokens tokens; diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index c4ef80c..6e30d4c 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -96,6 +96,7 @@ class AuthRepository { Completer? _oauthCompleter; OAuthClient? _pendingOAuthClient; OAuthContext? _pendingOAuthContext; + Future? _pendingOAuthCallbackExchange; String? _pendingHandle; String? _pendingService; LaunchMode? _oauthLaunchMode; @@ -248,6 +249,7 @@ class AuthRepository { final failedAttemptSummaries = []; for (final oauthService in oauthServices) { + Completer? callbackCompleter; try { final oauthClient = OAuthClient( metadata.copyWith(redirectUris: [redirectUri.toString()]), @@ -258,14 +260,11 @@ class AuthRepository { _pendingService = oauthService; _pendingOAuthClient = oauthClient; _pendingOAuthContext = context; + callbackCompleter = _oauthCompleter!; log.i('AuthRepository: OAuth PAR completed, launching browser to ${_sanitizeUriForLog(authorizationUrl)}'); await _launchUrl(authorizationUrl); - - return await _oauthCompleter!.future.timeout( - const Duration(minutes: 3), - onTimeout: () => throw TimeoutException('Timed out waiting for OAuth callback redirect'), - ); } catch (error, stackTrace) { + _resetPendingOAuthAttemptState(clearHandle: false); lastAttemptError = error; lastAttemptStackTrace = stackTrace; final summary = _summarizeOAuthRefreshError(error); @@ -275,7 +274,13 @@ class AuthRepository { error: error, stackTrace: stackTrace, ); + continue; } + + return await callbackCompleter.future.timeout( + const Duration(minutes: 3), + onTimeout: () => throw TimeoutException('Timed out waiting for OAuth callback redirect'), + ); } Error.throwWithStackTrace( @@ -507,9 +512,10 @@ class AuthRepository { return false; } + final joiningInFlightExchange = _pendingOAuthCallbackExchange != null; try { log.i('AuthRepository: Processing OAuth callback URI ${_sanitizeUriForLog(normalizedCallbackUri)}'); - final tokens = await _handleOAuthCallback(normalizedCallbackUri.toString()); + final tokens = await _runOAuthCallbackExchangeOnce(normalizedCallbackUri, _handleOAuthCallback); if (_oauthCompleter?.isCompleted == false) { _oauthCompleter?.complete(tokens); } @@ -521,8 +527,28 @@ class AuthRepository { } return false; } finally { - _resetPendingOAuthState(clearLaunchMode: false); + if (!joiningInFlightExchange) { + _resetPendingOAuthState(clearLaunchMode: false); + } + } + } + + Future _runOAuthCallbackExchangeOnce( + Uri normalizedCallbackUri, + Future Function(String callbackUrl) exchangeCallback, + ) async { + final inFlightExchange = _pendingOAuthCallbackExchange; + if (inFlightExchange != null) { + log.w( + 'AuthRepository: OAuth callback already being exchanged; ' + 'joining existing exchange for ${_sanitizeUriForLog(normalizedCallbackUri)}', + ); + return inFlightExchange; } + + final exchange = exchangeCallback(normalizedCallbackUri.toString()); + _pendingOAuthCallbackExchange = exchange; + return exchange; } Future _buildOAuthTokens( @@ -854,14 +880,28 @@ class AuthRepository { return callbackUri; } + if (callbackUri.scheme == _mobileOAuthRedirectScheme && + callbackUri.host == 'oauth' && + callbackUri.path == '/callback' && + _hasOAuthCallbackParameters(callbackUri)) { + return Uri( + scheme: _mobileOAuthRedirectScheme, + path: _mobileOAuthRedirectPath, + query: callbackUri.hasQuery ? callbackUri.query : null, + fragment: callbackUri.hasFragment ? callbackUri.fragment : null, + ); + } + if (_isSupportedHttpsRedirect(callbackUri)) { return callbackUri; } - if (!callbackUri.hasScheme && callbackUri.path == _mobileOAuthRedirectPath) { + if (!callbackUri.hasScheme && + (callbackUri.path == _mobileOAuthRedirectPath || callbackUri.path == '/callback') && + _hasOAuthCallbackParameters(callbackUri)) { return Uri( scheme: _mobileOAuthRedirectScheme, - path: callbackUri.path, + path: _mobileOAuthRedirectPath, query: callbackUri.hasQuery ? callbackUri.query : null, fragment: callbackUri.hasFragment ? callbackUri.fragment : null, ); @@ -870,6 +910,12 @@ class AuthRepository { return null; } + bool _hasOAuthCallbackParameters(Uri callbackUri) { + final queryParameters = callbackUri.queryParameters; + return queryParameters.containsKey('state') && + (queryParameters.containsKey('code') || queryParameters.containsKey('error')); + } + Uri _selectOAuthRedirectUriTemplate( List redirectUris, { required bool isAndroid, @@ -915,6 +961,14 @@ class AuthRepository { @visibleForTesting Uri? normalizeOAuthCallbackUriForTest(Uri callbackUri) => _normalizeOAuthCallbackUri(callbackUri); + @visibleForTesting + Future runOAuthCallbackExchangeOnceForTest( + Uri normalizedCallbackUri, + Future Function(String callbackUrl) exchangeCallback, + ) { + return _runOAuthCallbackExchangeOnce(normalizedCallbackUri, exchangeCallback); + } + @visibleForTesting Uri selectOAuthRedirectUriTemplateForTest( List redirectUris, { @@ -945,12 +999,19 @@ class AuthRepository { void _resetPendingOAuthState({bool clearLaunchMode = true}) { _oauthCompleter = null; + _resetPendingOAuthAttemptState(); + if (clearLaunchMode) { + _oauthLaunchMode = null; + } + } + + void _resetPendingOAuthAttemptState({bool clearHandle = true}) { _pendingOAuthClient = null; _pendingOAuthContext = null; - _pendingHandle = null; + _pendingOAuthCallbackExchange = null; _pendingService = null; - if (clearLaunchMode) { - _oauthLaunchMode = null; + if (clearHandle) { + _pendingHandle = null; } } diff --git a/lib/features/auth/presentation/oauth_callback_screen.dart b/lib/features/auth/presentation/oauth_callback_screen.dart index e80a561..8212be8 100644 --- a/lib/features/auth/presentation/oauth_callback_screen.dart +++ b/lib/features/auth/presentation/oauth_callback_screen.dart @@ -9,6 +9,7 @@ class OAuthCallbackScreen extends StatefulWidget { const OAuthCallbackScreen({required this.callbackUri, super.key}); static const String routePath = '/oauth/callback'; + static const String compatibilityRoutePath = '/callback'; final Uri callbackUri; diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index a43ae1e..b9a16c2 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/atproto_host_resolver.dart'; @@ -114,6 +115,9 @@ class SettingsScreen extends StatelessWidget { _buildSectionHeader(context, 'Advanced'), _buildAdvancedSettings(context), const SizedBox(height: 24), + _buildSectionHeader(context, 'Troubleshooting'), + _buildTroubleshootingSettings(context), + const SizedBox(height: 24), if (!kReleaseMode || kDebugMode) ...[ _buildSectionHeader(context, 'Developer'), _buildDeveloperSettings(context), @@ -579,6 +583,104 @@ class SettingsScreen extends StatelessWidget { } await crashReportingService.deleteUnsentReports(); } + + Widget _buildTroubleshootingSettings(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.dividerColor), + bottom: BorderSide(color: theme.dividerColor), + ), + color: theme.cardColor, + ), + child: Column( + children: [ + _SettingsTile( + icon: Icons.cached_outlined, + title: 'Clear Cache', + subtitle: 'Remove cached posts, profiles, images, feeds, threads, and semantic search data', + onTap: () => unawaited(_confirmAndClearCaches(context)), + ), + const Divider(height: 1), + _SettingsTile( + icon: Icons.manage_accounts_outlined, + title: 'Reset Sign-In Data', + subtitle: 'Troubleshoot OAuth or account-switching issues by clearing local sessions on this device', + isDestructive: true, + onTap: () => unawaited(_confirmAndClearLocalAuthData(context)), + ), + ], + ), + ); + } + + Future _confirmAndClearCaches(BuildContext context) async { + final shouldClear = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Clear cache?'), + content: const Text( + 'This removes cached posts, profiles, images, feeds, threads, label data, and local semantic search data.\n\n' + 'Accounts, settings, drafts, bookmarks, and likes are kept.', + ), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Clear Cache')), + ], + ); + }, + ); + + if (shouldClear != true || !context.mounted) { + return; + } + + try { + await context.read().clearCaches(); + if (context.mounted) { + showAppSnackBar(context, 'Cache cleared'); + } + } catch (error) { + if (context.mounted) { + showAppSnackBar(context, 'Failed to clear cache: $error', isError: true); + } + } + } + + Future _confirmAndClearLocalAuthData(BuildContext context) async { + final shouldClear = await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Reset sign-in data?'), + content: const Text( + 'Use this only when troubleshooting sign-in or account switching.\n\n' + 'This clears all local account sessions on this device and sends you back to sign in. ' + 'It does not delete your Bluesky account or posts.', + ), + actions: [ + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + foregroundColor: Theme.of(dialogContext).colorScheme.onError, + ), + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Reset Sign-In Data'), + ), + ], + ); + }, + ); + + if (shouldClear != true || !context.mounted) { + return; + } + + context.read().add(const LocalAuthDataClearRequested()); + } } class _ModerationSettingsPreview extends StatefulWidget { diff --git a/lib/main.dart b/lib/main.dart index 3deaf82..8622083 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/bootstrap/auth_bootstrap.dart'; +import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/database/app_database.dart'; @@ -469,8 +470,13 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { - return RepositoryProvider.value( - value: widget.crashReportingService, + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: widget.crashReportingService), + RepositoryProvider( + create: (_) => LocalCacheMaintenanceService(database: widget.database, objectBoxStore: widget.objectBoxStore), + ), + ], child: MultiBlocProvider( providers: [ BlocProvider.value(value: widget.authBloc), diff --git a/test/core/cache/local_cache_maintenance_service_test.dart b/test/core/cache/local_cache_maintenance_service_test.dart new file mode 100644 index 0000000..5c47648 --- /dev/null +++ b/test/core/cache/local_cache_maintenance_service_test.dart @@ -0,0 +1,70 @@ +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/objectbox/embedded_post.dart'; +import 'package:lazurite/core/objectbox/objectbox_store.dart'; +import 'package:lazurite/features/search/data/embedding_repository.dart'; +import 'package:lazurite/objectbox.g.dart'; + +var _storeCounter = 0; + +ObjectBoxStore _makeInMemoryStore() { + final store = Store(getObjectBoxModel(), directory: 'memory:cache-maintenance-${_storeCounter++}'); + return ObjectBoxStore.forTesting(store); +} + +EmbeddedPost _post({required String postUri, required String accountDid}) { + return EmbeddedPost( + postUri: postUri, + accountDid: accountDid, + source: 'saved', + indexedText: 'cached text', + embedding: Float32List(384), + embeddedAt: DateTime(2026, 1, 1), + ); +} + +void main() { + late AppDatabase database; + late ObjectBoxStore objectBoxStore; + late EmbeddingRepository embeddingRepository; + + setUp(() { + database = AppDatabase(executor: NativeDatabase.memory()); + objectBoxStore = _makeInMemoryStore(); + embeddingRepository = EmbeddingRepository(objectBoxStore); + }); + + tearDown(() async { + objectBoxStore.close(); + await database.close(); + }); + + test('clearCaches clears database caches, semantic index, and image caches', () async { + var diskImageCleared = false; + var memoryImageCleared = false; + final service = LocalCacheMaintenanceService( + database: database, + objectBoxStore: objectBoxStore, + clearImageDiskCache: () async { + diskImageCleared = true; + }, + clearImageMemoryCache: () { + memoryImageCleared = true; + }, + ); + + await database.cacheProfile(did: 'did:plc:user', handle: 'user.bsky.social', payload: '{}'); + embeddingRepository.upsert(_post(postUri: 'at://did:plc:user/app.bsky.feed.post/1', accountDid: 'did:plc:user')); + + await service.clearCaches(); + + expect(await database.select(database.cachedProfiles).get(), isEmpty); + expect(embeddingRepository.countByAccount('did:plc:user'), 0); + expect(diskImageCleared, isTrue); + expect(memoryImageCleared, isTrue); + }); +} diff --git a/test/core/database/app_database_test.dart b/test/core/database/app_database_test.dart index 9c66677..8a2c8a3 100644 --- a/test/core/database/app_database_test.dart +++ b/test/core/database/app_database_test.dart @@ -262,6 +262,65 @@ void main() { expect(newest, isNotNull); expect(oldest, isNull); }); + + test('clearLocalCaches removes cache tables while preserving user data', () async { + await database.insertAccount( + AccountsCompanion.insert(did: 'did:plc:user', handle: 'user.bsky.social', accessToken: 'token'), + ); + await database.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:user'); + await database.setSetting('theme', 'dark'); + await database.setSetting('moderation_preferences::did:plc:user', '[]'); + await database.cacheProfile(did: 'did:plc:user', handle: 'user.bsky.social', payload: '{}'); + await database.cachePost( + uri: 'at://did:plc:user/app.bsky.feed.post/1', + authorDid: 'did:plc:user', + payload: '{}', + ); + await database.cacheFeedPage(accountDid: 'did:plc:user', feedKey: 'timeline', payload: '{}'); + await database.upsertCachedFeedPosts( + accountDid: 'did:plc:user', + feedKey: 'timeline', + posts: [ + CachedFeedPostsCompanion.insert( + accountDid: 'did:plc:user', + feedKey: 'timeline', + postUri: 'at://did:plc:user/app.bsky.feed.post/1', + postJson: '{}', + sortOrder: 1, + ), + ], + ); + await database.cacheThreadRoot( + accountDid: 'did:plc:user', + rootUri: 'at://did:plc:user/app.bsky.feed.post/1', + payload: '{}', + ); + await database.upsertLabelerCache('did:plc:labeler', '{}'); + await database.saveDraft(DraftsCompanion.insert(accountDid: 'did:plc:user', content: 'draft')); + await database.savePost( + SavedPostsCompanion.insert( + accountDid: 'did:plc:user', + postUri: 'at://did:plc:user/app.bsky.feed.post/saved', + postJson: '{}', + ), + ); + + await database.clearLocalCaches(); + + expect(await database.select(database.cachedProfiles).get(), isEmpty); + expect(await database.select(database.cachedPosts).get(), isEmpty); + expect(await database.select(database.cachedFeedPages).get(), isEmpty); + expect(await database.select(database.cachedFeedPosts).get(), isEmpty); + expect(await database.select(database.cachedThreadRoots).get(), isEmpty); + expect(await database.select(database.labelerCache).get(), isEmpty); + expect(await database.getSetting('moderation_preferences::did:plc:user'), isNull); + + expect(await database.getAccount('did:plc:user'), isNotNull); + expect(await database.getSetting(AppDatabase.activeAccountDidSettingKey), 'did:plc:user'); + expect(await database.getSetting('theme'), 'dark'); + expect(await database.getDrafts('did:plc:user'), hasLength(1)); + expect(await database.getSavedPosts('did:plc:user'), hasLength(1)); + }); }); group('Notification delivery operations', () { diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 0c988d2..0b9c429 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -562,4 +562,30 @@ void main() { router.dispose(); }); + + testWidgets('processes compatibility oauth callback route while authenticated', (tester) async { + final router = AppRouter(authBloc: authBloc).router; + final pendingCallback = Completer(); + when(() => authBloc.handleOAuthRedirectUri(any())).thenAnswer((_) => pendingCallback.future); + + await tester.pumpWidget(buildSubjectWithRouter(router)); + router.go('/callback?code=abc&state=xyz'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + verify( + () => authBloc.handleOAuthRedirectUri( + any(that: predicate((uri) => uri.path == OAuthCallbackScreen.compatibilityRoutePath)), + ), + ).called(1); + expect(router.routeInformationProvider.value.uri.path, equals(OAuthCallbackScreen.compatibilityRoutePath)); + + pendingCallback.complete(true); + await tester.pumpAndSettle(); + + expect(router.routeInformationProvider.value.uri.path, isNot(equals(OAuthCallbackScreen.compatibilityRoutePath))); + expect(find.text('No feeds pinned'), findsOneWidget); + + router.dispose(); + }); } diff --git a/test/features/auth/bloc/auth_bloc_test.dart b/test/features/auth/bloc/auth_bloc_test.dart index 3a6f3ab..e2ec7d3 100644 --- a/test/features/auth/bloc/auth_bloc_test.dart +++ b/test/features/auth/bloc/auth_bloc_test.dart @@ -76,6 +76,21 @@ void main() { expect: () => [const AuthState.unauthenticated()], ); + blocTest( + 'clears local auth data and emits [unauthenticated] when LocalAuthDataClearRequested is added', + build: () => AuthBloc(authRepository: mockAuthRepository), + seed: () => const AuthState.authenticated(tokens), + setUp: () { + when(() => mockAuthRepository.clearSession()).thenAnswer((_) async {}); + }, + act: (bloc) => bloc.add(const LocalAuthDataClearRequested()), + expect: () => [const AuthState.unauthenticated()], + verify: (_) { + verify(() => mockAuthRepository.clearSession()).called(1); + verifyNever(() => mockAuthRepository.logout()); + }, + ); + blocTest( 'emits [authenticated] when SessionRestored is added', build: () => AuthBloc(authRepository: mockAuthRepository), diff --git a/test/features/auth/bloc/auth_event_test.dart b/test/features/auth/bloc/auth_event_test.dart index 883d5e0..050d517 100644 --- a/test/features/auth/bloc/auth_event_test.dart +++ b/test/features/auth/bloc/auth_event_test.dart @@ -45,6 +45,15 @@ void main() { }); }); + group('LocalAuthDataClearRequested', () { + test('should support value equality', () { + const event1 = LocalAuthDataClearRequested(); + const event2 = LocalAuthDataClearRequested(); + + expect(event1, equals(event2)); + }); + }); + group('SessionRestored', () { test('should support value equality', () { const event1 = SessionRestored(tokens: tokens); diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index d2bfe01..a7b88ac 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:atproto_core/atproto_core.dart' as atcore; @@ -396,6 +397,28 @@ void main() { expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); }); + test('normalizes authority-style custom scheme callback URI to canonical custom scheme', () { + final normalized = authRepository.normalizeOAuthCallbackUriForTest( + Uri.parse('org.stormlightlabs.lazurite://oauth/callback?code=abc&state=xyz'), + ); + + expect(normalized, isNotNull); + expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); + }); + + test('normalizes compatibility callback path to canonical custom scheme', () { + final normalized = authRepository.normalizeOAuthCallbackUriForTest(Uri.parse('/callback?code=abc&state=xyz')); + + expect(normalized, isNotNull); + expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); + }); + + test('rejects path-only callback without oauth response parameters', () { + final normalized = authRepository.normalizeOAuthCallbackUriForTest(Uri.parse('/callback?foo=bar')); + + expect(normalized, isNull); + }); + test('accepts exact HTTPS callback URI with oauth query parameters', () { final normalized = authRepository.normalizeOAuthCallbackUriForTest( Uri.parse( @@ -428,6 +451,38 @@ void main() { }); }); + group('oauth callback exchange coordination', () { + test('joins duplicate callback deliveries to one token exchange', () async { + const tokens = AuthTokens(accessToken: 'access', did: 'did:plc:abc123', handle: 'user.bsky.social'); + final exchangeCompleter = Completer(); + var exchangeCalls = 0; + + Future exchange(String callbackUrl) { + exchangeCalls += 1; + expect(callbackUrl, equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); + return exchangeCompleter.future; + } + + final firstResult = authRepository.runOAuthCallbackExchangeOnceForTest( + Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), + exchange, + ); + final secondResult = authRepository.runOAuthCallbackExchangeOnceForTest( + Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), + exchange, + ); + + await Future.delayed(Duration.zero); + expect(exchangeCalls, equals(1)); + + exchangeCompleter.complete(tokens); + + expect(await firstResult, equals(tokens)); + expect(await secondResult, equals(tokens)); + expect(exchangeCalls, equals(1)); + }); + }); + group('oauth redirect URI selection', () { test('prefers HTTPS callback on Android when flag is enabled', () { final selected = authRepository.selectOAuthRedirectUriTemplateForTest( diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index b55e4d2..a61b08f 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; @@ -24,6 +25,8 @@ class MockAuthBloc extends MockBloc implements AuthBloc {} class MockSettingsCubit extends MockCubit implements SettingsCubit {} +class MockLocalCacheMaintenanceService extends Mock implements LocalCacheMaintenanceService {} + class FakeCrashReportingService implements CrashReportingService { var crashCalls = 0; @@ -52,12 +55,14 @@ void main() { late MockAccountSwitcherCubit accountSwitcherCubit; late MockAuthBloc authBloc; late MockSettingsCubit settingsCubit; + late MockLocalCacheMaintenanceService cacheMaintenanceService; late FakeCrashReportingService crashReportingService; setUp(() { accountSwitcherCubit = MockAccountSwitcherCubit(); authBloc = MockAuthBloc(); settingsCubit = MockSettingsCubit(); + cacheMaintenanceService = MockLocalCacheMaintenanceService(); crashReportingService = FakeCrashReportingService(); when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); @@ -91,11 +96,15 @@ void main() { when(() => settingsCubit.refreshAppViewHealth()).thenAnswer((_) async {}); when(() => settingsCubit.setCrashReportingEnabled(any())).thenAnswer((_) async {}); when(() => settingsCubit.setCrashReportingConsentPrompted(any())).thenAnswer((_) async {}); + when(() => cacheMaintenanceService.clearCaches()).thenAnswer((_) async {}); }); Widget buildSubject() { - return RepositoryProvider.value( - value: crashReportingService, + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: crashReportingService), + RepositoryProvider.value(value: cacheMaintenanceService), + ], child: MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), @@ -112,8 +121,11 @@ void main() { routes: [ GoRoute( path: '/', - builder: (context, state) => RepositoryProvider.value( - value: crashReportingService, + builder: (context, state) => MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: crashReportingService), + RepositoryProvider.value(value: cacheMaintenanceService), + ], child: MultiBlocProvider( providers: [ BlocProvider.value(value: authBloc), @@ -302,6 +314,75 @@ void main() { expect(find.byIcon(Icons.edit_outlined), findsNothing); }); + testWidgets('troubleshooting reset sign-in data requires confirmation before clearing local auth data', ( + tester, + ) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('TROUBLESHOOTING'), 300); + await tester.pumpAndSettle(); + + expect(find.text('TROUBLESHOOTING'), findsOneWidget); + expect(find.text('Reset Sign-In Data'), findsOneWidget); + expect( + find.text('Troubleshoot OAuth or account-switching issues by clearing local sessions on this device'), + findsOneWidget, + ); + + await tester.tap(find.text('Reset Sign-In Data')); + await tester.pumpAndSettle(); + + expect(find.text('Reset sign-in data?'), findsOneWidget); + expect(find.textContaining('It does not delete your Bluesky account or posts.'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => authBloc.add(const LocalAuthDataClearRequested())); + + await tester.tap(find.text('Reset Sign-In Data')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Reset Sign-In Data')); + await tester.pumpAndSettle(); + + verify(() => authBloc.add(const LocalAuthDataClearRequested())).called(1); + }); + + testWidgets('troubleshooting clear cache requires confirmation and keeps auth state intact', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('TROUBLESHOOTING'), 300); + await tester.pumpAndSettle(); + + expect(find.text('Clear Cache'), findsOneWidget); + expect( + find.text('Remove cached posts, profiles, images, feeds, threads, and semantic search data'), + findsOneWidget, + ); + + await tester.tap(find.text('Clear Cache')); + await tester.pumpAndSettle(); + + expect(find.text('Clear cache?'), findsOneWidget); + expect(find.textContaining('Accounts, settings, drafts, bookmarks, and likes are kept.'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + verifyNever(() => cacheMaintenanceService.clearCaches()); + + await tester.tap(find.text('Clear Cache')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Clear Cache')); + await tester.pumpAndSettle(); + + verify(() => cacheMaintenanceService.clearCaches()).called(1); + verifyNever(() => authBloc.add(const LocalAuthDataClearRequested())); + expect(find.text('Cache cleared'), findsOneWidget); + }); + testWidgets('crash reporting toggle persists consent and reporting state', (tester) async { await tester.pumpWidget(buildSubject()); await tester.pumpAndSettle(); diff --git a/www/oauth/callback/index.html b/www/oauth/callback/index.html index 287634d..afee6db 100644 --- a/www/oauth/callback/index.html +++ b/www/oauth/callback/index.html @@ -20,37 +20,29 @@

If this still fails, use your browser menu and choose “Open in app”.

-