mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: OAuth callback duplicate exchange loop & add troubleshooting actions for cache/session reset (#14)

* fix: OAuth callback handling to coordinate duplicate exchanges

* feat: local cache maintenance service and add cache clearing

authored by

Owais and committed by
GitHub
5320aa6b 9e23bad9

+588 -38
+36
lib/core/cache/local_cache_maintenance_service.dart
··· 1 + import 'package:flutter/widgets.dart'; 2 + import 'package:lazurite/core/cache/lazurite_image_cache.dart'; 3 + import 'package:lazurite/core/database/app_database.dart'; 4 + import 'package:lazurite/core/objectbox/embedded_post.dart'; 5 + import 'package:lazurite/core/objectbox/objectbox_store.dart'; 6 + import 'package:lazurite/objectbox.g.dart'; 7 + 8 + class LocalCacheMaintenanceService { 9 + LocalCacheMaintenanceService({ 10 + required AppDatabase database, 11 + required ObjectBoxStore objectBoxStore, 12 + Future<void> Function()? clearImageDiskCache, 13 + VoidCallback? clearImageMemoryCache, 14 + }) : _database = database, 15 + _objectBoxStore = objectBoxStore, 16 + _clearImageDiskCache = clearImageDiskCache ?? LazuriteImageCacheManager.instance.emptyCache, 17 + _clearImageMemoryCache = clearImageMemoryCache ?? _defaultClearImageMemoryCache; 18 + 19 + final AppDatabase _database; 20 + final ObjectBoxStore _objectBoxStore; 21 + final Future<void> Function() _clearImageDiskCache; 22 + final VoidCallback _clearImageMemoryCache; 23 + 24 + Future<void> clearCaches() async { 25 + await _database.clearLocalCaches(); 26 + Box<EmbeddedPost>(_objectBoxStore.store).removeAll(); 27 + await _clearImageDiskCache(); 28 + _clearImageMemoryCache(); 29 + } 30 + 31 + static void _defaultClearImageMemoryCache() { 32 + final imageCache = PaintingBinding.instance.imageCache; 33 + imageCache.clear(); 34 + imageCache.clearLiveImages(); 35 + } 36 + }
+12
lib/core/database/app_database.dart
··· 266 266 267 267 Future<int> deleteSetting(String key) => (delete(settings)..where((s) => s.key.equals(key))).go(); 268 268 269 + Future<void> clearLocalCaches() async { 270 + await transaction(() async { 271 + await delete(cachedProfiles).go(); 272 + await delete(cachedPosts).go(); 273 + await delete(cachedFeedPages).go(); 274 + await delete(cachedFeedPosts).go(); 275 + await delete(cachedThreadRoots).go(); 276 + await delete(labelerCache).go(); 277 + await customStatement("DELETE FROM settings WHERE key LIKE 'moderation_preferences::%'"); 278 + }); 279 + } 280 + 269 281 Future<List<SavedFeedEntry>> getSavedFeeds(String accountDid) => 270 282 (select(savedFeeds) 271 283 ..where((f) => f.accountDid.equals(accountDid))
+13 -2
lib/core/router/app_router.dart
··· 53 53 import 'package:lazurite/features/profile/presentation/follow_audit_screen.dart'; 54 54 import 'package:lazurite/features/profile/presentation/profile_context_screen.dart'; 55 55 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 56 + import 'package:lazurite/features/search/bloc/search_bloc.dart'; 56 57 import 'package:lazurite/features/search/cubit/hashtag_cubit.dart'; 57 58 import 'package:lazurite/features/search/cubit/topic_cubit.dart'; 58 - import 'package:lazurite/features/search/bloc/search_bloc.dart'; 59 59 import 'package:lazurite/features/search/data/hashtag_utils.dart'; 60 60 import 'package:lazurite/features/search/data/search_repository.dart'; 61 61 import 'package:lazurite/features/search/presentation/hashtag_screen.dart'; ··· 102 102 redirect: (context, state) { 103 103 final isAuthenticated = authBloc.state.isAuthenticated; 104 104 final path = state.uri.path; 105 - final publicPaths = {'/login', '/terms', '/privacy', OAuthCallbackScreen.routePath}; 105 + final publicPaths = { 106 + '/login', 107 + '/terms', 108 + '/privacy', 109 + OAuthCallbackScreen.routePath, 110 + OAuthCallbackScreen.compatibilityRoutePath, 111 + }; 106 112 final isLoggingIn = path == '/login'; 107 113 final isReauthLogin = state.uri.queryParameters['reauth'] == '1'; 108 114 final isPublicPath = publicPaths.contains(path); ··· 121 127 GoRoute(path: '/login', pageBuilder: (context, state) => _page(context, state, const LoginScreen())), 122 128 GoRoute( 123 129 path: OAuthCallbackScreen.routePath, 130 + parentNavigatorKey: _rootNavigatorKey, 131 + pageBuilder: (context, state) => _page(context, state, OAuthCallbackScreen(callbackUri: state.uri)), 132 + ), 133 + GoRoute( 134 + path: OAuthCallbackScreen.compatibilityRoutePath, 124 135 parentNavigatorKey: _rootNavigatorKey, 125 136 pageBuilder: (context, state) => _page(context, state, OAuthCallbackScreen(callbackUri: state.uri)), 126 137 ),
+10
lib/features/auth/bloc/auth_bloc.dart
··· 13 13 on<LoginRequested>(_onLoginRequested); 14 14 on<OAuthLoginRequested>(_onOAuthLoginRequested); 15 15 on<LogoutRequested>(_onLogoutRequested); 16 + on<LocalAuthDataClearRequested>(_onLocalAuthDataClearRequested); 16 17 on<SessionRestored>(_onSessionRestored); 17 18 on<CheckSessionRequested>(_onCheckSessionRequested); 18 19 on<SessionCleared>(_onSessionCleared); ··· 62 63 emit(const AuthState.unauthenticated()); 63 64 } catch (error) { 64 65 emit(AuthState.authError('Logout failed: $error')); 66 + } 67 + } 68 + 69 + Future<void> _onLocalAuthDataClearRequested(LocalAuthDataClearRequested event, Emitter<AuthState> emit) async { 70 + try { 71 + await _authRepository.clearSession(); 72 + emit(const AuthState.unauthenticated()); 73 + } catch (error) { 74 + emit(AuthState.authError('Failed to clear sign-in data: $error')); 65 75 } 66 76 } 67 77
+4
lib/features/auth/bloc/auth_event.dart
··· 28 28 const LogoutRequested(); 29 29 } 30 30 31 + class LocalAuthDataClearRequested extends AuthEvent { 32 + const LocalAuthDataClearRequested(); 33 + } 34 + 31 35 class SessionRestored extends AuthEvent { 32 36 const SessionRestored({required this.tokens}); 33 37 final AuthTokens tokens;
+73 -12
lib/features/auth/data/auth_repository.dart
··· 96 96 Completer<AuthTokens?>? _oauthCompleter; 97 97 OAuthClient? _pendingOAuthClient; 98 98 OAuthContext? _pendingOAuthContext; 99 + Future<AuthTokens>? _pendingOAuthCallbackExchange; 99 100 String? _pendingHandle; 100 101 String? _pendingService; 101 102 LaunchMode? _oauthLaunchMode; ··· 248 249 final failedAttemptSummaries = <String>[]; 249 250 250 251 for (final oauthService in oauthServices) { 252 + Completer<AuthTokens?>? callbackCompleter; 251 253 try { 252 254 final oauthClient = OAuthClient( 253 255 metadata.copyWith(redirectUris: [redirectUri.toString()]), ··· 258 260 _pendingService = oauthService; 259 261 _pendingOAuthClient = oauthClient; 260 262 _pendingOAuthContext = context; 263 + callbackCompleter = _oauthCompleter!; 261 264 log.i('AuthRepository: OAuth PAR completed, launching browser to ${_sanitizeUriForLog(authorizationUrl)}'); 262 265 await _launchUrl(authorizationUrl); 263 - 264 - return await _oauthCompleter!.future.timeout( 265 - const Duration(minutes: 3), 266 - onTimeout: () => throw TimeoutException('Timed out waiting for OAuth callback redirect'), 267 - ); 268 266 } catch (error, stackTrace) { 267 + _resetPendingOAuthAttemptState(clearHandle: false); 269 268 lastAttemptError = error; 270 269 lastAttemptStackTrace = stackTrace; 271 270 final summary = _summarizeOAuthRefreshError(error); ··· 275 274 error: error, 276 275 stackTrace: stackTrace, 277 276 ); 277 + continue; 278 278 } 279 + 280 + return await callbackCompleter.future.timeout( 281 + const Duration(minutes: 3), 282 + onTimeout: () => throw TimeoutException('Timed out waiting for OAuth callback redirect'), 283 + ); 279 284 } 280 285 281 286 Error.throwWithStackTrace( ··· 507 512 return false; 508 513 } 509 514 515 + final joiningInFlightExchange = _pendingOAuthCallbackExchange != null; 510 516 try { 511 517 log.i('AuthRepository: Processing OAuth callback URI ${_sanitizeUriForLog(normalizedCallbackUri)}'); 512 - final tokens = await _handleOAuthCallback(normalizedCallbackUri.toString()); 518 + final tokens = await _runOAuthCallbackExchangeOnce(normalizedCallbackUri, _handleOAuthCallback); 513 519 if (_oauthCompleter?.isCompleted == false) { 514 520 _oauthCompleter?.complete(tokens); 515 521 } ··· 521 527 } 522 528 return false; 523 529 } finally { 524 - _resetPendingOAuthState(clearLaunchMode: false); 530 + if (!joiningInFlightExchange) { 531 + _resetPendingOAuthState(clearLaunchMode: false); 532 + } 533 + } 534 + } 535 + 536 + Future<AuthTokens> _runOAuthCallbackExchangeOnce( 537 + Uri normalizedCallbackUri, 538 + Future<AuthTokens> Function(String callbackUrl) exchangeCallback, 539 + ) async { 540 + final inFlightExchange = _pendingOAuthCallbackExchange; 541 + if (inFlightExchange != null) { 542 + log.w( 543 + 'AuthRepository: OAuth callback already being exchanged; ' 544 + 'joining existing exchange for ${_sanitizeUriForLog(normalizedCallbackUri)}', 545 + ); 546 + return inFlightExchange; 525 547 } 548 + 549 + final exchange = exchangeCallback(normalizedCallbackUri.toString()); 550 + _pendingOAuthCallbackExchange = exchange; 551 + return exchange; 526 552 } 527 553 528 554 Future<AuthTokens> _buildOAuthTokens( ··· 854 880 return callbackUri; 855 881 } 856 882 883 + if (callbackUri.scheme == _mobileOAuthRedirectScheme && 884 + callbackUri.host == 'oauth' && 885 + callbackUri.path == '/callback' && 886 + _hasOAuthCallbackParameters(callbackUri)) { 887 + return Uri( 888 + scheme: _mobileOAuthRedirectScheme, 889 + path: _mobileOAuthRedirectPath, 890 + query: callbackUri.hasQuery ? callbackUri.query : null, 891 + fragment: callbackUri.hasFragment ? callbackUri.fragment : null, 892 + ); 893 + } 894 + 857 895 if (_isSupportedHttpsRedirect(callbackUri)) { 858 896 return callbackUri; 859 897 } 860 898 861 - if (!callbackUri.hasScheme && callbackUri.path == _mobileOAuthRedirectPath) { 899 + if (!callbackUri.hasScheme && 900 + (callbackUri.path == _mobileOAuthRedirectPath || callbackUri.path == '/callback') && 901 + _hasOAuthCallbackParameters(callbackUri)) { 862 902 return Uri( 863 903 scheme: _mobileOAuthRedirectScheme, 864 - path: callbackUri.path, 904 + path: _mobileOAuthRedirectPath, 865 905 query: callbackUri.hasQuery ? callbackUri.query : null, 866 906 fragment: callbackUri.hasFragment ? callbackUri.fragment : null, 867 907 ); 868 908 } 869 909 870 910 return null; 911 + } 912 + 913 + bool _hasOAuthCallbackParameters(Uri callbackUri) { 914 + final queryParameters = callbackUri.queryParameters; 915 + return queryParameters.containsKey('state') && 916 + (queryParameters.containsKey('code') || queryParameters.containsKey('error')); 871 917 } 872 918 873 919 Uri _selectOAuthRedirectUriTemplate( ··· 916 962 Uri? normalizeOAuthCallbackUriForTest(Uri callbackUri) => _normalizeOAuthCallbackUri(callbackUri); 917 963 918 964 @visibleForTesting 965 + Future<AuthTokens> runOAuthCallbackExchangeOnceForTest( 966 + Uri normalizedCallbackUri, 967 + Future<AuthTokens> Function(String callbackUrl) exchangeCallback, 968 + ) { 969 + return _runOAuthCallbackExchangeOnce(normalizedCallbackUri, exchangeCallback); 970 + } 971 + 972 + @visibleForTesting 919 973 Uri selectOAuthRedirectUriTemplateForTest( 920 974 List<String> redirectUris, { 921 975 required bool isAndroid, ··· 945 999 946 1000 void _resetPendingOAuthState({bool clearLaunchMode = true}) { 947 1001 _oauthCompleter = null; 1002 + _resetPendingOAuthAttemptState(); 1003 + if (clearLaunchMode) { 1004 + _oauthLaunchMode = null; 1005 + } 1006 + } 1007 + 1008 + void _resetPendingOAuthAttemptState({bool clearHandle = true}) { 948 1009 _pendingOAuthClient = null; 949 1010 _pendingOAuthContext = null; 950 - _pendingHandle = null; 1011 + _pendingOAuthCallbackExchange = null; 951 1012 _pendingService = null; 952 - if (clearLaunchMode) { 953 - _oauthLaunchMode = null; 1013 + if (clearHandle) { 1014 + _pendingHandle = null; 954 1015 } 955 1016 } 956 1017
+1
lib/features/auth/presentation/oauth_callback_screen.dart
··· 9 9 const OAuthCallbackScreen({required this.callbackUri, super.key}); 10 10 11 11 static const String routePath = '/oauth/callback'; 12 + static const String compatibilityRoutePath = '/callback'; 12 13 13 14 final Uri callbackUri; 14 15
+102
lib/features/settings/presentation/settings_screen.dart
··· 4 4 import 'package:flutter/material.dart'; 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; 7 8 import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; 8 9 import 'package:lazurite/core/network/app_view_provider.dart'; 9 10 import 'package:lazurite/core/network/atproto_host_resolver.dart'; ··· 113 114 const SizedBox(height: 24), 114 115 _buildSectionHeader(context, 'Advanced'), 115 116 _buildAdvancedSettings(context), 117 + const SizedBox(height: 24), 118 + _buildSectionHeader(context, 'Troubleshooting'), 119 + _buildTroubleshootingSettings(context), 116 120 const SizedBox(height: 24), 117 121 if (!kReleaseMode || kDebugMode) ...[ 118 122 _buildSectionHeader(context, 'Developer'), ··· 578 582 return; 579 583 } 580 584 await crashReportingService.deleteUnsentReports(); 585 + } 586 + 587 + Widget _buildTroubleshootingSettings(BuildContext context) { 588 + final theme = Theme.of(context); 589 + return Container( 590 + decoration: BoxDecoration( 591 + border: Border( 592 + top: BorderSide(color: theme.dividerColor), 593 + bottom: BorderSide(color: theme.dividerColor), 594 + ), 595 + color: theme.cardColor, 596 + ), 597 + child: Column( 598 + children: [ 599 + _SettingsTile( 600 + icon: Icons.cached_outlined, 601 + title: 'Clear Cache', 602 + subtitle: 'Remove cached posts, profiles, images, feeds, threads, and semantic search data', 603 + onTap: () => unawaited(_confirmAndClearCaches(context)), 604 + ), 605 + const Divider(height: 1), 606 + _SettingsTile( 607 + icon: Icons.manage_accounts_outlined, 608 + title: 'Reset Sign-In Data', 609 + subtitle: 'Troubleshoot OAuth or account-switching issues by clearing local sessions on this device', 610 + isDestructive: true, 611 + onTap: () => unawaited(_confirmAndClearLocalAuthData(context)), 612 + ), 613 + ], 614 + ), 615 + ); 616 + } 617 + 618 + Future<void> _confirmAndClearCaches(BuildContext context) async { 619 + final shouldClear = await showDialog<bool>( 620 + context: context, 621 + builder: (dialogContext) { 622 + return AlertDialog( 623 + title: const Text('Clear cache?'), 624 + content: const Text( 625 + 'This removes cached posts, profiles, images, feeds, threads, label data, and local semantic search data.\n\n' 626 + 'Accounts, settings, drafts, bookmarks, and likes are kept.', 627 + ), 628 + actions: [ 629 + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), 630 + FilledButton(onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('Clear Cache')), 631 + ], 632 + ); 633 + }, 634 + ); 635 + 636 + if (shouldClear != true || !context.mounted) { 637 + return; 638 + } 639 + 640 + try { 641 + await context.read<LocalCacheMaintenanceService>().clearCaches(); 642 + if (context.mounted) { 643 + showAppSnackBar(context, 'Cache cleared'); 644 + } 645 + } catch (error) { 646 + if (context.mounted) { 647 + showAppSnackBar(context, 'Failed to clear cache: $error', isError: true); 648 + } 649 + } 650 + } 651 + 652 + Future<void> _confirmAndClearLocalAuthData(BuildContext context) async { 653 + final shouldClear = await showDialog<bool>( 654 + context: context, 655 + builder: (dialogContext) { 656 + return AlertDialog( 657 + title: const Text('Reset sign-in data?'), 658 + content: const Text( 659 + 'Use this only when troubleshooting sign-in or account switching.\n\n' 660 + 'This clears all local account sessions on this device and sends you back to sign in. ' 661 + 'It does not delete your Bluesky account or posts.', 662 + ), 663 + actions: [ 664 + TextButton(onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel')), 665 + FilledButton( 666 + style: FilledButton.styleFrom( 667 + backgroundColor: Theme.of(dialogContext).colorScheme.error, 668 + foregroundColor: Theme.of(dialogContext).colorScheme.onError, 669 + ), 670 + onPressed: () => Navigator.of(dialogContext).pop(true), 671 + child: const Text('Reset Sign-In Data'), 672 + ), 673 + ], 674 + ); 675 + }, 676 + ); 677 + 678 + if (shouldClear != true || !context.mounted) { 679 + return; 680 + } 681 + 682 + context.read<AuthBloc>().add(const LocalAuthDataClearRequested()); 581 683 } 582 684 } 583 685
+8 -2
lib/main.dart
··· 9 9 import 'package:flutter_bloc/flutter_bloc.dart'; 10 10 import 'package:go_router/go_router.dart'; 11 11 import 'package:lazurite/core/bootstrap/auth_bootstrap.dart'; 12 + import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; 12 13 import 'package:lazurite/core/cache/offline_cache_policy.dart'; 13 14 import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; 14 15 import 'package:lazurite/core/database/app_database.dart'; ··· 469 470 470 471 @override 471 472 Widget build(BuildContext context) { 472 - return RepositoryProvider<CrashReportingService>.value( 473 - value: widget.crashReportingService, 473 + return MultiRepositoryProvider( 474 + providers: [ 475 + RepositoryProvider<CrashReportingService>.value(value: widget.crashReportingService), 476 + RepositoryProvider( 477 + create: (_) => LocalCacheMaintenanceService(database: widget.database, objectBoxStore: widget.objectBoxStore), 478 + ), 479 + ], 474 480 child: MultiBlocProvider( 475 481 providers: [ 476 482 BlocProvider.value(value: widget.authBloc),
+70
test/core/cache/local_cache_maintenance_service_test.dart
··· 1 + import 'dart:typed_data'; 2 + 3 + import 'package:drift/native.dart'; 4 + import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; 6 + import 'package:lazurite/core/database/app_database.dart'; 7 + import 'package:lazurite/core/objectbox/embedded_post.dart'; 8 + import 'package:lazurite/core/objectbox/objectbox_store.dart'; 9 + import 'package:lazurite/features/search/data/embedding_repository.dart'; 10 + import 'package:lazurite/objectbox.g.dart'; 11 + 12 + var _storeCounter = 0; 13 + 14 + ObjectBoxStore _makeInMemoryStore() { 15 + final store = Store(getObjectBoxModel(), directory: 'memory:cache-maintenance-${_storeCounter++}'); 16 + return ObjectBoxStore.forTesting(store); 17 + } 18 + 19 + EmbeddedPost _post({required String postUri, required String accountDid}) { 20 + return EmbeddedPost( 21 + postUri: postUri, 22 + accountDid: accountDid, 23 + source: 'saved', 24 + indexedText: 'cached text', 25 + embedding: Float32List(384), 26 + embeddedAt: DateTime(2026, 1, 1), 27 + ); 28 + } 29 + 30 + void main() { 31 + late AppDatabase database; 32 + late ObjectBoxStore objectBoxStore; 33 + late EmbeddingRepository embeddingRepository; 34 + 35 + setUp(() { 36 + database = AppDatabase(executor: NativeDatabase.memory()); 37 + objectBoxStore = _makeInMemoryStore(); 38 + embeddingRepository = EmbeddingRepository(objectBoxStore); 39 + }); 40 + 41 + tearDown(() async { 42 + objectBoxStore.close(); 43 + await database.close(); 44 + }); 45 + 46 + test('clearCaches clears database caches, semantic index, and image caches', () async { 47 + var diskImageCleared = false; 48 + var memoryImageCleared = false; 49 + final service = LocalCacheMaintenanceService( 50 + database: database, 51 + objectBoxStore: objectBoxStore, 52 + clearImageDiskCache: () async { 53 + diskImageCleared = true; 54 + }, 55 + clearImageMemoryCache: () { 56 + memoryImageCleared = true; 57 + }, 58 + ); 59 + 60 + await database.cacheProfile(did: 'did:plc:user', handle: 'user.bsky.social', payload: '{}'); 61 + embeddingRepository.upsert(_post(postUri: 'at://did:plc:user/app.bsky.feed.post/1', accountDid: 'did:plc:user')); 62 + 63 + await service.clearCaches(); 64 + 65 + expect(await database.select(database.cachedProfiles).get(), isEmpty); 66 + expect(embeddingRepository.countByAccount('did:plc:user'), 0); 67 + expect(diskImageCleared, isTrue); 68 + expect(memoryImageCleared, isTrue); 69 + }); 70 + }
+59
test/core/database/app_database_test.dart
··· 262 262 expect(newest, isNotNull); 263 263 expect(oldest, isNull); 264 264 }); 265 + 266 + test('clearLocalCaches removes cache tables while preserving user data', () async { 267 + await database.insertAccount( 268 + AccountsCompanion.insert(did: 'did:plc:user', handle: 'user.bsky.social', accessToken: 'token'), 269 + ); 270 + await database.setSetting(AppDatabase.activeAccountDidSettingKey, 'did:plc:user'); 271 + await database.setSetting('theme', 'dark'); 272 + await database.setSetting('moderation_preferences::did:plc:user', '[]'); 273 + await database.cacheProfile(did: 'did:plc:user', handle: 'user.bsky.social', payload: '{}'); 274 + await database.cachePost( 275 + uri: 'at://did:plc:user/app.bsky.feed.post/1', 276 + authorDid: 'did:plc:user', 277 + payload: '{}', 278 + ); 279 + await database.cacheFeedPage(accountDid: 'did:plc:user', feedKey: 'timeline', payload: '{}'); 280 + await database.upsertCachedFeedPosts( 281 + accountDid: 'did:plc:user', 282 + feedKey: 'timeline', 283 + posts: [ 284 + CachedFeedPostsCompanion.insert( 285 + accountDid: 'did:plc:user', 286 + feedKey: 'timeline', 287 + postUri: 'at://did:plc:user/app.bsky.feed.post/1', 288 + postJson: '{}', 289 + sortOrder: 1, 290 + ), 291 + ], 292 + ); 293 + await database.cacheThreadRoot( 294 + accountDid: 'did:plc:user', 295 + rootUri: 'at://did:plc:user/app.bsky.feed.post/1', 296 + payload: '{}', 297 + ); 298 + await database.upsertLabelerCache('did:plc:labeler', '{}'); 299 + await database.saveDraft(DraftsCompanion.insert(accountDid: 'did:plc:user', content: 'draft')); 300 + await database.savePost( 301 + SavedPostsCompanion.insert( 302 + accountDid: 'did:plc:user', 303 + postUri: 'at://did:plc:user/app.bsky.feed.post/saved', 304 + postJson: '{}', 305 + ), 306 + ); 307 + 308 + await database.clearLocalCaches(); 309 + 310 + expect(await database.select(database.cachedProfiles).get(), isEmpty); 311 + expect(await database.select(database.cachedPosts).get(), isEmpty); 312 + expect(await database.select(database.cachedFeedPages).get(), isEmpty); 313 + expect(await database.select(database.cachedFeedPosts).get(), isEmpty); 314 + expect(await database.select(database.cachedThreadRoots).get(), isEmpty); 315 + expect(await database.select(database.labelerCache).get(), isEmpty); 316 + expect(await database.getSetting('moderation_preferences::did:plc:user'), isNull); 317 + 318 + expect(await database.getAccount('did:plc:user'), isNotNull); 319 + expect(await database.getSetting(AppDatabase.activeAccountDidSettingKey), 'did:plc:user'); 320 + expect(await database.getSetting('theme'), 'dark'); 321 + expect(await database.getDrafts('did:plc:user'), hasLength(1)); 322 + expect(await database.getSavedPosts('did:plc:user'), hasLength(1)); 323 + }); 265 324 }); 266 325 267 326 group('Notification delivery operations', () {
+26
test/core/router/app_router_test.dart
··· 562 562 563 563 router.dispose(); 564 564 }); 565 + 566 + testWidgets('processes compatibility oauth callback route while authenticated', (tester) async { 567 + final router = AppRouter(authBloc: authBloc).router; 568 + final pendingCallback = Completer<bool>(); 569 + when(() => authBloc.handleOAuthRedirectUri(any())).thenAnswer((_) => pendingCallback.future); 570 + 571 + await tester.pumpWidget(buildSubjectWithRouter(router)); 572 + router.go('/callback?code=abc&state=xyz'); 573 + await tester.pump(); 574 + await tester.pump(const Duration(milliseconds: 100)); 575 + 576 + verify( 577 + () => authBloc.handleOAuthRedirectUri( 578 + any(that: predicate<Uri>((uri) => uri.path == OAuthCallbackScreen.compatibilityRoutePath)), 579 + ), 580 + ).called(1); 581 + expect(router.routeInformationProvider.value.uri.path, equals(OAuthCallbackScreen.compatibilityRoutePath)); 582 + 583 + pendingCallback.complete(true); 584 + await tester.pumpAndSettle(); 585 + 586 + expect(router.routeInformationProvider.value.uri.path, isNot(equals(OAuthCallbackScreen.compatibilityRoutePath))); 587 + expect(find.text('No feeds pinned'), findsOneWidget); 588 + 589 + router.dispose(); 590 + }); 565 591 }
+15
test/features/auth/bloc/auth_bloc_test.dart
··· 77 77 ); 78 78 79 79 blocTest<AuthBloc, AuthState>( 80 + 'clears local auth data and emits [unauthenticated] when LocalAuthDataClearRequested is added', 81 + build: () => AuthBloc(authRepository: mockAuthRepository), 82 + seed: () => const AuthState.authenticated(tokens), 83 + setUp: () { 84 + when(() => mockAuthRepository.clearSession()).thenAnswer((_) async {}); 85 + }, 86 + act: (bloc) => bloc.add(const LocalAuthDataClearRequested()), 87 + expect: () => [const AuthState.unauthenticated()], 88 + verify: (_) { 89 + verify(() => mockAuthRepository.clearSession()).called(1); 90 + verifyNever(() => mockAuthRepository.logout()); 91 + }, 92 + ); 93 + 94 + blocTest<AuthBloc, AuthState>( 80 95 'emits [authenticated] when SessionRestored is added', 81 96 build: () => AuthBloc(authRepository: mockAuthRepository), 82 97 act: (bloc) => bloc.add(const SessionRestored(tokens: tokens)),
+9
test/features/auth/bloc/auth_event_test.dart
··· 45 45 }); 46 46 }); 47 47 48 + group('LocalAuthDataClearRequested', () { 49 + test('should support value equality', () { 50 + const event1 = LocalAuthDataClearRequested(); 51 + const event2 = LocalAuthDataClearRequested(); 52 + 53 + expect(event1, equals(event2)); 54 + }); 55 + }); 56 + 48 57 group('SessionRestored', () { 49 58 test('should support value equality', () { 50 59 const event1 = SessionRestored(tokens: tokens);
+55
test/features/auth/data/auth_repository_test.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:convert'; 2 3 3 4 import 'package:atproto_core/atproto_core.dart' as atcore; ··· 396 397 expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); 397 398 }); 398 399 400 + test('normalizes authority-style custom scheme callback URI to canonical custom scheme', () { 401 + final normalized = authRepository.normalizeOAuthCallbackUriForTest( 402 + Uri.parse('org.stormlightlabs.lazurite://oauth/callback?code=abc&state=xyz'), 403 + ); 404 + 405 + expect(normalized, isNotNull); 406 + expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); 407 + }); 408 + 409 + test('normalizes compatibility callback path to canonical custom scheme', () { 410 + final normalized = authRepository.normalizeOAuthCallbackUriForTest(Uri.parse('/callback?code=abc&state=xyz')); 411 + 412 + expect(normalized, isNotNull); 413 + expect(normalized!.toString(), equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); 414 + }); 415 + 416 + test('rejects path-only callback without oauth response parameters', () { 417 + final normalized = authRepository.normalizeOAuthCallbackUriForTest(Uri.parse('/callback?foo=bar')); 418 + 419 + expect(normalized, isNull); 420 + }); 421 + 399 422 test('accepts exact HTTPS callback URI with oauth query parameters', () { 400 423 final normalized = authRepository.normalizeOAuthCallbackUriForTest( 401 424 Uri.parse( ··· 425 448 ); 426 449 427 450 expect(normalized, isNull); 451 + }); 452 + }); 453 + 454 + group('oauth callback exchange coordination', () { 455 + test('joins duplicate callback deliveries to one token exchange', () async { 456 + const tokens = AuthTokens(accessToken: 'access', did: 'did:plc:abc123', handle: 'user.bsky.social'); 457 + final exchangeCompleter = Completer<AuthTokens>(); 458 + var exchangeCalls = 0; 459 + 460 + Future<AuthTokens> exchange(String callbackUrl) { 461 + exchangeCalls += 1; 462 + expect(callbackUrl, equals('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz')); 463 + return exchangeCompleter.future; 464 + } 465 + 466 + final firstResult = authRepository.runOAuthCallbackExchangeOnceForTest( 467 + Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), 468 + exchange, 469 + ); 470 + final secondResult = authRepository.runOAuthCallbackExchangeOnceForTest( 471 + Uri.parse('org.stormlightlabs.lazurite:/oauth/callback?code=abc&state=xyz'), 472 + exchange, 473 + ); 474 + 475 + await Future<void>.delayed(Duration.zero); 476 + expect(exchangeCalls, equals(1)); 477 + 478 + exchangeCompleter.complete(tokens); 479 + 480 + expect(await firstResult, equals(tokens)); 481 + expect(await secondResult, equals(tokens)); 482 + expect(exchangeCalls, equals(1)); 428 483 }); 429 484 }); 430 485
+85 -4
test/features/settings/presentation/settings_screen_test.dart
··· 5 5 import 'package:flutter_bloc/flutter_bloc.dart'; 6 6 import 'package:flutter_test/flutter_test.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 + import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; 8 9 import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; 9 10 import 'package:lazurite/core/database/app_database.dart'; 10 11 import 'package:lazurite/core/network/app_view_provider.dart'; ··· 23 24 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 24 25 25 26 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 27 + 28 + class MockLocalCacheMaintenanceService extends Mock implements LocalCacheMaintenanceService {} 26 29 27 30 class FakeCrashReportingService implements CrashReportingService { 28 31 var crashCalls = 0; ··· 52 55 late MockAccountSwitcherCubit accountSwitcherCubit; 53 56 late MockAuthBloc authBloc; 54 57 late MockSettingsCubit settingsCubit; 58 + late MockLocalCacheMaintenanceService cacheMaintenanceService; 55 59 late FakeCrashReportingService crashReportingService; 56 60 57 61 setUp(() { 58 62 accountSwitcherCubit = MockAccountSwitcherCubit(); 59 63 authBloc = MockAuthBloc(); 60 64 settingsCubit = MockSettingsCubit(); 65 + cacheMaintenanceService = MockLocalCacheMaintenanceService(); 61 66 crashReportingService = FakeCrashReportingService(); 62 67 63 68 when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); ··· 91 96 when(() => settingsCubit.refreshAppViewHealth()).thenAnswer((_) async {}); 92 97 when(() => settingsCubit.setCrashReportingEnabled(any())).thenAnswer((_) async {}); 93 98 when(() => settingsCubit.setCrashReportingConsentPrompted(any())).thenAnswer((_) async {}); 99 + when(() => cacheMaintenanceService.clearCaches()).thenAnswer((_) async {}); 94 100 }); 95 101 96 102 Widget buildSubject() { 97 - return RepositoryProvider<CrashReportingService>.value( 98 - value: crashReportingService, 103 + return MultiRepositoryProvider( 104 + providers: [ 105 + RepositoryProvider<CrashReportingService>.value(value: crashReportingService), 106 + RepositoryProvider<LocalCacheMaintenanceService>.value(value: cacheMaintenanceService), 107 + ], 99 108 child: MultiBlocProvider( 100 109 providers: [ 101 110 BlocProvider<AuthBloc>.value(value: authBloc), ··· 112 121 routes: [ 113 122 GoRoute( 114 123 path: '/', 115 - builder: (context, state) => RepositoryProvider<CrashReportingService>.value( 116 - value: crashReportingService, 124 + builder: (context, state) => MultiRepositoryProvider( 125 + providers: [ 126 + RepositoryProvider<CrashReportingService>.value(value: crashReportingService), 127 + RepositoryProvider<LocalCacheMaintenanceService>.value(value: cacheMaintenanceService), 128 + ], 117 129 child: MultiBlocProvider( 118 130 providers: [ 119 131 BlocProvider<AuthBloc>.value(value: authBloc), ··· 300 312 expect(find.text('Provider Diagnostics'), findsOneWidget); 301 313 expect(find.text('Refresh Provider Health'), findsOneWidget); 302 314 expect(find.byIcon(Icons.edit_outlined), findsNothing); 315 + }); 316 + 317 + testWidgets('troubleshooting reset sign-in data requires confirmation before clearing local auth data', ( 318 + tester, 319 + ) async { 320 + await tester.pumpWidget(buildSubject()); 321 + await tester.pumpAndSettle(); 322 + 323 + await tester.scrollUntilVisible(find.text('TROUBLESHOOTING'), 300); 324 + await tester.pumpAndSettle(); 325 + 326 + expect(find.text('TROUBLESHOOTING'), findsOneWidget); 327 + expect(find.text('Reset Sign-In Data'), findsOneWidget); 328 + expect( 329 + find.text('Troubleshoot OAuth or account-switching issues by clearing local sessions on this device'), 330 + findsOneWidget, 331 + ); 332 + 333 + await tester.tap(find.text('Reset Sign-In Data')); 334 + await tester.pumpAndSettle(); 335 + 336 + expect(find.text('Reset sign-in data?'), findsOneWidget); 337 + expect(find.textContaining('It does not delete your Bluesky account or posts.'), findsOneWidget); 338 + 339 + await tester.tap(find.text('Cancel')); 340 + await tester.pumpAndSettle(); 341 + 342 + verifyNever(() => authBloc.add(const LocalAuthDataClearRequested())); 343 + 344 + await tester.tap(find.text('Reset Sign-In Data')); 345 + await tester.pumpAndSettle(); 346 + await tester.tap(find.widgetWithText(FilledButton, 'Reset Sign-In Data')); 347 + await tester.pumpAndSettle(); 348 + 349 + verify(() => authBloc.add(const LocalAuthDataClearRequested())).called(1); 350 + }); 351 + 352 + testWidgets('troubleshooting clear cache requires confirmation and keeps auth state intact', (tester) async { 353 + await tester.pumpWidget(buildSubject()); 354 + await tester.pumpAndSettle(); 355 + 356 + await tester.scrollUntilVisible(find.text('TROUBLESHOOTING'), 300); 357 + await tester.pumpAndSettle(); 358 + 359 + expect(find.text('Clear Cache'), findsOneWidget); 360 + expect( 361 + find.text('Remove cached posts, profiles, images, feeds, threads, and semantic search data'), 362 + findsOneWidget, 363 + ); 364 + 365 + await tester.tap(find.text('Clear Cache')); 366 + await tester.pumpAndSettle(); 367 + 368 + expect(find.text('Clear cache?'), findsOneWidget); 369 + expect(find.textContaining('Accounts, settings, drafts, bookmarks, and likes are kept.'), findsOneWidget); 370 + 371 + await tester.tap(find.text('Cancel')); 372 + await tester.pumpAndSettle(); 373 + 374 + verifyNever(() => cacheMaintenanceService.clearCaches()); 375 + 376 + await tester.tap(find.text('Clear Cache')); 377 + await tester.pumpAndSettle(); 378 + await tester.tap(find.widgetWithText(FilledButton, 'Clear Cache')); 379 + await tester.pumpAndSettle(); 380 + 381 + verify(() => cacheMaintenanceService.clearCaches()).called(1); 382 + verifyNever(() => authBloc.add(const LocalAuthDataClearRequested())); 383 + expect(find.text('Cache cleared'), findsOneWidget); 303 384 }); 304 385 305 386 testWidgets('crash reporting toggle persists consent and reporting state', (tester) async {
+10 -18
www/oauth/callback/index.html
··· 20 20 <p class="meta oauth-callback-hint">If this still fails, use your browser menu and choose “Open in app”.</p> 21 21 </main> 22 22 </div> 23 - <iframe 24 - id="reopen-frame" 25 - title="Return to Lazurite" 26 - style="display: none; width: 0; height: 0; border: 0" 27 - ></iframe> 28 23 <script> 29 24 const query = window.location.search || ''; 30 25 const fragment = window.location.hash || ''; 31 26 const reopenUrl = `org.stormlightlabs.lazurite:/oauth/callback${query}${fragment}`; 32 27 const reopenLink = document.getElementById('reopen-link'); 33 - const reopenFrame = document.getElementById('reopen-frame'); 28 + const autoOpenKey = `lazurite.oauth.reopen.${query}${fragment}`; 34 29 35 30 reopenLink.setAttribute('href', reopenUrl); 36 31 37 - let attemptCount = 0; 38 32 function attemptReopen() { 39 - attemptCount += 1; 40 - if (reopenFrame) { 41 - reopenFrame.src = reopenUrl; 42 - } 43 - if (attemptCount === 1) { 44 - window.location.assign(reopenUrl); 45 - return; 33 + try { 34 + if (window.sessionStorage.getItem(autoOpenKey) === '1') { 35 + return; 36 + } 37 + window.sessionStorage.setItem(autoOpenKey, '1'); 38 + } catch (err) { 39 + console.debug("Storage unavailable; trying a single best-effort reopen.", err); 46 40 } 47 - window.location.href = reopenUrl; 41 + window.location.assign(reopenUrl); 48 42 } 49 43 50 44 window.addEventListener('load', function () { 51 - window.setTimeout(attemptReopen, 120); 52 - window.setTimeout(attemptReopen, 480); 53 - window.setTimeout(attemptReopen, 1000); 45 + window.setTimeout(attemptReopen, 250); 54 46 }); 55 47 </script> 56 48 </body>