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: ensure tokens are refreshed in the background (#7)

* feat: add refresh to app lifecycle

* build: add token refresh integration test

* feat: add trigger context for auth repo errors

* add tests to feed repository failure paths

* make convo repo tests behavioral and more exhaustive

* refactor: shared unauth helper

* feat: debug hook for 401

* fix: use existing share helper for logs

authored by

Owais and committed by
GitHub
a9f103a6 5e78d92a

+1158 -224
+252
integration_test/auth_token_recovery_flow_test.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:bloc_test/bloc_test.dart'; 5 + import 'package:bluesky/app_bsky_actor_defs.dart'; 6 + import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:drift/native.dart'; 8 + import 'package:flutter/material.dart'; 9 + import 'package:flutter_bloc/flutter_bloc.dart'; 10 + import 'package:flutter_test/flutter_test.dart'; 11 + import 'package:integration_test/integration_test.dart'; 12 + import 'package:lazurite/core/database/app_database.dart'; 13 + import 'package:lazurite/core/theme/app_theme.dart'; 14 + import 'package:lazurite/core/theme/feed_layout.dart'; 15 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 16 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 17 + import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; 18 + import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; 19 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 20 + import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 21 + import 'package:lazurite/features/feed/data/feed_repository.dart'; 22 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 23 + import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 24 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 25 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 26 + import 'package:lazurite/features/settings/bloc/settings_state.dart'; 27 + import 'package:mocktail/mocktail.dart'; 28 + 29 + class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 30 + 31 + class MockFeedPreferencesCubit extends MockCubit<FeedPreferencesState> implements FeedPreferencesCubit {} 32 + 33 + class MockConnectivityCubit extends MockCubit<ConnectivityState> implements ConnectivityCubit {} 34 + 35 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 36 + 37 + class MockSavedPostsCubit extends MockCubit<SavedPostsState> implements SavedPostsCubit {} 38 + 39 + class MockPostActionRepository extends Mock implements PostActionRepository {} 40 + 41 + class _FakeFeedData { 42 + _FakeFeedData({required this.feed, this.cursor}); 43 + 44 + final List<FeedViewPost> feed; 45 + final String? cursor; 46 + } 47 + 48 + class _FakeFeedResponse { 49 + _FakeFeedResponse(this.data); 50 + 51 + final _FakeFeedData data; 52 + } 53 + 54 + class _HandlerFeedApi { 55 + _HandlerFeedApi({required this.getTimelineHandler}); 56 + 57 + final Future<_FakeFeedResponse> Function({String? cursor, int? limit, Map<String, String>? headers}) 58 + getTimelineHandler; 59 + 60 + Future<_FakeFeedResponse> getTimeline({String? cursor, int? limit, Map<String, String>? $headers}) { 61 + return getTimelineHandler(cursor: cursor, limit: limit, headers: $headers); 62 + } 63 + } 64 + 65 + class _FakeBluesky { 66 + _FakeBluesky(this.feed); 67 + 68 + final dynamic feed; 69 + } 70 + 71 + void main() { 72 + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); 73 + 74 + const homeFeedState = FeedPreferencesState.loaded( 75 + feeds: [ 76 + SavedFeed( 77 + id: 'timeline', 78 + type: SavedFeedType.knownValue(data: KnownSavedFeedType.timeline), 79 + value: 'timeline', 80 + pinned: true, 81 + ), 82 + ], 83 + ); 84 + 85 + SettingsState settingsState(FeedLayout architecture) => SettingsState( 86 + themePalette: AppThemePalette.oxocarbon, 87 + themeVariant: AppThemeVariant.dark, 88 + useSystemTheme: false, 89 + feedLayout: architecture, 90 + ); 91 + 92 + testWidgets('home feed recovers from expired token unauthorized response', (tester) async { 93 + final database = AppDatabase(executor: NativeDatabase.memory()); 94 + addTearDown(database.close); 95 + 96 + final feedPreferencesCubit = MockFeedPreferencesCubit(); 97 + final connectivityCubit = MockConnectivityCubit(); 98 + final settingsCubit = MockSettingsCubit(); 99 + final authBloc = MockAuthBloc(); 100 + final savedPostsCubit = MockSavedPostsCubit(); 101 + final postActionRepository = MockPostActionRepository(); 102 + 103 + when(() => feedPreferencesCubit.state).thenReturn(homeFeedState); 104 + whenListen(feedPreferencesCubit, const Stream<FeedPreferencesState>.empty(), initialState: homeFeedState); 105 + 106 + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 107 + whenListen( 108 + connectivityCubit, 109 + const Stream<ConnectivityState>.empty(), 110 + initialState: const ConnectivityState.online(), 111 + ); 112 + 113 + when(() => settingsCubit.state).thenReturn(settingsState(FeedLayout.card)); 114 + whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: settingsState(FeedLayout.card)); 115 + 116 + const authState = AuthState.authenticated( 117 + AuthTokens( 118 + accessToken: 'access-token', 119 + refreshToken: 'refresh-token', 120 + did: 'did:plc:test', 121 + handle: 'test.bsky.social', 122 + ), 123 + ); 124 + when(() => authBloc.state).thenReturn(authState); 125 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: authState); 126 + 127 + const savedPostsState = SavedPostsState(status: SavedPostsStatus.loaded); 128 + when(() => savedPostsCubit.state).thenReturn(savedPostsState); 129 + whenListen(savedPostsCubit, const Stream<SavedPostsState>.empty(), initialState: savedPostsState); 130 + 131 + var primaryTimelineCalls = 0; 132 + var fallbackTimelineCalls = 0; 133 + var authRecoveryCalls = 0; 134 + 135 + final primaryFeedApi = _HandlerFeedApi( 136 + getTimelineHandler: ({String? cursor, int? limit, Map<String, String>? headers}) async { 137 + primaryTimelineCalls += 1; 138 + throw _unauthorizedException('app.bsky.feed.getTimeline'); 139 + }, 140 + ); 141 + 142 + final fallbackFeedApi = _HandlerFeedApi( 143 + getTimelineHandler: ({String? cursor, int? limit, Map<String, String>? headers}) async { 144 + fallbackTimelineCalls += 1; 145 + return _FakeFeedResponse(_FakeFeedData(feed: [_post(1)], cursor: null)); 146 + }, 147 + ); 148 + 149 + final repository = FeedRepository( 150 + bluesky: _FakeBluesky(primaryFeedApi), 151 + database: database, 152 + accountDid: 'did:plc:test', 153 + onUnauthorized: () async { 154 + authRecoveryCalls += 1; 155 + return _freshTokens(); 156 + }, 157 + blueskyClientFactory: (_) => _FakeBluesky(fallbackFeedApi), 158 + ); 159 + 160 + await tester.pumpWidget( 161 + MaterialApp( 162 + home: MultiRepositoryProvider( 163 + providers: [ 164 + RepositoryProvider<FeedRepository>.value(value: repository), 165 + RepositoryProvider<PostActionRepository>.value(value: postActionRepository), 166 + RepositoryProvider<PostActionCache>(create: (_) => PostActionCache()), 167 + ], 168 + child: MultiBlocProvider( 169 + providers: [ 170 + BlocProvider<AuthBloc>.value(value: authBloc), 171 + BlocProvider<SettingsCubit>.value(value: settingsCubit), 172 + BlocProvider<FeedPreferencesCubit>.value(value: feedPreferencesCubit), 173 + BlocProvider<ConnectivityCubit>.value(value: connectivityCubit), 174 + BlocProvider<SavedPostsCubit>.value(value: savedPostsCubit), 175 + ], 176 + child: const HomeFeedScreen(), 177 + ), 178 + ), 179 + ), 180 + ); 181 + 182 + await tester.pump(); 183 + 184 + await _pumpUntil( 185 + tester, 186 + condition: () => find.byType(PostCardWithActions).evaluate().isNotEmpty, 187 + timeout: const Duration(seconds: 5), 188 + ); 189 + 190 + expect(primaryTimelineCalls, 1); 191 + expect(authRecoveryCalls, 1); 192 + expect(fallbackTimelineCalls, 1); 193 + expect(find.byType(PostCardWithActions), findsOneWidget); 194 + expect(find.textContaining('Failed to load feed'), findsNothing); 195 + }); 196 + } 197 + 198 + Future<void> _pumpUntil( 199 + WidgetTester tester, { 200 + required bool Function() condition, 201 + Duration timeout = const Duration(seconds: 3), 202 + }) async { 203 + final deadline = DateTime.now().add(timeout); 204 + while (!condition()) { 205 + if (DateTime.now().isAfter(deadline)) { 206 + fail('Timed out waiting for condition in integration test'); 207 + } 208 + await tester.pump(const Duration(milliseconds: 50)); 209 + } 210 + } 211 + 212 + FeedViewPost _post(int index) { 213 + final timestamp = DateTime.utc(2026, 5, 4, 12).subtract(Duration(minutes: index)); 214 + final did = 'did:plc:author$index'; 215 + return FeedViewPost( 216 + post: PostView( 217 + uri: AtUri('at://$did/app.bsky.feed.post/$index'), 218 + cid: 'cid-$index', 219 + author: ProfileViewBasic(did: did, handle: 'author$index.bsky.social'), 220 + record: { 221 + r'$type': 'app.bsky.feed.post', 222 + 'text': 'Recovered post $index', 223 + 'createdAt': timestamp.toIso8601String(), 224 + }, 225 + indexedAt: timestamp, 226 + ), 227 + ); 228 + } 229 + 230 + AuthTokens _freshTokens() { 231 + final now = DateTime.now().toUtc(); 232 + return AuthTokens( 233 + accessToken: 'fresh-access-token', 234 + refreshToken: 'fresh-refresh-token', 235 + expiresAt: now.add(const Duration(hours: 1)), 236 + did: 'did:plc:test', 237 + handle: 'test.bsky.social', 238 + service: 'bsky.social', 239 + ); 240 + } 241 + 242 + UnauthorizedException _unauthorizedException(String methodId) { 243 + return UnauthorizedException( 244 + XRPCResponse( 245 + headers: const {}, 246 + status: HttpStatus.unauthorized, 247 + request: XRPCRequest(method: HttpMethod.get, url: Uri.https('bsky.social', '/xrpc/$methodId')), 248 + rateLimit: RateLimit.unlimited(), 249 + data: const XRPCError(error: 'Unauthorized', message: 'exp claim timestamp check failed'), 250 + ), 251 + ); 252 + }
+6
ios/Podfile.lock
··· 96 96 - GoogleUtilities/Privacy 97 97 - image_picker_ios (0.0.1): 98 98 - Flutter 99 + - integration_test (0.0.1): 100 + - Flutter 99 101 - nanopb (3.30910.0): 100 102 - nanopb/decode (= 3.30910.0) 101 103 - nanopb/encode (= 3.30910.0) ··· 183 185 - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) 184 186 - gal (from `.symlinks/plugins/gal/darwin`) 185 187 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) 188 + - integration_test (from `.symlinks/plugins/integration_test/ios`) 186 189 - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) 187 190 - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) 188 191 - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) ··· 233 236 :path: ".symlinks/plugins/gal/darwin" 234 237 image_picker_ios: 235 238 :path: ".symlinks/plugins/image_picker_ios/ios" 239 + integration_test: 240 + :path: ".symlinks/plugins/integration_test/ios" 236 241 objectbox_flutter_libs: 237 242 :path: ".symlinks/plugins/objectbox_flutter_libs/ios" 238 243 package_info_plus: ··· 276 281 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 277 282 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 278 283 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 284 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e 279 285 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 280 286 ObjectBox: eccb95ea2054c39d81dfa2d4ccc5f1e31187228a 281 287 objectbox_flutter_libs: ed1510f71602e4a0d3f2a721324e468d066fdbb9
+10
justfile
··· 29 29 just objectbox-check 30 30 flutter test {{ paths }} --fail-fast --timeout=120s 31 31 32 + # Run end-to-end style integration tests from integration_test/ 33 + e2e: 34 + just objectbox-check 35 + flutter test integration_test --reporter=failures-only --fail-fast --timeout=180s 36 + 37 + # Run one specific end-to-end test file 38 + e2e-file path: 39 + just objectbox-check 40 + flutter test {{ path }} --reporter=failures-only --fail-fast --timeout=180s 41 + 32 42 generate: 33 43 flutter pub run build_runner build --delete-conflicting-outputs 34 44
+58
lib/core/network/unauthorized_recovery_runner.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' as atcore show UnauthorizedException; 2 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 3 + 4 + typedef UnauthorizedRecoveryCallback = Future<AuthTokens?> Function(); 5 + typedef UnauthorizedClientFactory<TClient> = TClient? Function(AuthTokens tokens); 6 + typedef UnauthorizedRecoveryLogger = void Function(Object error, StackTrace stackTrace); 7 + 8 + /// Centralized helper for retry-on-unauthorized with token refresh. 9 + final class UnauthorizedRecoveryRunner<TClient> { 10 + UnauthorizedRecoveryRunner({ 11 + required TClient initialClient, 12 + required UnauthorizedRecoveryCallback? onUnauthorized, 13 + required UnauthorizedClientFactory<TClient> clientFactory, 14 + this.onUnauthorizedException, 15 + }) : _client = initialClient, 16 + _onUnauthorized = onUnauthorized, 17 + _clientFactory = clientFactory; 18 + 19 + TClient _client; 20 + final UnauthorizedRecoveryCallback? _onUnauthorized; 21 + final UnauthorizedClientFactory<TClient> _clientFactory; 22 + final UnauthorizedRecoveryLogger? onUnauthorizedException; 23 + 24 + TClient get client => _client; 25 + 26 + Future<T> run<T>(Future<T> Function(TClient client) request) async { 27 + try { 28 + return await request(_client); 29 + } on atcore.UnauthorizedException catch (error, stackTrace) { 30 + onUnauthorizedException?.call(error, stackTrace); 31 + final recovered = await _recoverAuthSession(); 32 + if (!recovered) { 33 + rethrow; 34 + } 35 + return request(_client); 36 + } 37 + } 38 + 39 + Future<bool> _recoverAuthSession() async { 40 + final callback = _onUnauthorized; 41 + if (callback == null) { 42 + return false; 43 + } 44 + 45 + final refreshedTokens = await callback(); 46 + if (refreshedTokens == null) { 47 + return false; 48 + } 49 + 50 + final refreshedClient = _clientFactory(refreshedTokens); 51 + if (refreshedClient == null) { 52 + return false; 53 + } 54 + 55 + _client = refreshedClient; 56 + return true; 57 + } 58 + }
+57
lib/core/network/xrpc_network_interceptor.dart
··· 14 14 } 15 15 16 16 abstract final class XrpcNetworkInterceptor { 17 + static int _forcedUnauthorizedResponses = 0; 18 + 19 + static void debugForceUnauthorizedOnce() { 20 + if (!kDebugMode) { 21 + return; 22 + } 23 + _forcedUnauthorizedResponses += 1; 24 + log.w('XRPC Debug Hook: next $_forcedUnauthorizedResponses request(s) will return 401 Unauthorized'); 25 + } 26 + 27 + @visibleForTesting 28 + static void debugResetForcedUnauthorized() { 29 + _forcedUnauthorizedResponses = 0; 30 + } 31 + 17 32 static atp_core.GetClient wrapGetClient([atp_core.GetClient? baseClient]) { 18 33 final delegate = baseClient ?? http.get; 19 34 return (Uri url, {Map<String, String>? headers}) async { 20 35 final metadata = metadataFor(url, headers: headers); 21 36 final stopwatch = Stopwatch()..start(); 22 37 log.t(_requestLogLine(httpMethod: 'GET', metadata: metadata)); 38 + final forced = _takeForcedUnauthorized(method: 'GET', url: url, metadata: metadata); 39 + if (forced != null) { 40 + _logResponse( 41 + httpMethod: 'GET', 42 + metadata: metadata, 43 + statusCode: forced.statusCode, 44 + elapsed: stopwatch.elapsed, 45 + ); 46 + return forced; 47 + } 23 48 try { 24 49 final response = await delegate(url, headers: headers); 25 50 _logResponse( ··· 46 71 final metadata = metadataFor(url, headers: headers); 47 72 final stopwatch = Stopwatch()..start(); 48 73 log.t(_requestLogLine(httpMethod: 'POST', metadata: metadata)); 74 + final forced = _takeForcedUnauthorized(method: 'POST', url: url, metadata: metadata); 75 + if (forced != null) { 76 + _logResponse( 77 + httpMethod: 'POST', 78 + metadata: metadata, 79 + statusCode: forced.statusCode, 80 + elapsed: stopwatch.elapsed, 81 + ); 82 + return forced; 83 + } 49 84 try { 50 85 final response = await delegate(url, headers: headers, body: body, encoding: encoding); 51 86 _logResponse( ··· 94 129 } 95 130 } 96 131 return null; 132 + } 133 + 134 + static http.Response? _takeForcedUnauthorized({ 135 + required String method, 136 + required Uri url, 137 + required XrpcRequestMetadata metadata, 138 + }) { 139 + if (!kDebugMode || _forcedUnauthorizedResponses < 1) { 140 + return null; 141 + } 142 + 143 + _forcedUnauthorizedResponses -= 1; 144 + log.w( 145 + 'XRPC Debug Hook: forcing 401 for method=$method, PDS=${metadata.pdsHost}, ' 146 + 'AppView=${metadata.appView}, XRPC method=${metadata.xrpcMethod}', 147 + ); 148 + return http.Response( 149 + '{"error":"Unauthorized","message":"Forced debug Unauthorized response"}', 150 + 401, 151 + headers: const {'content-type': 'application/json'}, 152 + request: http.Request(method, url), 153 + ); 97 154 } 98 155 99 156 static String _requestLogLine({required String httpMethod, required XrpcRequestMetadata metadata}) {
+67 -61
lib/features/feed/data/feed_repository.dart
··· 1 1 import 'dart:convert'; 2 2 3 - import 'package:atproto_core/atproto_core.dart' show AtUri; 3 + import 'package:atproto_core/atproto_core.dart' as atcore show AtUri; 4 4 import 'package:bluesky/app_bsky_actor_defs.dart'; 5 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 6 6 import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; ··· 11 11 import 'package:lazurite/core/logging/app_logger.dart'; 12 12 import 'package:lazurite/core/network/app_view_fallback_service.dart'; 13 13 import 'package:lazurite/core/network/app_view_request_context.dart'; 14 + import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; 15 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 16 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 14 17 import 'package:lazurite/features/feed/data/trending_join.dart'; 15 18 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 16 19 ··· 27 30 AppViewFallbackService? appViewFallbackService, 28 31 int routingEpoch = 0, 29 32 int Function()? routingEpochResolver, 30 - }) : _bluesky = bluesky, 31 - _database = database, 33 + Future<AuthTokens?> Function()? onUnauthorized, 34 + dynamic Function(AuthTokens tokens)? blueskyClientFactory, 35 + }) : _database = database, 32 36 _accountDid = accountDid, 33 37 _moderationService = moderationService, 34 38 _appViewContext = AppViewRequestContext( ··· 39 43 _crossProviderFallbackEnabledResolver = crossProviderFallbackEnabledResolver, 40 44 _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(), 41 45 _routingEpoch = routingEpoch, 42 - _routingEpochResolver = routingEpochResolver; 46 + _routingEpochResolver = routingEpochResolver { 47 + _authRecovery = UnauthorizedRecoveryRunner<dynamic>( 48 + initialClient: bluesky, 49 + onUnauthorized: onUnauthorized, 50 + clientFactory: blueskyClientFactory ?? createBlueskyClient, 51 + onUnauthorizedException: (error, stackTrace) { 52 + log.w('feed.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); 53 + }, 54 + ); 55 + } 43 56 44 - final dynamic _bluesky; 57 + late final UnauthorizedRecoveryRunner<dynamic> _authRecovery; 45 58 final AppDatabase _database; 46 59 final String _accountDid; 47 60 final ModerationService? _moderationService; ··· 77 90 await _moderationService?.headersForRequest(), 78 91 ); 79 92 80 - final response = await _bluesky.feed.getAuthorFeed( 81 - actor: actor, 82 - cursor: cursor, 83 - limit: limit, 84 - filter: bskyFilter, 85 - $headers: headers, 93 + final response = await _authRecovery.run( 94 + (client) => 95 + client.feed.getAuthorFeed(actor: actor, cursor: cursor, limit: limit, filter: bskyFilter, $headers: headers), 86 96 ); 87 97 88 98 return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); 89 99 } 90 100 91 101 Future<FeedResult> getTimeline({String? cursor, int limit = 50}) async { 92 - final response = await _bluesky.feed.getTimeline( 93 - cursor: cursor, 94 - limit: limit, 95 - $headers: _appViewContext.appBskyHeadersForEndpoint( 96 - 'app.bsky.feed.getTimeline', 97 - await _moderationService?.headersForRequest(), 98 - ), 102 + final headers = _appViewContext.appBskyHeadersForEndpoint( 103 + 'app.bsky.feed.getTimeline', 104 + await _moderationService?.headersForRequest(), 105 + ); 106 + final response = await _authRecovery.run( 107 + (client) => client.feed.getTimeline(cursor: cursor, limit: limit, $headers: headers), 99 108 ); 100 109 101 110 final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); ··· 103 112 return result; 104 113 } 105 114 106 - Future<FeedResult> getFeed({required AtUri feedUri, String? cursor, int limit = 50}) async { 107 - final response = await _bluesky.feed.getFeed( 108 - feed: feedUri, 109 - cursor: cursor, 110 - limit: limit, 111 - $headers: _appViewContext.appBskyHeadersForEndpoint( 112 - 'app.bsky.feed.getFeed', 113 - await _moderationService?.headersForRequest(), 114 - ), 115 + Future<FeedResult> getFeed({required atcore.AtUri feedUri, String? cursor, int limit = 50}) async { 116 + final headers = _appViewContext.appBskyHeadersForEndpoint( 117 + 'app.bsky.feed.getFeed', 118 + await _moderationService?.headersForRequest(), 119 + ); 120 + final response = await _authRecovery.run( 121 + (client) => client.feed.getFeed(feed: feedUri, cursor: cursor, limit: limit, $headers: headers), 115 122 ); 116 123 117 124 final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); ··· 161 168 162 169 Future<PreferencesResult> getPreferences() async { 163 170 final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); 164 - final response = await _bluesky.actor.getPreferences($headers: headers); 171 + final response = await _authRecovery.run((client) => client.actor.getPreferences($headers: headers)); 165 172 return PreferencesResult(preferences: response.data.preferences); 166 173 } 167 174 168 175 Future<void> putPreferences({required List<UPreferences> preferences}) async { 169 176 final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); 170 - await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); 177 + await _authRecovery.run((client) => client.actor.putPreferences(preferences: preferences, $headers: headers)); 171 178 } 172 179 173 180 Future<List<GeneratorView>> getSuggestedFeeds({String? cursor, int limit = 50}) async { 174 - final response = await _bluesky.feed.getSuggestedFeeds( 175 - cursor: cursor, 176 - limit: limit, 177 - $headers: _appViewContext.appBskyHeadersForEndpoint( 178 - 'app.bsky.feed.getSuggestedFeeds', 179 - await _moderationService?.headersForRequest(), 180 - ), 181 + final headers = _appViewContext.appBskyHeadersForEndpoint( 182 + 'app.bsky.feed.getSuggestedFeeds', 183 + await _moderationService?.headersForRequest(), 184 + ); 185 + final response = await _authRecovery.run( 186 + (client) => client.feed.getSuggestedFeeds(cursor: cursor, limit: limit, $headers: headers), 181 187 ); 182 188 return response.data.feeds; 183 189 } 184 190 185 - Future<AtUri> resolveFeedGeneratorUri({required String actor, required String rkey}) async { 191 + Future<atcore.AtUri> resolveFeedGeneratorUri({required String actor, required String rkey}) async { 186 192 final normalizedActor = actor.trim(); 187 193 final normalizedRkey = rkey.trim(); 188 194 if (normalizedActor.isEmpty || normalizedRkey.isEmpty) { ··· 190 196 } 191 197 192 198 if (normalizedActor.startsWith('did:')) { 193 - return AtUri.parse('at://$normalizedActor/app.bsky.feed.generator/$normalizedRkey'); 199 + return atcore.AtUri.parse('at://$normalizedActor/app.bsky.feed.generator/$normalizedRkey'); 194 200 } 195 201 196 - final response = await _bluesky.actor.getProfile( 197 - actor: normalizedActor, 198 - $headers: _appViewContext.appBskyHeadersForEndpoint( 199 - 'app.bsky.actor.getProfile', 200 - await _moderationService?.headersForRequest(), 201 - ), 202 + final headers = _appViewContext.appBskyHeadersForEndpoint( 203 + 'app.bsky.actor.getProfile', 204 + await _moderationService?.headersForRequest(), 205 + ); 206 + final response = await _authRecovery.run( 207 + (client) => client.actor.getProfile(actor: normalizedActor, $headers: headers), 202 208 ); 203 209 final did = response.data.did.trim(); 204 210 if (did.isEmpty) { 205 211 throw StateError('Resolved profile did was empty for actor=$normalizedActor'); 206 212 } 207 - return AtUri.parse('at://$did/app.bsky.feed.generator/$normalizedRkey'); 213 + return atcore.AtUri.parse('at://$did/app.bsky.feed.generator/$normalizedRkey'); 208 214 } 209 215 210 216 Future<TrendingScreenData> getTrendingScreenData({int limit = 10}) async { ··· 236 242 return _runPublicReadWithFallback( 237 243 endpointId: 'app.bsky.unspecced.getTrendingTopics', 238 244 request: (context, headers, {required fallbackUsed}) async { 239 - final response = await _bluesky.unspecced.getTrendingTopics( 245 + final response = await _authRecovery.client.unspecced.getTrendingTopics( 240 246 limit: clampedLimit, 241 247 $service: context.publicServiceHost(), 242 248 $headers: headers, ··· 251 257 return _runPublicReadWithFallback( 252 258 endpointId: 'app.bsky.unspecced.getTrends', 253 259 request: (context, headers, {required fallbackUsed}) async { 254 - final response = await _bluesky.unspecced.getTrends( 260 + final response = await _authRecovery.client.unspecced.getTrends( 255 261 limit: clampedLimit, 256 262 $service: context.publicServiceHost(), 257 263 $headers: headers, ··· 304 310 ); 305 311 } 306 312 307 - Future<GeneratorView> getFeedGenerator(AtUri feedUri) async { 308 - final response = await _bluesky.feed.getFeedGenerator( 309 - feed: feedUri, 310 - $headers: _appViewContext.appBskyHeadersForEndpoint( 311 - 'app.bsky.feed.getFeedGenerator', 312 - await _moderationService?.headersForRequest(), 313 - ), 313 + Future<GeneratorView> getFeedGenerator(atcore.AtUri feedUri) async { 314 + final headers = _appViewContext.appBskyHeadersForEndpoint( 315 + 'app.bsky.feed.getFeedGenerator', 316 + await _moderationService?.headersForRequest(), 317 + ); 318 + final response = await _authRecovery.run( 319 + (client) => client.feed.getFeedGenerator(feed: feedUri, $headers: headers), 314 320 ); 315 321 return response.data.view; 316 322 } 317 323 318 - Future<List<GeneratorView>> getFeedGenerators(List<AtUri> feedUris) async { 324 + Future<List<GeneratorView>> getFeedGenerators(List<atcore.AtUri> feedUris) async { 319 325 if (feedUris.isEmpty) return []; 320 - final response = await _bluesky.feed.getFeedGenerators( 321 - feeds: feedUris, 322 - $headers: _appViewContext.appBskyHeadersForEndpoint( 323 - 'app.bsky.feed.getFeedGenerators', 324 - await _moderationService?.headersForRequest(), 325 - ), 326 + final headers = _appViewContext.appBskyHeadersForEndpoint( 327 + 'app.bsky.feed.getFeedGenerators', 328 + await _moderationService?.headersForRequest(), 329 + ); 330 + final response = await _authRecovery.run( 331 + (client) => client.feed.getFeedGenerators(feeds: feedUris, $headers: headers), 326 332 ); 327 333 return response.data.feeds; 328 334 }
+38 -16
lib/features/feed/data/post_thread_repository.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto_core/atproto_core.dart' as atcore; 2 4 import 'package:bluesky/app_bsky_feed_defs.dart'; 3 5 import 'package:bluesky/app_bsky_feed_getpostthread.dart'; 4 6 import 'package:lazurite/core/cache/offline_cache_policy.dart'; 5 7 import 'package:lazurite/core/database/app_database.dart'; 6 8 import 'package:lazurite/core/logging/app_logger.dart'; 7 9 import 'package:lazurite/core/network/app_view_request_context.dart'; 10 + import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; 11 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 12 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 8 13 import 'package:lazurite/features/moderation/data/moderation_service.dart'; 9 - import 'dart:convert'; 10 14 11 15 class PostThreadRepository { 12 16 PostThreadRepository({ ··· 16 20 ModerationService? moderationService, 17 21 String? appViewProvider, 18 22 String Function()? appViewProviderResolver, 19 - }) : _bluesky = bluesky, 20 - _database = database, 23 + Future<AuthTokens?> Function()? onUnauthorized, 24 + dynamic Function(AuthTokens tokens)? blueskyClientFactory, 25 + }) : _database = database, 21 26 _accountDid = accountDid, 22 27 _moderationService = moderationService, 23 28 _appViewContext = AppViewRequestContext( 24 29 appViewProvider: appViewProvider, 25 30 appViewProviderResolver: appViewProviderResolver, 26 - ); 31 + ) { 32 + _authRecovery = UnauthorizedRecoveryRunner<dynamic>( 33 + initialClient: bluesky, 34 + onUnauthorized: onUnauthorized, 35 + clientFactory: blueskyClientFactory ?? createBlueskyClient, 36 + onUnauthorizedException: (error, stackTrace) { 37 + log.w('thread.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); 38 + }, 39 + ); 40 + } 27 41 28 - final dynamic _bluesky; 42 + late final UnauthorizedRecoveryRunner<dynamic> _authRecovery; 29 43 final AppDatabase _database; 30 44 final String _accountDid; 31 45 final ModerationService? _moderationService; ··· 33 47 34 48 Future<ThreadViewPost> getPostThread(String uri) async { 35 49 try { 36 - final response = await _bluesky.feed.getPostThread( 37 - uri: AtUri.parse(uri), 38 - $headers: _appViewContext.appBskyHeadersForEndpoint( 39 - 'app.bsky.feed.getPostThread', 40 - await _moderationService?.headersForRequest(), 41 - ), 50 + final headers = _appViewContext.appBskyHeadersForEndpoint( 51 + 'app.bsky.feed.getPostThread', 52 + await _moderationService?.headersForRequest(), 53 + ); 54 + final response = await _authRecovery.run( 55 + (client) => client.feed.getPostThread(uri: atcore.AtUri.parse(uri), $headers: headers), 42 56 ); 43 57 final thread = response.data.thread as UFeedGetPostThreadThread; 44 58 ··· 83 97 if (direct != null) { 84 98 try { 85 99 return ThreadViewPost.fromJson(jsonDecode(direct.payload) as Map<String, dynamic>); 86 - } catch (_) { 87 - // Ignore malformed cache rows and continue scanning. 100 + } catch (error, stackTrace) { 101 + log.d( 102 + 'thread.cache failed to decode direct snapshot for requestedUri=$requestedUri', 103 + error: error, 104 + stackTrace: stackTrace, 105 + ); 88 106 } 89 107 } 90 108 ··· 97 115 if (_containsPostUri(decoded, requestedUri)) { 98 116 return decoded; 99 117 } 100 - } catch (_) { 101 - // Ignore malformed cache rows and keep scanning valid snapshots. 118 + } catch (error, stackTrace) { 119 + log.d( 120 + 'thread.cache failed to decode snapshot while scanning rootUri=${candidate.rootUri} for requestedUri=$requestedUri', 121 + error: error, 122 + stackTrace: stackTrace, 123 + ); 102 124 } 103 125 } 104 126 return null;
+30 -12
lib/features/logs/presentation/logs_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_bloc/flutter_bloc.dart'; 3 3 import 'package:logger/logger.dart'; 4 + import 'package:lazurite/core/logging/app_logger.dart'; 4 5 import 'package:lazurite/features/logs/cubit/log_viewer_cubit.dart'; 5 6 import 'package:lazurite/features/logs/data/log_entry.dart'; 7 + import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 6 8 import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; 7 9 import 'package:lazurite/shared/presentation/widgets/error_state.dart'; 8 10 import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; 9 - import 'package:share_plus/share_plus.dart'; 10 11 import 'package:lazurite/core/theme/theme_extensions.dart'; 11 12 12 13 class LogsScreen extends StatelessWidget { ··· 84 85 appBar: AppBar( 85 86 title: const Text('Logs'), 86 87 actions: [ 87 - IconButton( 88 - icon: const Icon(Icons.share_outlined), 89 - tooltip: 'Share log file', 90 - onPressed: () => _shareLogs(context), 91 - ), 88 + IconButton(icon: const Icon(Icons.share_outlined), tooltip: 'Share log file', onPressed: _shareLogs), 92 89 IconButton( 93 90 icon: Icon(Icons.delete_outline, color: context.colorScheme.error), 94 91 tooltip: 'Clear all logs', ··· 114 111 ); 115 112 } 116 113 117 - Future<void> _shareLogs(BuildContext context) async { 114 + Future<void> _shareLogs() async { 118 115 final cubit = context.read<LogViewerCubit>(); 116 + final messenger = ScaffoldMessenger.of(context); 117 + final shareOrigin = ShareHelper.sharePositionOriginForContext(context); 118 + 119 119 final file = await cubit.getTodaysLogFile(); 120 - if (file != null && await file.exists()) { 121 - await Share.shareXFiles([XFile(file.path)], subject: 'Lazurite logs'); 122 - } else { 123 - if (context.mounted) { 124 - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No log file available'))); 120 + if (!mounted) { 121 + return; 122 + } 123 + 124 + if (file == null || !await file.exists()) { 125 + if (!mounted) { 126 + return; 127 + } 128 + messenger.showSnackBar(const SnackBar(content: Text('No log file available'))); 129 + return; 130 + } 131 + 132 + if (!mounted) { 133 + return; 134 + } 135 + 136 + try { 137 + await ShareHelper.shareFilePathsAtOrigin(shareOrigin, [file.path], subject: 'Lazurite logs'); 138 + } catch (error, stackTrace) { 139 + log.e('LogsScreen: Failed to open share sheet for log file', error: error, stackTrace: stackTrace); 140 + if (!mounted) { 141 + return; 125 142 } 143 + messenger.showSnackBar(const SnackBar(content: Text('Unable to open share sheet. Please try again.'))); 126 144 } 127 145 } 128 146
+31 -12
lib/features/messages/data/convo_repository.dart
··· 1 1 import 'package:bluesky/bluesky_chat.dart'; 2 2 import 'package:bluesky/chat_bsky_convo_defs.dart'; 3 3 import 'package:bluesky/chat_bsky_convo_getmessages.dart'; 4 + import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; 5 + import 'package:lazurite/core/network/xrpc_client_factory.dart'; 6 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 4 7 5 8 class ConvoRepository { 6 - ConvoRepository({required BlueskyChat chat}) : _chat = chat; 9 + ConvoRepository({ 10 + required BlueskyChat chat, 11 + Future<AuthTokens?> Function()? onUnauthorized, 12 + BlueskyChat? Function(AuthTokens tokens)? chatClientFactory, 13 + }) { 14 + _authRecovery = UnauthorizedRecoveryRunner<BlueskyChat>( 15 + initialClient: chat, 16 + onUnauthorized: onUnauthorized, 17 + clientFactory: chatClientFactory ?? createBlueSkyChatClient, 18 + ); 19 + } 7 20 8 - final BlueskyChat _chat; 21 + late final UnauthorizedRecoveryRunner<BlueskyChat> _authRecovery; 9 22 10 23 Future<ConvoListResult> listConvos({String? cursor, int limit = 20}) async { 11 - final response = await _chat.convo.listConvos(cursor: cursor, limit: limit); 24 + final response = await _authRecovery.run((client) => client.convo.listConvos(cursor: cursor, limit: limit)); 12 25 return ConvoListResult(convos: response.data.convos, cursor: response.data.cursor); 13 26 } 14 27 15 28 Future<ConvoView> getConvoForMembers(List<String> dids) async { 16 - final response = await _chat.convo.getConvoForMembers(members: dids); 29 + final response = await _authRecovery.run((client) => client.convo.getConvoForMembers(members: dids)); 17 30 return response.data.convo; 18 31 } 19 32 20 33 Future<MessageListResult> getMessages(String convoId, {String? cursor, int limit = 50}) async { 21 - final response = await _chat.convo.getMessages(convoId: convoId, cursor: cursor, limit: limit); 34 + final response = await _authRecovery.run( 35 + (client) => client.convo.getMessages(convoId: convoId, cursor: cursor, limit: limit), 36 + ); 22 37 return MessageListResult(messages: response.data.messages, cursor: response.data.cursor); 23 38 } 24 39 25 40 Future<MessageView> sendMessage(String convoId, String text) async { 26 - final response = await _chat.convo.sendMessage( 27 - convoId: convoId, 28 - message: MessageInput(text: text), 41 + final response = await _authRecovery.run( 42 + (client) => client.convo.sendMessage( 43 + convoId: convoId, 44 + message: MessageInput(text: text), 45 + ), 29 46 ); 30 47 return response.data; 31 48 } 32 49 33 50 Future<DeletedMessageView> deleteMessageForSelf(String convoId, String messageId) async { 34 - final response = await _chat.convo.deleteMessageForSelf(convoId: convoId, messageId: messageId); 51 + final response = await _authRecovery.run( 52 + (client) => client.convo.deleteMessageForSelf(convoId: convoId, messageId: messageId), 53 + ); 35 54 return response.data; 36 55 } 37 56 38 57 Future<ConvoView> muteConvo(String convoId) async { 39 - final response = await _chat.convo.muteConvo(convoId: convoId); 58 + final response = await _authRecovery.run((client) => client.convo.muteConvo(convoId: convoId)); 40 59 return response.data.convo; 41 60 } 42 61 43 62 Future<ConvoView> unmuteConvo(String convoId) async { 44 - final response = await _chat.convo.unmuteConvo(convoId: convoId); 63 + final response = await _authRecovery.run((client) => client.convo.unmuteConvo(convoId: convoId)); 45 64 return response.data.convo; 46 65 } 47 66 48 67 Future<void> updateRead(String convoId) async { 49 - await _chat.convo.updateRead(convoId: convoId); 68 + await _authRecovery.run((client) => client.convo.updateRead(convoId: convoId)); 50 69 } 51 70 } 52 71
+66 -56
lib/features/settings/presentation/settings_screen.dart
··· 7 7 import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; 8 8 import 'package:lazurite/core/network/app_view_provider.dart'; 9 9 import 'package:lazurite/core/network/atproto_host_resolver.dart'; 10 + import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 10 11 import 'package:lazurite/core/router/app_shell.dart'; 11 12 import 'package:lazurite/core/theme/app_theme.dart'; 12 13 import 'package:lazurite/core/theme/feed_layout.dart'; ··· 167 168 ); 168 169 } 169 170 170 - Widget _buildSectionHeader(BuildContext context, String title) { 171 - return Padding( 172 - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), 173 - child: Text( 174 - title.toUpperCase(), 175 - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), 176 - ), 177 - ); 178 - } 171 + Widget _buildSectionHeader(BuildContext context, String title) => Padding( 172 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), 173 + child: Text( 174 + title.toUpperCase(), 175 + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), 176 + ), 177 + ); 179 178 180 179 Widget _title(BuildContext context) => Text('Settings', style: context.textTheme.titleLarge); 181 180 ··· 306 305 ); 307 306 } 308 307 309 - Widget _buildSearchSettings(BuildContext context) { 310 - return BlocBuilder<SettingsCubit, SettingsState>( 311 - builder: (context, settingsState) { 312 - final theme = Theme.of(context); 313 - return Container( 314 - decoration: BoxDecoration( 315 - border: Border( 316 - top: BorderSide(color: theme.dividerColor), 317 - bottom: BorderSide(color: theme.dividerColor), 318 - ), 319 - color: theme.cardColor, 308 + Widget _buildSearchSettings(BuildContext context) => BlocBuilder<SettingsCubit, SettingsState>( 309 + builder: (context, settingsState) { 310 + final theme = Theme.of(context); 311 + return Container( 312 + decoration: BoxDecoration( 313 + border: Border( 314 + top: BorderSide(color: theme.dividerColor), 315 + bottom: BorderSide(color: theme.dividerColor), 320 316 ), 321 - child: Column( 322 - children: [ 323 - ListTile( 324 - leading: const Icon(Icons.tune_outlined), 325 - title: const Text('Typeahead Provider'), 326 - subtitle: Text( 327 - settingsState.typeaheadProvider == 'community' 328 - ? 'Community (waow.tech) selected. Third-party service, works before login.' 329 - : 'Bluesky official endpoint selected.', 330 - ), 317 + color: theme.cardColor, 318 + ), 319 + child: Column( 320 + children: [ 321 + ListTile( 322 + leading: const Icon(Icons.tune_outlined), 323 + title: const Text('Typeahead Provider'), 324 + subtitle: Text( 325 + settingsState.typeaheadProvider == 'community' 326 + ? 'Community (waow.tech) selected. Third-party service, works before login.' 327 + : 'Bluesky official endpoint selected.', 331 328 ), 332 - Padding( 333 - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), 334 - child: Align( 335 - alignment: Alignment.centerLeft, 336 - child: SegmentedButton<String>( 337 - segments: const [ 338 - ButtonSegment<String>(value: 'bluesky', label: Text('Bluesky')), 339 - ButtonSegment<String>(value: 'community', label: Text('Community')), 340 - ], 341 - selected: {settingsState.typeaheadProvider}, 342 - onSelectionChanged: (selection) { 343 - context.read<SettingsCubit>().setTypeaheadProvider(selection.first); 344 - }, 345 - ), 329 + ), 330 + Padding( 331 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), 332 + child: Align( 333 + alignment: Alignment.centerLeft, 334 + child: SegmentedButton<String>( 335 + segments: const [ 336 + ButtonSegment<String>(value: 'bluesky', label: Text('Bluesky')), 337 + ButtonSegment<String>(value: 'community', label: Text('Community')), 338 + ], 339 + selected: {settingsState.typeaheadProvider}, 340 + onSelectionChanged: (selection) { 341 + context.read<SettingsCubit>().setTypeaheadProvider(selection.first); 342 + }, 346 343 ), 347 344 ), 348 - const Divider(height: 1), 349 - const _SettingsTile( 350 - icon: Icons.manage_search_outlined, 351 - title: 'Semantic Search', 352 - subtitle: 'Manage semantic search from Bookmarks & Likes -> Search', 353 - ), 354 - ], 355 - ), 356 - ); 357 - }, 358 - ); 359 - } 345 + ), 346 + const Divider(height: 1), 347 + const _SettingsTile( 348 + icon: Icons.manage_search_outlined, 349 + title: 'Semantic Search', 350 + subtitle: 'Manage semantic search from Bookmarks & Likes -> Search', 351 + ), 352 + ], 353 + ), 354 + ); 355 + }, 356 + ); 360 357 361 358 Widget _buildDeveloperSettings(BuildContext context) { 362 359 final settingsCubit = context.read<SettingsCubit>(); ··· 389 386 trailing: const Icon(Icons.warning_amber_rounded), 390 387 onTap: crashReportingService?.crash, 391 388 ), 389 + if (kDebugMode) ...[ 390 + const Divider(height: 1), 391 + _SettingsTile( 392 + icon: Icons.lock_reset_outlined, 393 + title: 'Force Next XRPC 401', 394 + subtitle: 'Debug-only: next network request returns Unauthorized to test token refresh', 395 + trailing: const Icon(Icons.play_arrow_outlined), 396 + onTap: () { 397 + XrpcNetworkInterceptor.debugForceUnauthorizedOnce(); 398 + showAppSnackBar(context, 'Armed: next XRPC request will return debug 401 Unauthorized'); 399 + }, 400 + ), 401 + ], 392 402 ], 393 403 ), 394 404 );
+88 -2
lib/main.dart
··· 163 163 runApp( 164 164 LazuriteApp.from( 165 165 authBloc, 166 + authRepository, 166 167 database, 167 168 appViewFallbackService, 168 169 objectBoxStore, ··· 187 188 const LazuriteApp({ 188 189 super.key, 189 190 required this.authBloc, 191 + required this.authRepository, 190 192 required this.database, 191 193 required this.appViewFallbackService, 192 194 required this.objectBoxStore, ··· 201 203 }); 202 204 203 205 final AuthBloc authBloc; 206 + final AuthRepository authRepository; 204 207 final AppDatabase database; 205 208 final AppViewFallbackService appViewFallbackService; 206 209 final ObjectBoxStore objectBoxStore; ··· 216 219 /// factory constructor with positional params 217 220 static LazuriteApp from( 218 221 AuthBloc authBloc, 222 + AuthRepository authRepository, 219 223 AppDatabase database, 220 224 AppViewFallbackService appViewFallbackService, 221 225 ObjectBoxStore objectBoxStore, ··· 229 233 bool firebaseAvailable, 230 234 ) => LazuriteApp( 231 235 authBloc: authBloc, 236 + authRepository: authRepository, 232 237 database: database, 233 238 appViewFallbackService: appViewFallbackService, 234 239 objectBoxStore: objectBoxStore, ··· 246 251 State<LazuriteApp> createState() => _LazuriteAppState(); 247 252 } 248 253 249 - class _LazuriteAppState extends State<LazuriteApp> { 254 + class _LazuriteAppState extends State<LazuriteApp> with WidgetsBindingObserver { 250 255 static final _navigatorObserver = LoggingNavigatorObserver(); 251 256 late GoRouter _router; 252 257 late String _routerSessionKey; ··· 259 264 late String _observedAppViewProvider; 260 265 var _routerGeneration = 0; 261 266 var _isSoftRestarting = false; 267 + Completer<AuthTokens?>? _authRecoveryCompleter; 262 268 263 269 @override 264 270 void initState() { 265 271 super.initState(); 272 + WidgetsBinding.instance.addObserver(this); 266 273 _routerSessionKey = _sessionKeyFor(widget.authBloc.state); 267 274 _observedAppViewProvider = widget.settingsCubit.state.appViewProvider; 268 275 _router = _createRouter(); ··· 307 314 308 315 @override 309 316 void dispose() { 317 + WidgetsBinding.instance.removeObserver(this); 310 318 _authSubscription.cancel(); 311 319 _pushRegistrationSubscription.cancel(); 312 320 _pushForegroundMessageSubscription?.cancel(); ··· 323 331 super.dispose(); 324 332 } 325 333 334 + @override 335 + void didChangeAppLifecycleState(AppLifecycleState state) { 336 + super.didChangeAppLifecycleState(state); 337 + if (state == AppLifecycleState.resumed) { 338 + unawaited(_refreshExpiredSessionOnResume()); 339 + } 340 + } 341 + 342 + Future<void> _refreshExpiredSessionOnResume() async { 343 + final authState = widget.authBloc.state; 344 + final tokens = authState.tokens; 345 + if (!authState.isAuthenticated || tokens == null || !tokens.isExpired) { 346 + return; 347 + } 348 + await _recoverAuthSession(trigger: 'app_resumed'); 349 + } 350 + 351 + Future<AuthTokens?> _recoverAuthSession({required String trigger}) async { 352 + final inFlight = _authRecoveryCompleter; 353 + if (inFlight != null) { 354 + return inFlight.future; 355 + } 356 + 357 + final completer = Completer<AuthTokens?>(); 358 + _authRecoveryCompleter = completer; 359 + String? refreshingDid; 360 + try { 361 + final authState = widget.authBloc.state; 362 + final tokens = authState.tokens; 363 + if (!authState.isAuthenticated || tokens == null || tokens.refreshToken == null) { 364 + completer.complete(null); 365 + return null; 366 + } 367 + refreshingDid = tokens.did; 368 + 369 + final refreshed = await widget.authRepository.refreshSession(tokens); 370 + if (!_canPublishRecoveryForDid(refreshingDid)) { 371 + completer.complete(null); 372 + return null; 373 + } 374 + 375 + if (refreshed == null || refreshed.did != refreshingDid) { 376 + completer.complete(null); 377 + return null; 378 + } 379 + widget.authBloc.add(SessionRestored(tokens: refreshed)); 380 + completer.complete(refreshed); 381 + return refreshed; 382 + } catch (error, stackTrace) { 383 + log.w('Auth recovery failed (trigger=$trigger)', error: error, stackTrace: stackTrace); 384 + if (_canPublishRecoveryForDid(refreshingDid)) { 385 + widget.authBloc.add(const CheckSessionRequested()); 386 + } 387 + completer.complete(null); 388 + return null; 389 + } finally { 390 + if (identical(_authRecoveryCompleter, completer)) { 391 + _authRecoveryCompleter = null; 392 + } 393 + } 394 + } 395 + 396 + bool _canPublishRecoveryForDid(String? refreshingDid) { 397 + if (!mounted || refreshingDid == null) { 398 + return false; 399 + } 400 + 401 + final state = widget.authBloc.state; 402 + return state.isAuthenticated && state.tokens?.did == refreshingDid; 403 + } 404 + 326 405 GoRouter _createRouter() { 327 406 return AppRouter(authBloc: widget.authBloc, navigatorObserver: _navigatorObserver).router; 328 407 } ··· 491 570 appViewFallbackService: widget.appViewFallbackService, 492 571 routingEpoch: context.read<SettingsCubit>().state.routingEpoch, 493 572 routingEpochResolver: () => context.read<SettingsCubit>().state.routingEpoch, 573 + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), 494 574 ), 495 575 ), 496 576 RepositoryProvider( ··· 559 639 accountDid: accountDid, 560 640 moderationService: context.read<ModerationService>(), 561 641 appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 642 + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), 562 643 ), 563 644 ), 564 645 RepositoryProvider( ··· 580 661 appViewProviderResolver: () => context.read<SettingsCubit>().state.appViewProvider, 581 662 ), 582 663 ), 583 - RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), 664 + RepositoryProvider( 665 + create: (_) => ConvoRepository( 666 + chat: blueskyChat, 667 + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), 668 + ), 669 + ), 584 670 RepositoryProvider(create: (_) => PostActionCache()), 585 671 RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), 586 672 RepositoryProvider.value(value: bluesky),
+25 -1
lib/shared/presentation/helpers/share_helper.dart
··· 5 5 const ShareHelper._(); 6 6 7 7 static Future<void> shareText(BuildContext context, String text) { 8 - return Share.share(text, sharePositionOrigin: _sharePositionOrigin(context)); 8 + return Share.share(text, sharePositionOrigin: sharePositionOriginForContext(context)); 9 + } 10 + 11 + static Future<void> shareFiles(BuildContext context, List<XFile> files, {String? text, String? subject}) { 12 + return shareFilesAtOrigin(sharePositionOriginForContext(context), files, text: text, subject: subject); 13 + } 14 + 15 + static Future<void> shareFilesAtOrigin(Rect sharePositionOrigin, List<XFile> files, {String? text, String? subject}) { 16 + return Share.shareXFiles(files, text: text, subject: subject, sharePositionOrigin: sharePositionOrigin); 9 17 } 18 + 19 + static Future<void> shareFilePaths(BuildContext context, List<String> filePaths, {String? text, String? subject}) { 20 + return shareFilePathsAtOrigin(sharePositionOriginForContext(context), filePaths, text: text, subject: subject); 21 + } 22 + 23 + static Future<void> shareFilePathsAtOrigin( 24 + Rect sharePositionOrigin, 25 + List<String> filePaths, { 26 + String? text, 27 + String? subject, 28 + }) { 29 + final files = [for (final path in filePaths) XFile(path)]; 30 + return shareFilesAtOrigin(sharePositionOrigin, files, text: text, subject: subject); 31 + } 32 + 33 + static Rect sharePositionOriginForContext(BuildContext context) => _sharePositionOrigin(context); 10 34 11 35 static Rect _sharePositionOrigin(BuildContext context) { 12 36 final renderObject = context.findRenderObject();
+39
pubspec.lock
··· 590 590 url: "https://pub.dev" 591 591 source: hosted 592 592 version: "3.4.1" 593 + flutter_driver: 594 + dependency: transitive 595 + description: flutter 596 + source: sdk 597 + version: "0.0.0" 593 598 flutter_lints: 594 599 dependency: "direct dev" 595 600 description: ··· 688 693 url: "https://pub.dev" 689 694 source: hosted 690 695 version: "4.0.0" 696 + fuchsia_remote_debug_protocol: 697 + dependency: transitive 698 + description: flutter 699 + source: sdk 700 + version: "0.0.0" 691 701 gal: 692 702 dependency: "direct main" 693 703 description: ··· 840 850 url: "https://pub.dev" 841 851 source: hosted 842 852 version: "0.2.2" 853 + integration_test: 854 + dependency: "direct dev" 855 + description: flutter 856 + source: sdk 857 + version: "0.0.0" 843 858 intl: 844 859 dependency: "direct main" 845 860 description: ··· 1248 1263 url: "https://pub.dev" 1249 1264 source: hosted 1250 1265 version: "1.5.2" 1266 + process: 1267 + dependency: transitive 1268 + description: 1269 + name: process 1270 + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 1271 + url: "https://pub.dev" 1272 + source: hosted 1273 + version: "5.0.5" 1251 1274 provider: 1252 1275 dependency: "direct main" 1253 1276 description: ··· 1493 1516 url: "https://pub.dev" 1494 1517 source: hosted 1495 1518 version: "1.4.1" 1519 + sync_http: 1520 + dependency: transitive 1521 + description: 1522 + name: sync_http 1523 + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" 1524 + url: "https://pub.dev" 1525 + source: hosted 1526 + version: "0.3.1" 1496 1527 synchronized: 1497 1528 dependency: transitive 1498 1529 description: ··· 1757 1788 url: "https://pub.dev" 1758 1789 source: hosted 1759 1790 version: "3.0.3" 1791 + webdriver: 1792 + dependency: transitive 1793 + description: 1794 + name: webdriver 1795 + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" 1796 + url: "https://pub.dev" 1797 + source: hosted 1798 + version: "3.1.0" 1760 1799 webkit_inspection_protocol: 1761 1800 dependency: transitive 1762 1801 description:
+2
pubspec.yaml
··· 61 61 dev_dependencies: 62 62 flutter_test: 63 63 sdk: flutter 64 + integration_test: 65 + sdk: flutter 64 66 flutter_lints: ^6.0.0 65 67 drift_dev: ^2.24.0 66 68 build_runner: ^2.4.15
+21
test/core/network/xrpc_network_interceptor_test.dart
··· 4 4 5 5 void main() { 6 6 group('XrpcNetworkInterceptor', () { 7 + setUp(XrpcNetworkInterceptor.debugResetForcedUnauthorized); 8 + tearDown(XrpcNetworkInterceptor.debugResetForcedUnauthorized); 9 + 7 10 group('metadataFor', () { 8 11 test('extracts pds, appview, and xrpc method', () { 9 12 final metadata = XrpcNetworkInterceptor.metadataFor( ··· 35 38 }); 36 39 37 40 group('wrap clients', () { 41 + test('debug hook forces one unauthorized response then clears', () async { 42 + var calls = 0; 43 + final wrapped = XrpcNetworkInterceptor.wrapGetClient((url, {headers}) async { 44 + calls += 1; 45 + return http.Response('ok', 200, request: http.Request('GET', url)); 46 + }); 47 + 48 + XrpcNetworkInterceptor.debugForceUnauthorizedOnce(); 49 + 50 + final first = await wrapped(Uri.parse('https://example.com/xrpc/app.bsky.feed.getFeed')); 51 + final second = await wrapped(Uri.parse('https://example.com/xrpc/app.bsky.feed.getFeed')); 52 + 53 + expect(first.statusCode, 401); 54 + expect(first.body, contains('Unauthorized')); 55 + expect(second.statusCode, 200); 56 + expect(calls, 1); 57 + }); 58 + 38 59 test('wrapGetClient delegates request and returns response', () async { 39 60 final wrapped = XrpcNetworkInterceptor.wrapGetClient((url, {headers}) async { 40 61 return http.Response('ok', 200, request: http.Request('GET', url));
+116 -1
test/features/feed/data/feed_repository_cache_test.dart
··· 8 8 import 'package:flutter_test/flutter_test.dart'; 9 9 import 'package:lazurite/core/cache/offline_cache_policy.dart'; 10 10 import 'package:lazurite/core/database/app_database.dart'; 11 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 11 12 import 'package:lazurite/features/feed/data/feed_repository.dart'; 12 13 13 14 class _FakeFeedData { ··· 37 38 } 38 39 } 39 40 41 + class _HandlerFeedApi { 42 + _HandlerFeedApi({required this.getTimelineHandler}); 43 + 44 + final Future<_FakeFeedResponse> Function({String? cursor, int? limit, Map<String, String>? headers}) 45 + getTimelineHandler; 46 + 47 + Future<_FakeFeedResponse> getTimeline({String? cursor, int? limit, Map<String, String>? $headers}) { 48 + return getTimelineHandler(cursor: cursor, limit: limit, headers: $headers); 49 + } 50 + } 51 + 40 52 class _FakeBluesky { 41 53 _FakeBluesky(this.feed); 42 54 43 - final _QueuedFeedApi feed; 55 + final dynamic feed; 44 56 } 45 57 46 58 void main() { ··· 139 151 expect(cached!.posts.length, 1); 140 152 expect(cached.posts.single.post.uri.toString(), validPost.post.uri.toString()); 141 153 }); 154 + 155 + test('retries timeline request once after unauthorized recovery', () async { 156 + var refreshCalls = 0; 157 + var primaryCalls = 0; 158 + var fallbackCalls = 0; 159 + 160 + final primaryFeedApi = _HandlerFeedApi( 161 + getTimelineHandler: ({String? cursor, int? limit, Map<String, String>? headers}) async { 162 + primaryCalls += 1; 163 + throw _unauthorizedException('app.bsky.feed.getTimeline'); 164 + }, 165 + ); 166 + final fallbackFeedApi = _HandlerFeedApi( 167 + getTimelineHandler: ({String? cursor, int? limit, Map<String, String>? headers}) async { 168 + fallbackCalls += 1; 169 + return _FakeFeedResponse(_FakeFeedData(feed: [_post(1)], cursor: null)); 170 + }, 171 + ); 172 + final repository = FeedRepository( 173 + bluesky: _FakeBluesky(primaryFeedApi), 174 + database: database, 175 + accountDid: 'did:plc:test', 176 + onUnauthorized: () async { 177 + refreshCalls += 1; 178 + return _testTokens(); 179 + }, 180 + blueskyClientFactory: (_) => _FakeBluesky(fallbackFeedApi), 181 + ); 182 + 183 + final result = await repository.getTimeline(); 184 + 185 + expect(refreshCalls, 1); 186 + expect(primaryCalls, 1); 187 + expect(fallbackCalls, 1); 188 + expect(result.posts.length, 1); 189 + expect(result.posts.first.post.uri.toString(), _post(1).post.uri.toString()); 190 + }); 191 + 192 + test('rethrows unauthorized when recovery callback returns null tokens', () async { 193 + var refreshCalls = 0; 194 + var primaryCalls = 0; 195 + final primaryFeedApi = _HandlerFeedApi( 196 + getTimelineHandler: ({String? cursor, int? limit, Map<String, String>? headers}) async { 197 + primaryCalls += 1; 198 + throw _unauthorizedException('app.bsky.feed.getTimeline'); 199 + }, 200 + ); 201 + final repository = FeedRepository( 202 + bluesky: _FakeBluesky(primaryFeedApi), 203 + database: database, 204 + accountDid: 'did:plc:test', 205 + onUnauthorized: () async { 206 + refreshCalls += 1; 207 + return null; 208 + }, 209 + ); 210 + 211 + await expectLater(repository.getTimeline(), throwsA(isA<UnauthorizedException>())); 212 + expect(primaryCalls, 1); 213 + expect(refreshCalls, 1); 214 + }); 215 + 216 + test('rethrows unauthorized when no recovery callback is configured', () async { 217 + var primaryCalls = 0; 218 + final primaryFeedApi = _HandlerFeedApi( 219 + getTimelineHandler: ({String? cursor, int? limit, Map<String, String>? headers}) async { 220 + primaryCalls += 1; 221 + throw _unauthorizedException('app.bsky.feed.getTimeline'); 222 + }, 223 + ); 224 + final repository = FeedRepository( 225 + bluesky: _FakeBluesky(primaryFeedApi), 226 + database: database, 227 + accountDid: 'did:plc:test', 228 + ); 229 + 230 + await expectLater(repository.getTimeline(), throwsA(isA<UnauthorizedException>())); 231 + expect(primaryCalls, 1); 232 + }); 142 233 }); 143 234 } 144 235 ··· 157 248 } 158 249 159 250 List<String> _uris(List<FeedViewPost> posts) => posts.map((post) => post.post.uri.toString()).toList(growable: false); 251 + 252 + AuthTokens _testTokens() { 253 + final now = DateTime.now().toUtc(); 254 + return AuthTokens( 255 + accessToken: 'access-token', 256 + refreshToken: 'refresh-token', 257 + expiresAt: now.add(const Duration(hours: 1)), 258 + did: 'did:plc:test', 259 + handle: 'test.bsky.social', 260 + service: 'bsky.social', 261 + ); 262 + } 263 + 264 + UnauthorizedException _unauthorizedException(String methodId) { 265 + return UnauthorizedException( 266 + XRPCResponse( 267 + headers: const {}, 268 + status: HttpStatus.unauthorized, 269 + request: XRPCRequest(method: HttpMethod.get, url: Uri.https('bsky.social', '/xrpc/$methodId')), 270 + rateLimit: RateLimit.unlimited(), 271 + data: const XRPCError(error: 'Unauthorized', message: 'exp claim timestamp check failed'), 272 + ), 273 + ); 274 + }
+63
test/features/feed/data/post_thread_repository_cache_test.dart
··· 8 8 import 'package:flutter_test/flutter_test.dart'; 9 9 import 'package:lazurite/core/cache/offline_cache_policy.dart'; 10 10 import 'package:lazurite/core/database/app_database.dart'; 11 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 11 12 import 'package:lazurite/features/feed/data/post_thread_repository.dart'; 12 13 13 14 class _FakeThreadResponse { ··· 131 132 final newestEntry = await database.getCachedThreadRoot('did:plc:test', newest.post.uri.toString()); 132 133 expect(newestEntry, isNotNull); 133 134 }); 135 + 136 + test('retries thread request once after unauthorized recovery', () async { 137 + var primaryCalls = 0; 138 + var fallbackCalls = 0; 139 + var refreshCalls = 0; 140 + final thread = _thread(uri: 'at://did:plc:retry/app.bsky.feed.post/retry', cid: 'cid-retry', text: 'Retry'); 141 + final primaryFeedApi = _FakeThreadFeedApi( 142 + getPostThreadHandler: ({required uri}) async { 143 + primaryCalls += 1; 144 + throw _unauthorizedException('app.bsky.feed.getPostThread'); 145 + }, 146 + ); 147 + final fallbackFeedApi = _FakeThreadFeedApi( 148 + getPostThreadHandler: ({required uri}) async { 149 + fallbackCalls += 1; 150 + return _FakeThreadResponse( 151 + FeedGetPostThreadOutput(thread: UFeedGetPostThreadThread.threadViewPost(data: thread)), 152 + ); 153 + }, 154 + ); 155 + final repository = PostThreadRepository( 156 + bluesky: _FakeBluesky(primaryFeedApi), 157 + database: database, 158 + accountDid: 'did:plc:test', 159 + onUnauthorized: () async { 160 + refreshCalls += 1; 161 + return _testTokens(); 162 + }, 163 + blueskyClientFactory: (_) => _FakeBluesky(fallbackFeedApi), 164 + ); 165 + 166 + final resolved = await repository.getPostThread(thread.post.uri.toString()); 167 + 168 + expect(refreshCalls, 1); 169 + expect(primaryCalls, 1); 170 + expect(fallbackCalls, 1); 171 + expect(resolved.post.uri.toString(), thread.post.uri.toString()); 172 + }); 134 173 }); 135 174 } 136 175 ··· 151 190 indexedAt: timestamp, 152 191 ); 153 192 } 193 + 194 + AuthTokens _testTokens() { 195 + final now = DateTime.now().toUtc(); 196 + return AuthTokens( 197 + accessToken: 'access-token', 198 + refreshToken: 'refresh-token', 199 + expiresAt: now.add(const Duration(hours: 1)), 200 + did: 'did:plc:test', 201 + handle: 'test.bsky.social', 202 + service: 'bsky.social', 203 + ); 204 + } 205 + 206 + UnauthorizedException _unauthorizedException(String methodId) { 207 + return UnauthorizedException( 208 + XRPCResponse( 209 + headers: const {}, 210 + status: HttpStatus.unauthorized, 211 + request: XRPCRequest(method: HttpMethod.get, url: Uri.https('bsky.social', '/xrpc/$methodId')), 212 + rateLimit: RateLimit.unlimited(), 213 + data: const XRPCError(error: 'Unauthorized', message: 'exp claim timestamp check failed'), 214 + ), 215 + ); 216 + }
+144 -63
test/features/messages/data/convo_repository_test.dart
··· 1 + import 'dart:collection'; 2 + import 'dart:convert'; 3 + 4 + import 'package:atproto_core/atproto_core.dart' as atcore; 5 + import 'package:bluesky/bluesky_chat.dart'; 1 6 import 'package:bluesky/chat_bsky_convo_defs.dart'; 2 7 import 'package:flutter_test/flutter_test.dart'; 8 + import 'package:http/http.dart' as http; 9 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 3 10 import 'package:lazurite/features/messages/data/convo_repository.dart'; 4 - import 'package:mocktail/mocktail.dart'; 5 - 6 - class MockConvoRepository extends Mock implements ConvoRepository {} 7 11 8 12 void main() { 9 13 group('ConvoListResult', () { ··· 36 40 }); 37 41 }); 38 42 39 - group('ConvoRepository interface', () { 40 - late MockConvoRepository mockRepo; 41 - 42 - setUp(() { 43 - mockRepo = MockConvoRepository(); 44 - }); 43 + group('ConvoRepository auth recovery', () { 44 + test('listConvos retries once after unauthorized and succeeds with refreshed client', () async { 45 + final primary = _ScriptedTransport(getReplies: [_unauthorizedReply()]); 46 + final fallback = _ScriptedTransport( 47 + getReplies: [ 48 + _okReply({ 49 + 'convos': [_convoJson('c1')], 50 + 'cursor': 'next-cursor', 51 + }), 52 + ], 53 + ); 45 54 46 - test('listConvos returns ConvoListResult', () async { 47 - final expected = ConvoListResult(convos: [_makeConvoView('c1')], cursor: 'cursor-1'); 48 - when( 49 - () => mockRepo.listConvos( 50 - cursor: any(named: 'cursor'), 51 - limit: any(named: 'limit'), 52 - ), 53 - ).thenAnswer((_) async => expected); 55 + var recoveryCalls = 0; 56 + var factoryCalls = 0; 57 + final repository = ConvoRepository( 58 + chat: primary.createChat(), 59 + onUnauthorized: () async { 60 + recoveryCalls += 1; 61 + return _freshTokens(); 62 + }, 63 + chatClientFactory: (_) { 64 + factoryCalls += 1; 65 + return fallback.createChat(); 66 + }, 67 + ); 54 68 55 - final result = await mockRepo.listConvos(); 69 + final result = await repository.listConvos(); 56 70 71 + expect(recoveryCalls, 1); 72 + expect(factoryCalls, 1); 73 + expect(primary.getCalls, 1); 74 + expect(fallback.getCalls, 1); 75 + expect(result.cursor, 'next-cursor'); 57 76 expect(result.convos.length, 1); 58 - expect(result.cursor, 'cursor-1'); 77 + expect(result.convos.first.id, 'c1'); 59 78 }); 60 79 61 - test('getMessages returns MessageListResult', () async { 62 - final expected = MessageListResult(messages: [], cursor: null); 63 - when( 64 - () => mockRepo.getMessages( 65 - any(), 66 - cursor: any(named: 'cursor'), 67 - limit: any(named: 'limit'), 68 - ), 69 - ).thenAnswer((_) async => expected); 70 - 71 - final result = await mockRepo.getMessages('convo-1'); 80 + test('listConvos rethrows unauthorized when recovery returns null tokens', () async { 81 + final primary = _ScriptedTransport(getReplies: [_unauthorizedReply()]); 82 + var recoveryCalls = 0; 83 + final repository = ConvoRepository( 84 + chat: primary.createChat(), 85 + onUnauthorized: () async { 86 + recoveryCalls += 1; 87 + return null; 88 + }, 89 + ); 72 90 73 - expect(result.messages, isEmpty); 74 - expect(result.cursor, isNull); 91 + await expectLater(repository.listConvos(), throwsA(isA<atcore.UnauthorizedException>())); 92 + expect(recoveryCalls, 1); 93 + expect(primary.getCalls, 1); 75 94 }); 76 95 77 - test('muteConvo returns ConvoView', () async { 78 - final expected = _makeConvoView('c1'); 79 - when(() => mockRepo.muteConvo(any())).thenAnswer((_) async => expected); 96 + test('updateRead retries post request after unauthorized and succeeds', () async { 97 + final primary = _ScriptedTransport(postReplies: [_unauthorizedReply()]); 98 + final fallback = _ScriptedTransport( 99 + postReplies: [ 100 + _okReply({'convo': _convoJson('c1')}), 101 + ], 102 + ); 80 103 81 - final result = await mockRepo.muteConvo('c1'); 104 + var recoveryCalls = 0; 105 + final repository = ConvoRepository( 106 + chat: primary.createChat(), 107 + onUnauthorized: () async { 108 + recoveryCalls += 1; 109 + return _freshTokens(); 110 + }, 111 + chatClientFactory: (_) => fallback.createChat(), 112 + ); 113 + 114 + await repository.updateRead('c1'); 82 115 83 - expect(result.id, 'c1'); 116 + expect(recoveryCalls, 1); 117 + expect(primary.postCalls, 1); 118 + expect(fallback.postCalls, 1); 84 119 }); 120 + }); 121 + } 85 122 86 - test('unmuteConvo returns ConvoView', () async { 87 - final expected = _makeConvoView('c1'); 88 - when(() => mockRepo.unmuteConvo(any())).thenAnswer((_) async => expected); 123 + class _ScriptedTransport { 124 + _ScriptedTransport({List<_ScriptedReply>? getReplies, List<_ScriptedReply>? postReplies}) 125 + : _getReplies = Queue<_ScriptedReply>.from(getReplies ?? const []), 126 + _postReplies = Queue<_ScriptedReply>.from(postReplies ?? const []); 89 127 90 - final result = await mockRepo.unmuteConvo('c1'); 128 + final Queue<_ScriptedReply> _getReplies; 129 + final Queue<_ScriptedReply> _postReplies; 91 130 92 - expect(result.id, 'c1'); 93 - }); 131 + int getCalls = 0; 132 + int postCalls = 0; 94 133 95 - test('updateRead completes', () async { 96 - when(() => mockRepo.updateRead(any())).thenAnswer((_) async {}); 134 + BlueskyChat createChat() { 135 + return BlueskyChat.fromSession( 136 + const atcore.Session( 137 + did: 'did:plc:test', 138 + handle: 'test.bsky.social', 139 + accessJwt: 'access-token', 140 + refreshJwt: 'refresh-token', 141 + ), 142 + getClient: (uri, {headers}) async { 143 + getCalls += 1; 144 + if (_getReplies.isEmpty) { 145 + throw StateError('No scripted GET response queued for $uri'); 146 + } 147 + return _getReplies.removeFirst().toResponse(method: 'GET', url: uri); 148 + }, 149 + postClient: (uri, {headers, body, encoding}) async { 150 + postCalls += 1; 151 + if (_postReplies.isEmpty) { 152 + throw StateError('No scripted POST response queued for $uri'); 153 + } 154 + return _postReplies.removeFirst().toResponse(method: 'POST', url: uri); 155 + }, 156 + ); 157 + } 158 + } 159 + 160 + Map<String, Object?> _convoJson(String id) { 161 + return {'id': id, 'rev': 'rev-$id', 'members': const [], 'muted': false, 'unreadCount': 0}; 162 + } 97 163 98 - await mockRepo.updateRead('c1'); 164 + class _ScriptedReply { 165 + const _ScriptedReply({required this.statusCode, required this.payload}); 99 166 100 - verify(() => mockRepo.updateRead('c1')).called(1); 101 - }); 167 + final int statusCode; 168 + final Map<String, Object?> payload; 102 169 103 - test('sendMessage returns MessageView', () async { 104 - final expected = _makeMessageView('msg-1', 'Hello'); 105 - when(() => mockRepo.sendMessage(any(), any())).thenAnswer((_) async => expected); 170 + Future<http.Response> toResponse({required String method, required Uri url}) async { 171 + final streamed = http.StreamedResponse( 172 + Stream<List<int>>.value(utf8.encode(jsonEncode(payload))), 173 + statusCode, 174 + request: http.Request(method, url), 175 + headers: const {'content-type': 'application/json'}, 176 + ); 177 + return http.Response.fromStream(streamed); 178 + } 179 + } 106 180 107 - final result = await mockRepo.sendMessage('c1', 'Hello'); 181 + _ScriptedReply _okReply(Map<String, Object?> payload) { 182 + return _ScriptedReply(statusCode: 200, payload: payload); 183 + } 108 184 109 - expect(result.id, 'msg-1'); 110 - expect(result.text, 'Hello'); 111 - }); 112 - }); 185 + _ScriptedReply _unauthorizedReply() { 186 + return const _ScriptedReply( 187 + statusCode: 401, 188 + payload: {'error': 'Unauthorized', 'message': 'exp claim timestamp check failed'}, 189 + ); 113 190 } 114 191 115 192 ConvoView _makeConvoView(String id) => ConvoView(id: id, rev: 'rev-1', members: [], muted: false, unreadCount: 0); 116 193 117 - MessageView _makeMessageView(String id, String text) => MessageView( 118 - id: id, 119 - rev: 'rev-1', 120 - text: text, 121 - sender: const MessageViewSender(did: 'did:plc:user'), 122 - sentAt: DateTime.utc(2026, 3, 15), 123 - ); 194 + AuthTokens _freshTokens() { 195 + final now = DateTime.now().toUtc(); 196 + return AuthTokens( 197 + accessToken: 'fresh-access-token', 198 + refreshToken: 'fresh-refresh-token', 199 + expiresAt: now.add(const Duration(hours: 1)), 200 + did: 'did:plc:test', 201 + handle: 'test.bsky.social', 202 + service: 'bsky.social', 203 + ); 204 + }
+45
test/shared/presentation/helpers/share_helper_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; 4 + 5 + void main() { 6 + testWidgets('sharePositionOriginForContext returns render box bounds when available', (tester) async { 7 + final key = GlobalKey(); 8 + 9 + await tester.pumpWidget( 10 + MaterialApp( 11 + home: Align( 12 + alignment: Alignment.topLeft, 13 + child: SizedBox(key: key, width: 140, height: 72, child: const SizedBox.shrink()), 14 + ), 15 + ), 16 + ); 17 + 18 + final rect = ShareHelper.sharePositionOriginForContext(key.currentContext!); 19 + 20 + expect(rect, isNotNull); 21 + expect(rect.width, 140); 22 + expect(rect.height, 72); 23 + }); 24 + 25 + testWidgets('sharePositionOriginForContext falls back when render box has empty size', (tester) async { 26 + Rect? rect; 27 + 28 + await tester.pumpWidget( 29 + MaterialApp( 30 + home: SizedBox.shrink( 31 + child: Builder( 32 + builder: (context) { 33 + rect = ShareHelper.sharePositionOriginForContext(context); 34 + return const SizedBox.shrink(); 35 + }, 36 + ), 37 + ), 38 + ), 39 + ); 40 + 41 + expect(rect, isNotNull); 42 + expect(rect!.width, greaterThan(0)); 43 + expect(rect!.height, greaterThan(0)); 44 + }); 45 + }