diff --git a/integration_test/auth_token_recovery_flow_test.dart b/integration_test/auth_token_recovery_flow_test.dart new file mode 100644 index 0000000..d25a577 --- /dev/null +++ b/integration_test/auth_token_recovery_flow_test.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:atproto_core/atproto_core.dart'; +import 'package:bloc_test/bloc_test.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/core/theme/feed_layout.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; +import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; +import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; +import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; +import 'package:lazurite/features/feed/data/feed_repository.dart'; +import 'package:lazurite/features/feed/data/post_action_repository.dart'; +import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockSettingsCubit extends MockCubit implements SettingsCubit {} + +class MockFeedPreferencesCubit extends MockCubit implements FeedPreferencesCubit {} + +class MockConnectivityCubit extends MockCubit implements ConnectivityCubit {} + +class MockAuthBloc extends MockBloc implements AuthBloc {} + +class MockSavedPostsCubit extends MockCubit implements SavedPostsCubit {} + +class MockPostActionRepository extends Mock implements PostActionRepository {} + +class _FakeFeedData { + _FakeFeedData({required this.feed, this.cursor}); + + final List feed; + final String? cursor; +} + +class _FakeFeedResponse { + _FakeFeedResponse(this.data); + + final _FakeFeedData data; +} + +class _HandlerFeedApi { + _HandlerFeedApi({required this.getTimelineHandler}); + + final Future<_FakeFeedResponse> Function({String? cursor, int? limit, Map? headers}) + getTimelineHandler; + + Future<_FakeFeedResponse> getTimeline({String? cursor, int? limit, Map? $headers}) { + return getTimelineHandler(cursor: cursor, limit: limit, headers: $headers); + } +} + +class _FakeBluesky { + _FakeBluesky(this.feed); + + final dynamic feed; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const homeFeedState = FeedPreferencesState.loaded( + feeds: [ + SavedFeed( + id: 'timeline', + type: SavedFeedType.knownValue(data: KnownSavedFeedType.timeline), + value: 'timeline', + pinned: true, + ), + ], + ); + + SettingsState settingsState(FeedLayout architecture) => SettingsState( + themePalette: AppThemePalette.oxocarbon, + themeVariant: AppThemeVariant.dark, + useSystemTheme: false, + feedLayout: architecture, + ); + + testWidgets('home feed recovers from expired token unauthorized response', (tester) async { + final database = AppDatabase(executor: NativeDatabase.memory()); + addTearDown(database.close); + + final feedPreferencesCubit = MockFeedPreferencesCubit(); + final connectivityCubit = MockConnectivityCubit(); + final settingsCubit = MockSettingsCubit(); + final authBloc = MockAuthBloc(); + final savedPostsCubit = MockSavedPostsCubit(); + final postActionRepository = MockPostActionRepository(); + + when(() => feedPreferencesCubit.state).thenReturn(homeFeedState); + whenListen(feedPreferencesCubit, const Stream.empty(), initialState: homeFeedState); + + when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); + whenListen( + connectivityCubit, + const Stream.empty(), + initialState: const ConnectivityState.online(), + ); + + when(() => settingsCubit.state).thenReturn(settingsState(FeedLayout.card)); + whenListen(settingsCubit, const Stream.empty(), initialState: settingsState(FeedLayout.card)); + + const authState = AuthState.authenticated( + AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + did: 'did:plc:test', + handle: 'test.bsky.social', + ), + ); + when(() => authBloc.state).thenReturn(authState); + whenListen(authBloc, const Stream.empty(), initialState: authState); + + const savedPostsState = SavedPostsState(status: SavedPostsStatus.loaded); + when(() => savedPostsCubit.state).thenReturn(savedPostsState); + whenListen(savedPostsCubit, const Stream.empty(), initialState: savedPostsState); + + var primaryTimelineCalls = 0; + var fallbackTimelineCalls = 0; + var authRecoveryCalls = 0; + + final primaryFeedApi = _HandlerFeedApi( + getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { + primaryTimelineCalls += 1; + throw _unauthorizedException('app.bsky.feed.getTimeline'); + }, + ); + + final fallbackFeedApi = _HandlerFeedApi( + getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { + fallbackTimelineCalls += 1; + return _FakeFeedResponse(_FakeFeedData(feed: [_post(1)], cursor: null)); + }, + ); + + final repository = FeedRepository( + bluesky: _FakeBluesky(primaryFeedApi), + database: database, + accountDid: 'did:plc:test', + onUnauthorized: () async { + authRecoveryCalls += 1; + return _freshTokens(); + }, + blueskyClientFactory: (_) => _FakeBluesky(fallbackFeedApi), + ); + + await tester.pumpWidget( + MaterialApp( + home: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: repository), + RepositoryProvider.value(value: postActionRepository), + RepositoryProvider(create: (_) => PostActionCache()), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: settingsCubit), + BlocProvider.value(value: feedPreferencesCubit), + BlocProvider.value(value: connectivityCubit), + BlocProvider.value(value: savedPostsCubit), + ], + child: const HomeFeedScreen(), + ), + ), + ), + ); + + await tester.pump(); + + await _pumpUntil( + tester, + condition: () => find.byType(PostCardWithActions).evaluate().isNotEmpty, + timeout: const Duration(seconds: 5), + ); + + expect(primaryTimelineCalls, 1); + expect(authRecoveryCalls, 1); + expect(fallbackTimelineCalls, 1); + expect(find.byType(PostCardWithActions), findsOneWidget); + expect(find.textContaining('Failed to load feed'), findsNothing); + }); +} + +Future _pumpUntil( + WidgetTester tester, { + required bool Function() condition, + Duration timeout = const Duration(seconds: 3), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + fail('Timed out waiting for condition in integration test'); + } + await tester.pump(const Duration(milliseconds: 50)); + } +} + +FeedViewPost _post(int index) { + final timestamp = DateTime.utc(2026, 5, 4, 12).subtract(Duration(minutes: index)); + final did = 'did:plc:author$index'; + return FeedViewPost( + post: PostView( + uri: AtUri('at://$did/app.bsky.feed.post/$index'), + cid: 'cid-$index', + author: ProfileViewBasic(did: did, handle: 'author$index.bsky.social'), + record: { + r'$type': 'app.bsky.feed.post', + 'text': 'Recovered post $index', + 'createdAt': timestamp.toIso8601String(), + }, + indexedAt: timestamp, + ), + ); +} + +AuthTokens _freshTokens() { + final now = DateTime.now().toUtc(); + return AuthTokens( + accessToken: 'fresh-access-token', + refreshToken: 'fresh-refresh-token', + expiresAt: now.add(const Duration(hours: 1)), + did: 'did:plc:test', + handle: 'test.bsky.social', + service: 'bsky.social', + ); +} + +UnauthorizedException _unauthorizedException(String methodId) { + return UnauthorizedException( + XRPCResponse( + headers: const {}, + status: HttpStatus.unauthorized, + request: XRPCRequest(method: HttpMethod.get, url: Uri.https('bsky.social', '/xrpc/$methodId')), + rateLimit: RateLimit.unlimited(), + data: const XRPCError(error: 'Unauthorized', message: 'exp claim timestamp check failed'), + ), + ); +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8e3c79c..052c213 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -96,6 +96,8 @@ PODS: - GoogleUtilities/Privacy - image_picker_ios (0.0.1): - Flutter + - integration_test (0.0.1): + - Flutter - nanopb (3.30910.0): - nanopb/decode (= 3.30910.0) - nanopb/encode (= 3.30910.0) @@ -183,6 +185,7 @@ DEPENDENCIES: - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - gal (from `.symlinks/plugins/gal/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) @@ -233,6 +236,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/gal/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" objectbox_flutter_libs: :path: ".symlinks/plugins/objectbox_flutter_libs/ios" package_info_plus: @@ -276,6 +281,7 @@ SPEC CHECKSUMS: GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 ObjectBox: eccb95ea2054c39d81dfa2d4ccc5f1e31187228a objectbox_flutter_libs: ed1510f71602e4a0d3f2a721324e468d066fdbb9 diff --git a/justfile b/justfile index 38e0283..feadc4a 100644 --- a/justfile +++ b/justfile @@ -29,6 +29,16 @@ test *paths='': just objectbox-check flutter test {{ paths }} --fail-fast --timeout=120s +# Run end-to-end style integration tests from integration_test/ +e2e: + just objectbox-check + flutter test integration_test --reporter=failures-only --fail-fast --timeout=180s + +# Run one specific end-to-end test file +e2e-file path: + just objectbox-check + flutter test {{ path }} --reporter=failures-only --fail-fast --timeout=180s + generate: flutter pub run build_runner build --delete-conflicting-outputs diff --git a/lib/core/network/unauthorized_recovery_runner.dart b/lib/core/network/unauthorized_recovery_runner.dart new file mode 100644 index 0000000..9760856 --- /dev/null +++ b/lib/core/network/unauthorized_recovery_runner.dart @@ -0,0 +1,58 @@ +import 'package:atproto_core/atproto_core.dart' as atcore show UnauthorizedException; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; + +typedef UnauthorizedRecoveryCallback = Future Function(); +typedef UnauthorizedClientFactory = TClient? Function(AuthTokens tokens); +typedef UnauthorizedRecoveryLogger = void Function(Object error, StackTrace stackTrace); + +/// Centralized helper for retry-on-unauthorized with token refresh. +final class UnauthorizedRecoveryRunner { + UnauthorizedRecoveryRunner({ + required TClient initialClient, + required UnauthorizedRecoveryCallback? onUnauthorized, + required UnauthorizedClientFactory clientFactory, + this.onUnauthorizedException, + }) : _client = initialClient, + _onUnauthorized = onUnauthorized, + _clientFactory = clientFactory; + + TClient _client; + final UnauthorizedRecoveryCallback? _onUnauthorized; + final UnauthorizedClientFactory _clientFactory; + final UnauthorizedRecoveryLogger? onUnauthorizedException; + + TClient get client => _client; + + Future run(Future Function(TClient client) request) async { + try { + return await request(_client); + } on atcore.UnauthorizedException catch (error, stackTrace) { + onUnauthorizedException?.call(error, stackTrace); + final recovered = await _recoverAuthSession(); + if (!recovered) { + rethrow; + } + return request(_client); + } + } + + Future _recoverAuthSession() async { + final callback = _onUnauthorized; + if (callback == null) { + return false; + } + + final refreshedTokens = await callback(); + if (refreshedTokens == null) { + return false; + } + + final refreshedClient = _clientFactory(refreshedTokens); + if (refreshedClient == null) { + return false; + } + + _client = refreshedClient; + return true; + } +} diff --git a/lib/core/network/xrpc_network_interceptor.dart b/lib/core/network/xrpc_network_interceptor.dart index 5892373..2d4a716 100644 --- a/lib/core/network/xrpc_network_interceptor.dart +++ b/lib/core/network/xrpc_network_interceptor.dart @@ -14,12 +14,37 @@ class XrpcRequestMetadata { } abstract final class XrpcNetworkInterceptor { + static int _forcedUnauthorizedResponses = 0; + + static void debugForceUnauthorizedOnce() { + if (!kDebugMode) { + return; + } + _forcedUnauthorizedResponses += 1; + log.w('XRPC Debug Hook: next $_forcedUnauthorizedResponses request(s) will return 401 Unauthorized'); + } + + @visibleForTesting + static void debugResetForcedUnauthorized() { + _forcedUnauthorizedResponses = 0; + } + static atp_core.GetClient wrapGetClient([atp_core.GetClient? baseClient]) { final delegate = baseClient ?? http.get; return (Uri url, {Map? headers}) async { final metadata = metadataFor(url, headers: headers); final stopwatch = Stopwatch()..start(); log.t(_requestLogLine(httpMethod: 'GET', metadata: metadata)); + final forced = _takeForcedUnauthorized(method: 'GET', url: url, metadata: metadata); + if (forced != null) { + _logResponse( + httpMethod: 'GET', + metadata: metadata, + statusCode: forced.statusCode, + elapsed: stopwatch.elapsed, + ); + return forced; + } try { final response = await delegate(url, headers: headers); _logResponse( @@ -46,6 +71,16 @@ abstract final class XrpcNetworkInterceptor { final metadata = metadataFor(url, headers: headers); final stopwatch = Stopwatch()..start(); log.t(_requestLogLine(httpMethod: 'POST', metadata: metadata)); + final forced = _takeForcedUnauthorized(method: 'POST', url: url, metadata: metadata); + if (forced != null) { + _logResponse( + httpMethod: 'POST', + metadata: metadata, + statusCode: forced.statusCode, + elapsed: stopwatch.elapsed, + ); + return forced; + } try { final response = await delegate(url, headers: headers, body: body, encoding: encoding); _logResponse( @@ -96,6 +131,28 @@ abstract final class XrpcNetworkInterceptor { return null; } + static http.Response? _takeForcedUnauthorized({ + required String method, + required Uri url, + required XrpcRequestMetadata metadata, + }) { + if (!kDebugMode || _forcedUnauthorizedResponses < 1) { + return null; + } + + _forcedUnauthorizedResponses -= 1; + log.w( + 'XRPC Debug Hook: forcing 401 for method=$method, PDS=${metadata.pdsHost}, ' + 'AppView=${metadata.appView}, XRPC method=${metadata.xrpcMethod}', + ); + return http.Response( + '{"error":"Unauthorized","message":"Forced debug Unauthorized response"}', + 401, + headers: const {'content-type': 'application/json'}, + request: http.Request(method, url), + ); + } + static String _requestLogLine({required String httpMethod, required XrpcRequestMetadata metadata}) { return 'XRPC Request: method=$httpMethod, PDS=${metadata.pdsHost}, AppView=${metadata.appView}, ' 'XRPC method=${metadata.xrpcMethod}'; diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 2e593f6..6e79386 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:atproto_core/atproto_core.dart' show AtUri; +import 'package:atproto_core/atproto_core.dart' as atcore show AtUri; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; @@ -11,6 +11,9 @@ import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_fallback_service.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; +import 'package:lazurite/core/network/xrpc_client_factory.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/data/trending_join.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; @@ -27,8 +30,9 @@ class FeedRepository { AppViewFallbackService? appViewFallbackService, int routingEpoch = 0, int Function()? routingEpochResolver, - }) : _bluesky = bluesky, - _database = database, + Future Function()? onUnauthorized, + dynamic Function(AuthTokens tokens)? blueskyClientFactory, + }) : _database = database, _accountDid = accountDid, _moderationService = moderationService, _appViewContext = AppViewRequestContext( @@ -39,9 +43,18 @@ class FeedRepository { _crossProviderFallbackEnabledResolver = crossProviderFallbackEnabledResolver, _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(), _routingEpoch = routingEpoch, - _routingEpochResolver = routingEpochResolver; + _routingEpochResolver = routingEpochResolver { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + onUnauthorizedException: (error, stackTrace) { + log.w('feed.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); + }, + ); + } - final dynamic _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final AppDatabase _database; final String _accountDid; final ModerationService? _moderationService; @@ -77,25 +90,21 @@ class FeedRepository { await _moderationService?.headersForRequest(), ); - final response = await _bluesky.feed.getAuthorFeed( - actor: actor, - cursor: cursor, - limit: limit, - filter: bskyFilter, - $headers: headers, + final response = await _authRecovery.run( + (client) => + client.feed.getAuthorFeed(actor: actor, cursor: cursor, limit: limit, filter: bskyFilter, $headers: headers), ); return FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); } Future getTimeline({String? cursor, int limit = 50}) async { - final response = await _bluesky.feed.getTimeline( - cursor: cursor, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.getTimeline', - await _moderationService?.headersForRequest(), - ), + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.feed.getTimeline', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run( + (client) => client.feed.getTimeline(cursor: cursor, limit: limit, $headers: headers), ); final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); @@ -103,15 +112,13 @@ class FeedRepository { return result; } - Future getFeed({required AtUri feedUri, String? cursor, int limit = 50}) async { - final response = await _bluesky.feed.getFeed( - feed: feedUri, - cursor: cursor, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.getFeed', - await _moderationService?.headersForRequest(), - ), + Future getFeed({required atcore.AtUri feedUri, String? cursor, int limit = 50}) async { + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.feed.getFeed', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run( + (client) => client.feed.getFeed(feed: feedUri, cursor: cursor, limit: limit, $headers: headers), ); final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); @@ -161,28 +168,27 @@ class FeedRepository { Future getPreferences() async { final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); - final response = await _bluesky.actor.getPreferences($headers: headers); + final response = await _authRecovery.run((client) => client.actor.getPreferences($headers: headers)); return PreferencesResult(preferences: response.data.preferences); } Future putPreferences({required List preferences}) async { final headers = _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()); - await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); + await _authRecovery.run((client) => client.actor.putPreferences(preferences: preferences, $headers: headers)); } Future> getSuggestedFeeds({String? cursor, int limit = 50}) async { - final response = await _bluesky.feed.getSuggestedFeeds( - cursor: cursor, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.getSuggestedFeeds', - await _moderationService?.headersForRequest(), - ), + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.feed.getSuggestedFeeds', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run( + (client) => client.feed.getSuggestedFeeds(cursor: cursor, limit: limit, $headers: headers), ); return response.data.feeds; } - Future resolveFeedGeneratorUri({required String actor, required String rkey}) async { + Future resolveFeedGeneratorUri({required String actor, required String rkey}) async { final normalizedActor = actor.trim(); final normalizedRkey = rkey.trim(); if (normalizedActor.isEmpty || normalizedRkey.isEmpty) { @@ -190,21 +196,21 @@ class FeedRepository { } if (normalizedActor.startsWith('did:')) { - return AtUri.parse('at://$normalizedActor/app.bsky.feed.generator/$normalizedRkey'); + return atcore.AtUri.parse('at://$normalizedActor/app.bsky.feed.generator/$normalizedRkey'); } - final response = await _bluesky.actor.getProfile( - actor: normalizedActor, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.actor.getProfile', - await _moderationService?.headersForRequest(), - ), + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.actor.getProfile', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run( + (client) => client.actor.getProfile(actor: normalizedActor, $headers: headers), ); final did = response.data.did.trim(); if (did.isEmpty) { throw StateError('Resolved profile did was empty for actor=$normalizedActor'); } - return AtUri.parse('at://$did/app.bsky.feed.generator/$normalizedRkey'); + return atcore.AtUri.parse('at://$did/app.bsky.feed.generator/$normalizedRkey'); } Future getTrendingScreenData({int limit = 10}) async { @@ -236,7 +242,7 @@ class FeedRepository { return _runPublicReadWithFallback( endpointId: 'app.bsky.unspecced.getTrendingTopics', request: (context, headers, {required fallbackUsed}) async { - final response = await _bluesky.unspecced.getTrendingTopics( + final response = await _authRecovery.client.unspecced.getTrendingTopics( limit: clampedLimit, $service: context.publicServiceHost(), $headers: headers, @@ -251,7 +257,7 @@ class FeedRepository { return _runPublicReadWithFallback( endpointId: 'app.bsky.unspecced.getTrends', request: (context, headers, {required fallbackUsed}) async { - final response = await _bluesky.unspecced.getTrends( + final response = await _authRecovery.client.unspecced.getTrends( limit: clampedLimit, $service: context.publicServiceHost(), $headers: headers, @@ -304,25 +310,25 @@ class FeedRepository { ); } - Future getFeedGenerator(AtUri feedUri) async { - final response = await _bluesky.feed.getFeedGenerator( - feed: feedUri, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.getFeedGenerator', - await _moderationService?.headersForRequest(), - ), + Future getFeedGenerator(atcore.AtUri feedUri) async { + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.feed.getFeedGenerator', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run( + (client) => client.feed.getFeedGenerator(feed: feedUri, $headers: headers), ); return response.data.view; } - Future> getFeedGenerators(List feedUris) async { + Future> getFeedGenerators(List feedUris) async { if (feedUris.isEmpty) return []; - final response = await _bluesky.feed.getFeedGenerators( - feeds: feedUris, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.getFeedGenerators', - await _moderationService?.headersForRequest(), - ), + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.feed.getFeedGenerators', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run( + (client) => client.feed.getFeedGenerators(feeds: feedUris, $headers: headers), ); return response.data.feeds; } diff --git a/lib/features/feed/data/post_thread_repository.dart b/lib/features/feed/data/post_thread_repository.dart index 94b3d77..1312e47 100644 --- a/lib/features/feed/data/post_thread_repository.dart +++ b/lib/features/feed/data/post_thread_repository.dart @@ -1,12 +1,16 @@ -import 'package:atproto_core/atproto_core.dart'; +import 'dart:convert'; + +import 'package:atproto_core/atproto_core.dart' as atcore; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/app_bsky_feed_getpostthread.dart'; import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; +import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; +import 'package:lazurite/core/network/xrpc_client_factory.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; -import 'dart:convert'; class PostThreadRepository { PostThreadRepository({ @@ -16,16 +20,26 @@ class PostThreadRepository { ModerationService? moderationService, String? appViewProvider, String Function()? appViewProviderResolver, - }) : _bluesky = bluesky, - _database = database, + Future Function()? onUnauthorized, + dynamic Function(AuthTokens tokens)? blueskyClientFactory, + }) : _database = database, _accountDid = accountDid, _moderationService = moderationService, _appViewContext = AppViewRequestContext( appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, - ); + ) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + onUnauthorizedException: (error, stackTrace) { + log.w('thread.auth unauthorized; attempting session recovery', error: error, stackTrace: stackTrace); + }, + ); + } - final dynamic _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final AppDatabase _database; final String _accountDid; final ModerationService? _moderationService; @@ -33,12 +47,12 @@ class PostThreadRepository { Future getPostThread(String uri) async { try { - final response = await _bluesky.feed.getPostThread( - uri: AtUri.parse(uri), - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.getPostThread', - await _moderationService?.headersForRequest(), - ), + final headers = _appViewContext.appBskyHeadersForEndpoint( + 'app.bsky.feed.getPostThread', + await _moderationService?.headersForRequest(), + ); + final response = await _authRecovery.run( + (client) => client.feed.getPostThread(uri: atcore.AtUri.parse(uri), $headers: headers), ); final thread = response.data.thread as UFeedGetPostThreadThread; @@ -83,8 +97,12 @@ class PostThreadRepository { if (direct != null) { try { return ThreadViewPost.fromJson(jsonDecode(direct.payload) as Map); - } catch (_) { - // Ignore malformed cache rows and continue scanning. + } catch (error, stackTrace) { + log.d( + 'thread.cache failed to decode direct snapshot for requestedUri=$requestedUri', + error: error, + stackTrace: stackTrace, + ); } } @@ -97,8 +115,12 @@ class PostThreadRepository { if (_containsPostUri(decoded, requestedUri)) { return decoded; } - } catch (_) { - // Ignore malformed cache rows and keep scanning valid snapshots. + } catch (error, stackTrace) { + log.d( + 'thread.cache failed to decode snapshot while scanning rootUri=${candidate.rootUri} for requestedUri=$requestedUri', + error: error, + stackTrace: stackTrace, + ); } } return null; diff --git a/lib/features/logs/presentation/logs_screen.dart b/lib/features/logs/presentation/logs_screen.dart index 17dcb57..b77d936 100644 --- a/lib/features/logs/presentation/logs_screen.dart +++ b/lib/features/logs/presentation/logs_screen.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logger/logger.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/logs/cubit/log_viewer_cubit.dart'; import 'package:lazurite/features/logs/data/log_entry.dart'; +import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; class LogsScreen extends StatelessWidget { @@ -84,11 +85,7 @@ class _LogsScreenContentState extends State<_LogsScreenContent> { appBar: AppBar( title: const Text('Logs'), actions: [ - IconButton( - icon: const Icon(Icons.share_outlined), - tooltip: 'Share log file', - onPressed: () => _shareLogs(context), - ), + IconButton(icon: const Icon(Icons.share_outlined), tooltip: 'Share log file', onPressed: _shareLogs), IconButton( icon: Icon(Icons.delete_outline, color: context.colorScheme.error), tooltip: 'Clear all logs', @@ -114,15 +111,36 @@ class _LogsScreenContentState extends State<_LogsScreenContent> { ); } - Future _shareLogs(BuildContext context) async { + Future _shareLogs() async { final cubit = context.read(); + final messenger = ScaffoldMessenger.of(context); + final shareOrigin = ShareHelper.sharePositionOriginForContext(context); + final file = await cubit.getTodaysLogFile(); - if (file != null && await file.exists()) { - await Share.shareXFiles([XFile(file.path)], subject: 'Lazurite logs'); - } else { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No log file available'))); + if (!mounted) { + return; + } + + if (file == null || !await file.exists()) { + if (!mounted) { + return; + } + messenger.showSnackBar(const SnackBar(content: Text('No log file available'))); + return; + } + + if (!mounted) { + return; + } + + try { + await ShareHelper.shareFilePathsAtOrigin(shareOrigin, [file.path], subject: 'Lazurite logs'); + } catch (error, stackTrace) { + log.e('LogsScreen: Failed to open share sheet for log file', error: error, stackTrace: stackTrace); + if (!mounted) { + return; } + messenger.showSnackBar(const SnackBar(content: Text('Unable to open share sheet. Please try again.'))); } } diff --git a/lib/features/messages/data/convo_repository.dart b/lib/features/messages/data/convo_repository.dart index 80371a7..7b888db 100644 --- a/lib/features/messages/data/convo_repository.dart +++ b/lib/features/messages/data/convo_repository.dart @@ -1,52 +1,71 @@ import 'package:bluesky/bluesky_chat.dart'; import 'package:bluesky/chat_bsky_convo_defs.dart'; import 'package:bluesky/chat_bsky_convo_getmessages.dart'; +import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; +import 'package:lazurite/core/network/xrpc_client_factory.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; class ConvoRepository { - ConvoRepository({required BlueskyChat chat}) : _chat = chat; + ConvoRepository({ + required BlueskyChat chat, + Future Function()? onUnauthorized, + BlueskyChat? Function(AuthTokens tokens)? chatClientFactory, + }) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: chat, + onUnauthorized: onUnauthorized, + clientFactory: chatClientFactory ?? createBlueSkyChatClient, + ); + } - final BlueskyChat _chat; + late final UnauthorizedRecoveryRunner _authRecovery; Future listConvos({String? cursor, int limit = 20}) async { - final response = await _chat.convo.listConvos(cursor: cursor, limit: limit); + final response = await _authRecovery.run((client) => client.convo.listConvos(cursor: cursor, limit: limit)); return ConvoListResult(convos: response.data.convos, cursor: response.data.cursor); } Future getConvoForMembers(List dids) async { - final response = await _chat.convo.getConvoForMembers(members: dids); + final response = await _authRecovery.run((client) => client.convo.getConvoForMembers(members: dids)); return response.data.convo; } Future getMessages(String convoId, {String? cursor, int limit = 50}) async { - final response = await _chat.convo.getMessages(convoId: convoId, cursor: cursor, limit: limit); + final response = await _authRecovery.run( + (client) => client.convo.getMessages(convoId: convoId, cursor: cursor, limit: limit), + ); return MessageListResult(messages: response.data.messages, cursor: response.data.cursor); } Future sendMessage(String convoId, String text) async { - final response = await _chat.convo.sendMessage( - convoId: convoId, - message: MessageInput(text: text), + final response = await _authRecovery.run( + (client) => client.convo.sendMessage( + convoId: convoId, + message: MessageInput(text: text), + ), ); return response.data; } Future deleteMessageForSelf(String convoId, String messageId) async { - final response = await _chat.convo.deleteMessageForSelf(convoId: convoId, messageId: messageId); + final response = await _authRecovery.run( + (client) => client.convo.deleteMessageForSelf(convoId: convoId, messageId: messageId), + ); return response.data; } Future muteConvo(String convoId) async { - final response = await _chat.convo.muteConvo(convoId: convoId); + final response = await _authRecovery.run((client) => client.convo.muteConvo(convoId: convoId)); return response.data.convo; } Future unmuteConvo(String convoId) async { - final response = await _chat.convo.unmuteConvo(convoId: convoId); + final response = await _authRecovery.run((client) => client.convo.unmuteConvo(convoId: convoId)); return response.data.convo; } Future updateRead(String convoId) async { - await _chat.convo.updateRead(convoId: convoId); + await _authRecovery.run((client) => client.convo.updateRead(convoId: convoId)); } } diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index bf9a15d..a43ae1e 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/atproto_host_resolver.dart'; +import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; @@ -167,15 +168,13 @@ class SettingsScreen extends StatelessWidget { ); } - Widget _buildSectionHeader(BuildContext context, String title) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - title.toUpperCase(), - style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), - ), - ); - } + Widget _buildSectionHeader(BuildContext context, String title) => Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + title.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), + ), + ); Widget _title(BuildContext context) => Text('Settings', style: context.textTheme.titleLarge); @@ -306,57 +305,55 @@ class SettingsScreen extends StatelessWidget { ); } - Widget _buildSearchSettings(BuildContext context) { - return BlocBuilder( - builder: (context, settingsState) { - final theme = Theme.of(context); - return Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.dividerColor), - bottom: BorderSide(color: theme.dividerColor), - ), - color: theme.cardColor, + Widget _buildSearchSettings(BuildContext context) => BlocBuilder( + builder: (context, settingsState) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.dividerColor), + bottom: BorderSide(color: theme.dividerColor), ), - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.tune_outlined), - title: const Text('Typeahead Provider'), - subtitle: Text( - settingsState.typeaheadProvider == 'community' - ? 'Community (waow.tech) selected. Third-party service, works before login.' - : 'Bluesky official endpoint selected.', - ), + color: theme.cardColor, + ), + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.tune_outlined), + title: const Text('Typeahead Provider'), + subtitle: Text( + settingsState.typeaheadProvider == 'community' + ? 'Community (waow.tech) selected. Third-party service, works before login.' + : 'Bluesky official endpoint selected.', ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Align( - alignment: Alignment.centerLeft, - child: SegmentedButton( - segments: const [ - ButtonSegment(value: 'bluesky', label: Text('Bluesky')), - ButtonSegment(value: 'community', label: Text('Community')), - ], - selected: {settingsState.typeaheadProvider}, - onSelectionChanged: (selection) { - context.read().setTypeaheadProvider(selection.first); - }, - ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Align( + alignment: Alignment.centerLeft, + child: SegmentedButton( + segments: const [ + ButtonSegment(value: 'bluesky', label: Text('Bluesky')), + ButtonSegment(value: 'community', label: Text('Community')), + ], + selected: {settingsState.typeaheadProvider}, + onSelectionChanged: (selection) { + context.read().setTypeaheadProvider(selection.first); + }, ), ), - const Divider(height: 1), - const _SettingsTile( - icon: Icons.manage_search_outlined, - title: 'Semantic Search', - subtitle: 'Manage semantic search from Bookmarks & Likes -> Search', - ), - ], - ), - ); - }, - ); - } + ), + const Divider(height: 1), + const _SettingsTile( + icon: Icons.manage_search_outlined, + title: 'Semantic Search', + subtitle: 'Manage semantic search from Bookmarks & Likes -> Search', + ), + ], + ), + ); + }, + ); Widget _buildDeveloperSettings(BuildContext context) { final settingsCubit = context.read(); @@ -389,6 +386,19 @@ class SettingsScreen extends StatelessWidget { trailing: const Icon(Icons.warning_amber_rounded), onTap: crashReportingService?.crash, ), + if (kDebugMode) ...[ + const Divider(height: 1), + _SettingsTile( + icon: Icons.lock_reset_outlined, + title: 'Force Next XRPC 401', + subtitle: 'Debug-only: next network request returns Unauthorized to test token refresh', + trailing: const Icon(Icons.play_arrow_outlined), + onTap: () { + XrpcNetworkInterceptor.debugForceUnauthorizedOnce(); + showAppSnackBar(context, 'Armed: next XRPC request will return debug 401 Unauthorized'); + }, + ), + ], ], ), ); diff --git a/lib/main.dart b/lib/main.dart index 86aa4ac..3deaf82 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -163,6 +163,7 @@ Future main() async { runApp( LazuriteApp.from( authBloc, + authRepository, database, appViewFallbackService, objectBoxStore, @@ -187,6 +188,7 @@ class LazuriteApp extends StatefulWidget { const LazuriteApp({ super.key, required this.authBloc, + required this.authRepository, required this.database, required this.appViewFallbackService, required this.objectBoxStore, @@ -201,6 +203,7 @@ class LazuriteApp extends StatefulWidget { }); final AuthBloc authBloc; + final AuthRepository authRepository; final AppDatabase database; final AppViewFallbackService appViewFallbackService; final ObjectBoxStore objectBoxStore; @@ -216,6 +219,7 @@ class LazuriteApp extends StatefulWidget { /// factory constructor with positional params static LazuriteApp from( AuthBloc authBloc, + AuthRepository authRepository, AppDatabase database, AppViewFallbackService appViewFallbackService, ObjectBoxStore objectBoxStore, @@ -229,6 +233,7 @@ class LazuriteApp extends StatefulWidget { bool firebaseAvailable, ) => LazuriteApp( authBloc: authBloc, + authRepository: authRepository, database: database, appViewFallbackService: appViewFallbackService, objectBoxStore: objectBoxStore, @@ -246,7 +251,7 @@ class LazuriteApp extends StatefulWidget { State createState() => _LazuriteAppState(); } -class _LazuriteAppState extends State { +class _LazuriteAppState extends State with WidgetsBindingObserver { static final _navigatorObserver = LoggingNavigatorObserver(); late GoRouter _router; late String _routerSessionKey; @@ -259,10 +264,12 @@ class _LazuriteAppState extends State { late String _observedAppViewProvider; var _routerGeneration = 0; var _isSoftRestarting = false; + Completer? _authRecoveryCompleter; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _routerSessionKey = _sessionKeyFor(widget.authBloc.state); _observedAppViewProvider = widget.settingsCubit.state.appViewProvider; _router = _createRouter(); @@ -307,6 +314,7 @@ class _LazuriteAppState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _authSubscription.cancel(); _pushRegistrationSubscription.cancel(); _pushForegroundMessageSubscription?.cancel(); @@ -323,6 +331,77 @@ class _LazuriteAppState extends State { super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + unawaited(_refreshExpiredSessionOnResume()); + } + } + + Future _refreshExpiredSessionOnResume() async { + final authState = widget.authBloc.state; + final tokens = authState.tokens; + if (!authState.isAuthenticated || tokens == null || !tokens.isExpired) { + return; + } + await _recoverAuthSession(trigger: 'app_resumed'); + } + + Future _recoverAuthSession({required String trigger}) async { + final inFlight = _authRecoveryCompleter; + if (inFlight != null) { + return inFlight.future; + } + + final completer = Completer(); + _authRecoveryCompleter = completer; + String? refreshingDid; + try { + final authState = widget.authBloc.state; + final tokens = authState.tokens; + if (!authState.isAuthenticated || tokens == null || tokens.refreshToken == null) { + completer.complete(null); + return null; + } + refreshingDid = tokens.did; + + final refreshed = await widget.authRepository.refreshSession(tokens); + if (!_canPublishRecoveryForDid(refreshingDid)) { + completer.complete(null); + return null; + } + + if (refreshed == null || refreshed.did != refreshingDid) { + completer.complete(null); + return null; + } + widget.authBloc.add(SessionRestored(tokens: refreshed)); + completer.complete(refreshed); + return refreshed; + } catch (error, stackTrace) { + log.w('Auth recovery failed (trigger=$trigger)', error: error, stackTrace: stackTrace); + if (_canPublishRecoveryForDid(refreshingDid)) { + widget.authBloc.add(const CheckSessionRequested()); + } + completer.complete(null); + return null; + } finally { + if (identical(_authRecoveryCompleter, completer)) { + _authRecoveryCompleter = null; + } + } + } + + bool _canPublishRecoveryForDid(String? refreshingDid) { + if (!mounted || refreshingDid == null) { + return false; + } + + final state = widget.authBloc.state; + return state.isAuthenticated && state.tokens?.did == refreshingDid; + } + GoRouter _createRouter() { return AppRouter(authBloc: widget.authBloc, navigatorObserver: _navigatorObserver).router; } @@ -491,6 +570,7 @@ class _LazuriteAppState extends State { appViewFallbackService: widget.appViewFallbackService, routingEpoch: context.read().state.routingEpoch, routingEpochResolver: () => context.read().state.routingEpoch, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ), ), RepositoryProvider( @@ -559,6 +639,7 @@ class _LazuriteAppState extends State { accountDid: accountDid, moderationService: context.read(), appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ), ), RepositoryProvider( @@ -580,7 +661,12 @@ class _LazuriteAppState extends State { appViewProviderResolver: () => context.read().state.appViewProvider, ), ), - RepositoryProvider(create: (_) => ConvoRepository(chat: blueskyChat)), + RepositoryProvider( + create: (_) => ConvoRepository( + chat: blueskyChat, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), + ), + ), RepositoryProvider(create: (_) => PostActionCache()), RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), RepositoryProvider.value(value: bluesky), diff --git a/lib/shared/presentation/helpers/share_helper.dart b/lib/shared/presentation/helpers/share_helper.dart index dd83ac5..bccf65f 100644 --- a/lib/shared/presentation/helpers/share_helper.dart +++ b/lib/shared/presentation/helpers/share_helper.dart @@ -5,9 +5,33 @@ class ShareHelper { const ShareHelper._(); static Future shareText(BuildContext context, String text) { - return Share.share(text, sharePositionOrigin: _sharePositionOrigin(context)); + return Share.share(text, sharePositionOrigin: sharePositionOriginForContext(context)); } + static Future shareFiles(BuildContext context, List files, {String? text, String? subject}) { + return shareFilesAtOrigin(sharePositionOriginForContext(context), files, text: text, subject: subject); + } + + static Future shareFilesAtOrigin(Rect sharePositionOrigin, List files, {String? text, String? subject}) { + return Share.shareXFiles(files, text: text, subject: subject, sharePositionOrigin: sharePositionOrigin); + } + + static Future shareFilePaths(BuildContext context, List filePaths, {String? text, String? subject}) { + return shareFilePathsAtOrigin(sharePositionOriginForContext(context), filePaths, text: text, subject: subject); + } + + static Future shareFilePathsAtOrigin( + Rect sharePositionOrigin, + List filePaths, { + String? text, + String? subject, + }) { + final files = [for (final path in filePaths) XFile(path)]; + return shareFilesAtOrigin(sharePositionOrigin, files, text: text, subject: subject); + } + + static Rect sharePositionOriginForContext(BuildContext context) => _sharePositionOrigin(context); + static Rect _sharePositionOrigin(BuildContext context) { final renderObject = context.findRenderObject(); if (renderObject is RenderBox && renderObject.hasSize && !renderObject.size.isEmpty) { diff --git a/pubspec.lock b/pubspec.lock index f052e0f..b3e3335 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -590,6 +590,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -688,6 +693,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" gal: dependency: "direct main" description: @@ -840,6 +850,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -1248,6 +1263,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" provider: dependency: "direct main" description: @@ -1493,6 +1516,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -1757,6 +1788,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0971521..ade17f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^6.0.0 drift_dev: ^2.24.0 build_runner: ^2.4.15 diff --git a/test/core/network/xrpc_network_interceptor_test.dart b/test/core/network/xrpc_network_interceptor_test.dart index 6b329af..0f8a25d 100644 --- a/test/core/network/xrpc_network_interceptor_test.dart +++ b/test/core/network/xrpc_network_interceptor_test.dart @@ -4,6 +4,9 @@ import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; void main() { group('XrpcNetworkInterceptor', () { + setUp(XrpcNetworkInterceptor.debugResetForcedUnauthorized); + tearDown(XrpcNetworkInterceptor.debugResetForcedUnauthorized); + group('metadataFor', () { test('extracts pds, appview, and xrpc method', () { final metadata = XrpcNetworkInterceptor.metadataFor( @@ -35,6 +38,24 @@ void main() { }); group('wrap clients', () { + test('debug hook forces one unauthorized response then clears', () async { + var calls = 0; + final wrapped = XrpcNetworkInterceptor.wrapGetClient((url, {headers}) async { + calls += 1; + return http.Response('ok', 200, request: http.Request('GET', url)); + }); + + XrpcNetworkInterceptor.debugForceUnauthorizedOnce(); + + final first = await wrapped(Uri.parse('https://example.com/xrpc/app.bsky.feed.getFeed')); + final second = await wrapped(Uri.parse('https://example.com/xrpc/app.bsky.feed.getFeed')); + + expect(first.statusCode, 401); + expect(first.body, contains('Unauthorized')); + expect(second.statusCode, 200); + expect(calls, 1); + }); + test('wrapGetClient delegates request and returns response', () async { final wrapped = XrpcNetworkInterceptor.wrapGetClient((url, {headers}) async { return http.Response('ok', 200, request: http.Request('GET', url)); diff --git a/test/features/feed/data/feed_repository_cache_test.dart b/test/features/feed/data/feed_repository_cache_test.dart index 3c71682..71e03dc 100644 --- a/test/features/feed/data/feed_repository_cache_test.dart +++ b/test/features/feed/data/feed_repository_cache_test.dart @@ -8,6 +8,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; class _FakeFeedData { @@ -37,10 +38,21 @@ class _QueuedFeedApi { } } +class _HandlerFeedApi { + _HandlerFeedApi({required this.getTimelineHandler}); + + final Future<_FakeFeedResponse> Function({String? cursor, int? limit, Map? headers}) + getTimelineHandler; + + Future<_FakeFeedResponse> getTimeline({String? cursor, int? limit, Map? $headers}) { + return getTimelineHandler(cursor: cursor, limit: limit, headers: $headers); + } +} + class _FakeBluesky { _FakeBluesky(this.feed); - final _QueuedFeedApi feed; + final dynamic feed; } void main() { @@ -139,6 +151,85 @@ void main() { expect(cached!.posts.length, 1); expect(cached.posts.single.post.uri.toString(), validPost.post.uri.toString()); }); + + test('retries timeline request once after unauthorized recovery', () async { + var refreshCalls = 0; + var primaryCalls = 0; + var fallbackCalls = 0; + + final primaryFeedApi = _HandlerFeedApi( + getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { + primaryCalls += 1; + throw _unauthorizedException('app.bsky.feed.getTimeline'); + }, + ); + final fallbackFeedApi = _HandlerFeedApi( + getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { + fallbackCalls += 1; + return _FakeFeedResponse(_FakeFeedData(feed: [_post(1)], cursor: null)); + }, + ); + final repository = FeedRepository( + bluesky: _FakeBluesky(primaryFeedApi), + database: database, + accountDid: 'did:plc:test', + onUnauthorized: () async { + refreshCalls += 1; + return _testTokens(); + }, + blueskyClientFactory: (_) => _FakeBluesky(fallbackFeedApi), + ); + + final result = await repository.getTimeline(); + + expect(refreshCalls, 1); + expect(primaryCalls, 1); + expect(fallbackCalls, 1); + expect(result.posts.length, 1); + expect(result.posts.first.post.uri.toString(), _post(1).post.uri.toString()); + }); + + test('rethrows unauthorized when recovery callback returns null tokens', () async { + var refreshCalls = 0; + var primaryCalls = 0; + final primaryFeedApi = _HandlerFeedApi( + getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { + primaryCalls += 1; + throw _unauthorizedException('app.bsky.feed.getTimeline'); + }, + ); + final repository = FeedRepository( + bluesky: _FakeBluesky(primaryFeedApi), + database: database, + accountDid: 'did:plc:test', + onUnauthorized: () async { + refreshCalls += 1; + return null; + }, + ); + + await expectLater(repository.getTimeline(), throwsA(isA())); + expect(primaryCalls, 1); + expect(refreshCalls, 1); + }); + + test('rethrows unauthorized when no recovery callback is configured', () async { + var primaryCalls = 0; + final primaryFeedApi = _HandlerFeedApi( + getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { + primaryCalls += 1; + throw _unauthorizedException('app.bsky.feed.getTimeline'); + }, + ); + final repository = FeedRepository( + bluesky: _FakeBluesky(primaryFeedApi), + database: database, + accountDid: 'did:plc:test', + ); + + await expectLater(repository.getTimeline(), throwsA(isA())); + expect(primaryCalls, 1); + }); }); } @@ -157,3 +248,27 @@ FeedViewPost _post(int index) { } List _uris(List posts) => posts.map((post) => post.post.uri.toString()).toList(growable: false); + +AuthTokens _testTokens() { + final now = DateTime.now().toUtc(); + return AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: now.add(const Duration(hours: 1)), + did: 'did:plc:test', + handle: 'test.bsky.social', + service: 'bsky.social', + ); +} + +UnauthorizedException _unauthorizedException(String methodId) { + return UnauthorizedException( + XRPCResponse( + headers: const {}, + status: HttpStatus.unauthorized, + request: XRPCRequest(method: HttpMethod.get, url: Uri.https('bsky.social', '/xrpc/$methodId')), + rateLimit: RateLimit.unlimited(), + data: const XRPCError(error: 'Unauthorized', message: 'exp claim timestamp check failed'), + ), + ); +} diff --git a/test/features/feed/data/post_thread_repository_cache_test.dart b/test/features/feed/data/post_thread_repository_cache_test.dart index 0e17e89..cb2e38c 100644 --- a/test/features/feed/data/post_thread_repository_cache_test.dart +++ b/test/features/feed/data/post_thread_repository_cache_test.dart @@ -8,6 +8,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/data/post_thread_repository.dart'; class _FakeThreadResponse { @@ -131,6 +132,44 @@ void main() { final newestEntry = await database.getCachedThreadRoot('did:plc:test', newest.post.uri.toString()); expect(newestEntry, isNotNull); }); + + test('retries thread request once after unauthorized recovery', () async { + var primaryCalls = 0; + var fallbackCalls = 0; + var refreshCalls = 0; + final thread = _thread(uri: 'at://did:plc:retry/app.bsky.feed.post/retry', cid: 'cid-retry', text: 'Retry'); + final primaryFeedApi = _FakeThreadFeedApi( + getPostThreadHandler: ({required uri}) async { + primaryCalls += 1; + throw _unauthorizedException('app.bsky.feed.getPostThread'); + }, + ); + final fallbackFeedApi = _FakeThreadFeedApi( + getPostThreadHandler: ({required uri}) async { + fallbackCalls += 1; + return _FakeThreadResponse( + FeedGetPostThreadOutput(thread: UFeedGetPostThreadThread.threadViewPost(data: thread)), + ); + }, + ); + final repository = PostThreadRepository( + bluesky: _FakeBluesky(primaryFeedApi), + database: database, + accountDid: 'did:plc:test', + onUnauthorized: () async { + refreshCalls += 1; + return _testTokens(); + }, + blueskyClientFactory: (_) => _FakeBluesky(fallbackFeedApi), + ); + + final resolved = await repository.getPostThread(thread.post.uri.toString()); + + expect(refreshCalls, 1); + expect(primaryCalls, 1); + expect(fallbackCalls, 1); + expect(resolved.post.uri.toString(), thread.post.uri.toString()); + }); }); } @@ -151,3 +190,27 @@ PostView _post({required String uri, required String cid, required String text}) indexedAt: timestamp, ); } + +AuthTokens _testTokens() { + final now = DateTime.now().toUtc(); + return AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresAt: now.add(const Duration(hours: 1)), + did: 'did:plc:test', + handle: 'test.bsky.social', + service: 'bsky.social', + ); +} + +UnauthorizedException _unauthorizedException(String methodId) { + return UnauthorizedException( + XRPCResponse( + headers: const {}, + status: HttpStatus.unauthorized, + request: XRPCRequest(method: HttpMethod.get, url: Uri.https('bsky.social', '/xrpc/$methodId')), + rateLimit: RateLimit.unlimited(), + data: const XRPCError(error: 'Unauthorized', message: 'exp claim timestamp check failed'), + ), + ); +} diff --git a/test/features/messages/data/convo_repository_test.dart b/test/features/messages/data/convo_repository_test.dart index 62c0ac6..7a4326e 100644 --- a/test/features/messages/data/convo_repository_test.dart +++ b/test/features/messages/data/convo_repository_test.dart @@ -1,9 +1,13 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:atproto_core/atproto_core.dart' as atcore; +import 'package:bluesky/bluesky_chat.dart'; import 'package:bluesky/chat_bsky_convo_defs.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/messages/data/convo_repository.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockConvoRepository extends Mock implements ConvoRepository {} void main() { group('ConvoListResult', () { @@ -36,88 +40,165 @@ void main() { }); }); - group('ConvoRepository interface', () { - late MockConvoRepository mockRepo; - - setUp(() { - mockRepo = MockConvoRepository(); - }); - - test('listConvos returns ConvoListResult', () async { - final expected = ConvoListResult(convos: [_makeConvoView('c1')], cursor: 'cursor-1'); - when( - () => mockRepo.listConvos( - cursor: any(named: 'cursor'), - limit: any(named: 'limit'), - ), - ).thenAnswer((_) async => expected); - - final result = await mockRepo.listConvos(); - + group('ConvoRepository auth recovery', () { + test('listConvos retries once after unauthorized and succeeds with refreshed client', () async { + final primary = _ScriptedTransport(getReplies: [_unauthorizedReply()]); + final fallback = _ScriptedTransport( + getReplies: [ + _okReply({ + 'convos': [_convoJson('c1')], + 'cursor': 'next-cursor', + }), + ], + ); + + var recoveryCalls = 0; + var factoryCalls = 0; + final repository = ConvoRepository( + chat: primary.createChat(), + onUnauthorized: () async { + recoveryCalls += 1; + return _freshTokens(); + }, + chatClientFactory: (_) { + factoryCalls += 1; + return fallback.createChat(); + }, + ); + + final result = await repository.listConvos(); + + expect(recoveryCalls, 1); + expect(factoryCalls, 1); + expect(primary.getCalls, 1); + expect(fallback.getCalls, 1); + expect(result.cursor, 'next-cursor'); expect(result.convos.length, 1); - expect(result.cursor, 'cursor-1'); - }); - - test('getMessages returns MessageListResult', () async { - final expected = MessageListResult(messages: [], cursor: null); - when( - () => mockRepo.getMessages( - any(), - cursor: any(named: 'cursor'), - limit: any(named: 'limit'), - ), - ).thenAnswer((_) async => expected); - - final result = await mockRepo.getMessages('convo-1'); - - expect(result.messages, isEmpty); - expect(result.cursor, isNull); + expect(result.convos.first.id, 'c1'); }); - test('muteConvo returns ConvoView', () async { - final expected = _makeConvoView('c1'); - when(() => mockRepo.muteConvo(any())).thenAnswer((_) async => expected); - - final result = await mockRepo.muteConvo('c1'); - - expect(result.id, 'c1'); + test('listConvos rethrows unauthorized when recovery returns null tokens', () async { + final primary = _ScriptedTransport(getReplies: [_unauthorizedReply()]); + var recoveryCalls = 0; + final repository = ConvoRepository( + chat: primary.createChat(), + onUnauthorized: () async { + recoveryCalls += 1; + return null; + }, + ); + + await expectLater(repository.listConvos(), throwsA(isA())); + expect(recoveryCalls, 1); + expect(primary.getCalls, 1); }); - test('unmuteConvo returns ConvoView', () async { - final expected = _makeConvoView('c1'); - when(() => mockRepo.unmuteConvo(any())).thenAnswer((_) async => expected); - - final result = await mockRepo.unmuteConvo('c1'); - - expect(result.id, 'c1'); + test('updateRead retries post request after unauthorized and succeeds', () async { + final primary = _ScriptedTransport(postReplies: [_unauthorizedReply()]); + final fallback = _ScriptedTransport( + postReplies: [ + _okReply({'convo': _convoJson('c1')}), + ], + ); + + var recoveryCalls = 0; + final repository = ConvoRepository( + chat: primary.createChat(), + onUnauthorized: () async { + recoveryCalls += 1; + return _freshTokens(); + }, + chatClientFactory: (_) => fallback.createChat(), + ); + + await repository.updateRead('c1'); + + expect(recoveryCalls, 1); + expect(primary.postCalls, 1); + expect(fallback.postCalls, 1); }); + }); +} - test('updateRead completes', () async { - when(() => mockRepo.updateRead(any())).thenAnswer((_) async {}); - - await mockRepo.updateRead('c1'); +class _ScriptedTransport { + _ScriptedTransport({List<_ScriptedReply>? getReplies, List<_ScriptedReply>? postReplies}) + : _getReplies = Queue<_ScriptedReply>.from(getReplies ?? const []), + _postReplies = Queue<_ScriptedReply>.from(postReplies ?? const []); + + final Queue<_ScriptedReply> _getReplies; + final Queue<_ScriptedReply> _postReplies; + + int getCalls = 0; + int postCalls = 0; + + BlueskyChat createChat() { + return BlueskyChat.fromSession( + const atcore.Session( + did: 'did:plc:test', + handle: 'test.bsky.social', + accessJwt: 'access-token', + refreshJwt: 'refresh-token', + ), + getClient: (uri, {headers}) async { + getCalls += 1; + if (_getReplies.isEmpty) { + throw StateError('No scripted GET response queued for $uri'); + } + return _getReplies.removeFirst().toResponse(method: 'GET', url: uri); + }, + postClient: (uri, {headers, body, encoding}) async { + postCalls += 1; + if (_postReplies.isEmpty) { + throw StateError('No scripted POST response queued for $uri'); + } + return _postReplies.removeFirst().toResponse(method: 'POST', url: uri); + }, + ); + } +} - verify(() => mockRepo.updateRead('c1')).called(1); - }); +Map _convoJson(String id) { + return {'id': id, 'rev': 'rev-$id', 'members': const [], 'muted': false, 'unreadCount': 0}; +} - test('sendMessage returns MessageView', () async { - final expected = _makeMessageView('msg-1', 'Hello'); - when(() => mockRepo.sendMessage(any(), any())).thenAnswer((_) async => expected); +class _ScriptedReply { + const _ScriptedReply({required this.statusCode, required this.payload}); + + final int statusCode; + final Map payload; + + Future toResponse({required String method, required Uri url}) async { + final streamed = http.StreamedResponse( + Stream>.value(utf8.encode(jsonEncode(payload))), + statusCode, + request: http.Request(method, url), + headers: const {'content-type': 'application/json'}, + ); + return http.Response.fromStream(streamed); + } +} - final result = await mockRepo.sendMessage('c1', 'Hello'); +_ScriptedReply _okReply(Map payload) { + return _ScriptedReply(statusCode: 200, payload: payload); +} - expect(result.id, 'msg-1'); - expect(result.text, 'Hello'); - }); - }); +_ScriptedReply _unauthorizedReply() { + return const _ScriptedReply( + statusCode: 401, + payload: {'error': 'Unauthorized', 'message': 'exp claim timestamp check failed'}, + ); } ConvoView _makeConvoView(String id) => ConvoView(id: id, rev: 'rev-1', members: [], muted: false, unreadCount: 0); -MessageView _makeMessageView(String id, String text) => MessageView( - id: id, - rev: 'rev-1', - text: text, - sender: const MessageViewSender(did: 'did:plc:user'), - sentAt: DateTime.utc(2026, 3, 15), -); +AuthTokens _freshTokens() { + final now = DateTime.now().toUtc(); + return AuthTokens( + accessToken: 'fresh-access-token', + refreshToken: 'fresh-refresh-token', + expiresAt: now.add(const Duration(hours: 1)), + did: 'did:plc:test', + handle: 'test.bsky.social', + service: 'bsky.social', + ); +} diff --git a/test/shared/presentation/helpers/share_helper_test.dart b/test/shared/presentation/helpers/share_helper_test.dart new file mode 100644 index 0000000..c933c1a --- /dev/null +++ b/test/shared/presentation/helpers/share_helper_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; + +void main() { + testWidgets('sharePositionOriginForContext returns render box bounds when available', (tester) async { + final key = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox(key: key, width: 140, height: 72, child: const SizedBox.shrink()), + ), + ), + ); + + final rect = ShareHelper.sharePositionOriginForContext(key.currentContext!); + + expect(rect, isNotNull); + expect(rect.width, 140); + expect(rect.height, 72); + }); + + testWidgets('sharePositionOriginForContext falls back when render box has empty size', (tester) async { + Rect? rect; + + await tester.pumpWidget( + MaterialApp( + home: SizedBox.shrink( + child: Builder( + builder: (context) { + rect = ShareHelper.sharePositionOriginForContext(context); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + expect(rect, isNotNull); + expect(rect!.width, greaterThan(0)); + expect(rect!.height, greaterThan(0)); + }); +}