diff --git a/analysis_options.yaml b/analysis_options.yaml index 4d68d66..90f31ce 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,6 +1,8 @@ include: package:flutter_lints/flutter.yaml analyzer: + exclude: + - build/** plugins: - custom_lint errors: diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8e3c79c..45b1fb7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -292,7 +292,7 @@ SPEC CHECKSUMS: tflite_flutter: 64b192e11352fe36943ab6656e1d49207f1a5595 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + wakelock_plus: e29112ab3ef0b318e58be66b556 workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 PODFILE CHECKSUM: eacb6976ee55d09dd7976888367ed4eee5f3a1bd diff --git a/lib/app/lazurite_app.dart b/lib/app/lazurite_app.dart index 9f6dff7..71bdd48 100644 --- a/lib/app/lazurite_app.dart +++ b/lib/app/lazurite_app.dart @@ -457,6 +457,7 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { accountDid: accountDid, userDid: accountDid, appViewProviderResolver: () => settingsCubit.state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ); unawaited(moderationService.ensureInitialized()); return moderationService; @@ -489,6 +490,7 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { appViewFallbackService: widget.appViewFallbackService, routingEpoch: settingsCubit.state.routingEpoch, routingEpochResolver: () => settingsCubit.state.routingEpoch, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ); }, ), @@ -500,6 +502,7 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { providerResolver: () => settingsCubit.state.typeaheadProvider, appViewProviderResolver: () => settingsCubit.state.appViewProvider, moderationService: context.read(), + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ); }, ), @@ -508,6 +511,7 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { bluesky: bluesky, moderationService: context.read(), appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ), ), RepositoryProvider( @@ -554,18 +558,21 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { bluesky: bluesky, moderationService: context.read(), appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ), ), RepositoryProvider( create: (context) => PostActionRepository( bluesky: bluesky, appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ), ), RepositoryProvider( create: (context) => ProfileActionRepository( bluesky: bluesky, appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ), ), RepositoryProvider( @@ -575,7 +582,12 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { ), ), RepositoryProvider(create: (_) => PostActionCache()), - RepositoryProvider(create: (_) => VideoRepository(bluesky: bluesky)), + RepositoryProvider( + create: (_) => VideoRepository( + bluesky: bluesky, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), + ), + ), RepositoryProvider.value(value: bluesky), RepositoryProvider.value(value: widget.objectBoxStore), RepositoryProvider.value(value: widget.embeddingService), @@ -593,6 +605,7 @@ class _LazuriteAppState extends State with WidgetsBindingObserver { database: widget.database, semanticIndexer: context.read(), appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: () => _recoverAuthSession(trigger: 'unauthorized_response'), ), ), RepositoryProvider( diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 0da220a..7f41d77 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -338,6 +338,7 @@ class AppRouter { postClient: XrpcNetworkInterceptor.wrapPostClient(), ), constellationClient: ConstellationClient(baseUrl: constellationUrl), + onUnauthorized: onUnauthorized, ); return buildAppRoutePage( context, @@ -676,6 +677,7 @@ class AppRouter { repository: FollowAuditRepository( bluesky: context.read(), appViewProviderResolver: () => context.read().state.appViewProvider, + onUnauthorized: onUnauthorized, ), ownDid: context.read(), ), @@ -1152,6 +1154,7 @@ class AppRouter { crossProviderFallbackEnabledResolver: () => settingsCubit.state.crossProviderFallbackEnabled, routingEpoch: settingsCubit.state.routingEpoch, routingEpochResolver: () => settingsCubit.state.routingEpoch, + onUnauthorized: onUnauthorized, ); } diff --git a/lib/core/scheduler/post_scheduler.dart b/lib/core/scheduler/post_scheduler.dart index b94e56c..b2cfd85 100644 --- a/lib/core/scheduler/post_scheduler.dart +++ b/lib/core/scheduler/post_scheduler.dart @@ -15,6 +15,7 @@ import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; import 'package:lazurite/features/compose/data/draft_embed_payload.dart'; import 'package:lazurite/features/notifications/background/notification_background_worker.dart'; @@ -63,10 +64,24 @@ Future _submitScheduledDraft(int draftId) async { final database = AppDatabase(); try { final authRepo = AuthRepository(database: database); - final tokens = await authRepo.restoreSession(); + var tokens = await authRepo.restoreSession(); if (tokens == null) { throw Exception('No authenticated session for scheduled draft $draftId'); } + final accountDid = tokens.did; + + Future recoverSession() async { + final currentTokens = tokens; + if (currentTokens == null) { + return null; + } + final refreshed = await authRepo.refreshSession(currentTokens); + if (refreshed == null || refreshed.did != accountDid) { + return null; + } + tokens = refreshed; + return refreshed; + } final bluesky = createBlueskyClient(tokens); if (bluesky == null) { @@ -79,7 +94,7 @@ Future _submitScheduledDraft(int draftId) async { return; } - final composeRepo = ComposeRepository(bluesky: bluesky); + final composeRepo = ComposeRepository(bluesky: bluesky, onUnauthorized: recoverSession); final facets = []; for (final entity in BlueskyText(draft.content).entities) { @@ -121,7 +136,7 @@ Future _submitScheduledDraft(int draftId) async { facets: facets, embed: embed, reply: reply, - repo: tokens.did, + repo: accountDid, ); if (!success) { @@ -167,8 +182,8 @@ Future _buildImageEmbed(ComposeRepository repo, DraftImagesEmbe if (dims != null) { aspectRatio = embed_defs.AspectRatio(width: dims.width, height: dims.height); } - } catch (_) { - log.w('Scheduled post: could not read image dimensions for ${paths[i]}'); + } catch (error, stackTrace) { + log.w('Scheduled post: could not read image dimensions for ${paths[i]}', error: error, stackTrace: stackTrace); } images.add(EmbedImagesImage(image: blob, alt: altText, aspectRatio: aspectRatio)); diff --git a/lib/features/feed/cubit/feed_preferences_cubit.dart b/lib/features/feed/cubit/feed_preferences_cubit.dart index 183b990..28dc553 100644 --- a/lib/features/feed/cubit/feed_preferences_cubit.dart +++ b/lib/features/feed/cubit/feed_preferences_cubit.dart @@ -105,10 +105,10 @@ class FeedPreferencesCubit extends Cubit { final unpinnedFeeds = state.unpinnedFeeds; if (oldIndex < 0 || oldIndex >= pinnedFeeds.length) return; + if (newIndex < 0) return; - final adjustedNewIndex = newIndex > oldIndex ? newIndex - 1 : newIndex; final item = pinnedFeeds.removeAt(oldIndex); - pinnedFeeds.insert(adjustedNewIndex, item); + pinnedFeeds.insert(newIndex.clamp(0, pinnedFeeds.length), item); final newFeeds = [...pinnedFeeds, ...unpinnedFeeds]; await _savePreferences(newFeeds); diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index 42291bf..bd830c7 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -267,10 +267,12 @@ class FeedRepository { return _runPublicReadWithFallback( endpointId: 'app.bsky.unspecced.getTrendingTopics', request: (context, headers, {required fallbackUsed}) async { - final response = await _authRecovery.client.unspecced.getTrendingTopics( - limit: clampedLimit, - $service: context.publicServiceHost(), - $headers: headers, + final response = await _authRecovery.run( + (client) => client.unspecced.getTrendingTopics( + limit: clampedLimit, + $service: context.publicServiceHost(), + $headers: headers, + ), ); return TrendingTopicsResult(topics: response.data.topics, suggested: response.data.suggested); }, @@ -282,10 +284,9 @@ class FeedRepository { return _runPublicReadWithFallback( endpointId: 'app.bsky.unspecced.getTrends', request: (context, headers, {required fallbackUsed}) async { - final response = await _authRecovery.client.unspecced.getTrends( - limit: clampedLimit, - $service: context.publicServiceHost(), - $headers: headers, + final response = await _authRecovery.run( + (client) => + client.unspecced.getTrends(limit: clampedLimit, $service: context.publicServiceHost(), $headers: headers), ); return response.data.trends; }, diff --git a/lib/features/feed/data/liked_posts_repository.dart b/lib/features/feed/data/liked_posts_repository.dart index 5096962..93a8e8b 100644 --- a/lib/features/feed/data/liked_posts_repository.dart +++ b/lib/features/feed/data/liked_posts_repository.dart @@ -4,6 +4,9 @@ import 'package:lazurite/core/cache/poptart_cache_codecs.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; import 'package:lazurite/core/network/poptart_client_adapter.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/search/data/semantic_indexer.dart'; class LikedPostsRepository { @@ -13,15 +16,22 @@ class LikedPostsRepository { SemanticIndexer? semanticIndexer, String? appViewProvider, String Function()? appViewProviderResolver, - }) : _bluesky = bluesky, - _database = database, + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _database = database, _semanticIndexer = semanticIndexer, _appViewContext = AppViewRequestContext( appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, - ); + ) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); + } - final Bluesky _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final AppDatabase _database; final SemanticIndexer? _semanticIndexer; final AppViewRequestContext _appViewContext; @@ -38,11 +48,13 @@ class LikedPostsRepository { var scanned = 0; while (scanned < _maxLikes) { - final response = await _bluesky.feed.getActorLikes( - actor: accountDid, - limit: _pageSize, - cursor: cursor, - $headers: _appViewContext.appBskyHeadersWithoutProxy(), + final response = await _authRecovery.run( + (client) => client.feed.getActorLikes( + actor: accountDid, + limit: _pageSize, + cursor: cursor, + $headers: _appViewContext.appBskyHeadersWithoutProxy(), + ), ); final data = response.data; diff --git a/lib/features/feed/data/post_action_repository.dart b/lib/features/feed/data/post_action_repository.dart index 2afe162..3d7a330 100644 --- a/lib/features/feed/data/post_action_repository.dart +++ b/lib/features/feed/data/post_action_repository.dart @@ -5,23 +5,38 @@ import 'package:bluesky_poptart/app/bsky/feed/get_quotes.dart'; import 'package:bluesky_poptart/app/bsky/feed/get_reposted_by.dart'; import 'package:lazurite/core/network/poptart_client_adapter.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'; class PostActionRepository { - PostActionRepository({required Bluesky bluesky, String? appViewProvider, String Function()? appViewProviderResolver}) - : _bluesky = bluesky, - _appViewContext = AppViewRequestContext( - appViewProvider: appViewProvider, - appViewProviderResolver: appViewProviderResolver, - ); - - final Bluesky _bluesky; + PostActionRepository({ + required Bluesky bluesky, + String? appViewProvider, + String Function()? appViewProviderResolver, + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _appViewContext = AppViewRequestContext( + appViewProvider: appViewProvider, + appViewProviderResolver: appViewProviderResolver, + ) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); + } + + late final UnauthorizedRecoveryRunner _authRecovery; final AppViewRequestContext _appViewContext; Future likePost({required AtUri uri, required String cid}) async { - final response = await _bluesky.feed.like.create( - subject: RepoStrongRef(cid: cid, uri: uri), - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(), + final response = await _authRecovery.run( + (client) => client.feed.like.create( + subject: RepoStrongRef(cid: cid, uri: uri), + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(), + ), ); return response.data.uri.toString(); @@ -29,14 +44,18 @@ class PostActionRepository { Future unlikePost({required String likeUri}) async { final rkey = _extractRkey(likeUri); - await _bluesky.feed.like.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); + await _authRecovery.run( + (client) => client.feed.like.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()), + ); } Future repostPost({required AtUri uri, required String cid}) async { - final response = await _bluesky.feed.repost.create( - subject: RepoStrongRef(cid: cid, uri: uri), - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(), + final response = await _authRecovery.run( + (client) => client.feed.repost.create( + subject: RepoStrongRef(cid: cid, uri: uri), + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(), + ), ); return response.data.uri.toString(); @@ -44,57 +63,74 @@ class PostActionRepository { Future unrepostPost({required String repostUri}) async { final rkey = _extractRkey(repostUri); - await _bluesky.feed.repost.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); + await _authRecovery.run( + (client) => client.feed.repost.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()), + ); } Future deletePost({required String postUri}) async { final rkey = _extractRkey(postUri); - await _bluesky.feed.post.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); + await _authRecovery.run( + (client) => client.feed.post.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()), + ); } Future createBookmark({required AtUri uri, required String cid}) async { - await _bluesky.bookmark.createBookmark(uri: uri, cid: cid, $headers: _appViewContext.appBskyHeadersWithoutProxy()); + await _authRecovery.run( + (client) => + client.bookmark.createBookmark(uri: uri, cid: cid, $headers: _appViewContext.appBskyHeadersWithoutProxy()), + ); } Future deleteBookmark({required AtUri uri}) async { - await _bluesky.bookmark.deleteBookmark(uri: uri, $headers: _appViewContext.appBskyHeadersWithoutProxy()); + await _authRecovery.run( + (client) => client.bookmark.deleteBookmark(uri: uri, $headers: _appViewContext.appBskyHeadersWithoutProxy()), + ); } Future getBookmarks({int? limit, String? cursor}) async { - final response = await _bluesky.bookmark.getBookmarks( - limit: limit ?? 50, - cursor: cursor, - $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.bookmark.getBookmarks'), + final response = await _authRecovery.run( + (client) => client.bookmark.getBookmarks( + limit: limit ?? 50, + cursor: cursor, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.bookmark.getBookmarks'), + ), ); return response.data; } Future getLikes({required AtUri uri, String? cursor}) async { - final response = await _bluesky.feed.getLikes( - uri: uri, - limit: 25, - cursor: cursor, - $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getLikes'), + final response = await _authRecovery.run( + (client) => client.feed.getLikes( + uri: uri, + limit: 25, + cursor: cursor, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getLikes'), + ), ); return response.data; } Future getRepostedBy({required AtUri uri, String? cursor}) async { - final response = await _bluesky.feed.getRepostedBy( - uri: uri, - limit: 25, - cursor: cursor, - $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getRepostedBy'), + final response = await _authRecovery.run( + (client) => client.feed.getRepostedBy( + uri: uri, + limit: 25, + cursor: cursor, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getRepostedBy'), + ), ); return response.data; } Future getQuotes({required AtUri uri, String? cursor}) async { - final response = await _bluesky.feed.getQuotes( - uri: uri, - limit: 25, - cursor: cursor, - $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getQuotes'), + final response = await _authRecovery.run( + (client) => client.feed.getQuotes( + uri: uri, + limit: 25, + cursor: cursor, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getQuotes'), + ), ); return response.data; } diff --git a/lib/features/feed/presentation/feed_management_screen.dart b/lib/features/feed/presentation/feed_management_screen.dart index abf0731..71fbe10 100644 --- a/lib/features/feed/presentation/feed_management_screen.dart +++ b/lib/features/feed/presentation/feed_management_screen.dart @@ -117,7 +117,7 @@ class _FeedManagementScreenState extends State { physics: const NeverScrollableScrollPhysics(), buildDefaultDragHandles: false, itemCount: pinnedFeeds.length, - onReorder: (oldIndex, newIndex) { + onReorderItem: (oldIndex, newIndex) { context.read().reorderPinnedFeeds(oldIndex, newIndex); }, itemBuilder: (context, index) { diff --git a/lib/features/lists/data/list_repository.dart b/lib/features/lists/data/list_repository.dart index c42e20f..2f15945 100644 --- a/lib/features/lists/data/list_repository.dart +++ b/lib/features/lists/data/list_repository.dart @@ -8,6 +8,9 @@ import 'package:bluesky_poptart/app/bsky/graph/get_lists_with_membership.dart'; import 'package:bluesky_poptart/app/bsky/graph/list.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; import 'package:lazurite/core/network/poptart_client_adapter.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'; class ListRepository { @@ -16,14 +19,21 @@ class ListRepository { ModerationService? moderationService, String? appViewProvider, String Function()? appViewProviderResolver, - }) : _bluesky = bluesky, - _moderationService = moderationService, + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _moderationService = moderationService, _appViewContext = AppViewRequestContext( appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, - ); + ) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); + } - final Bluesky _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final ModerationService? _moderationService; final AppViewRequestContext _appViewContext; @@ -33,14 +43,14 @@ class ListRepository { int limit = 50, bool includeReference = false, }) async { - final response = await _bluesky.graph.getLists( - actor: actor, - cursor: cursor, - limit: limit, - purposes: includeReference ? null : _listPurposes, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.graph.getLists', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.graph.getLists( + actor: actor, + cursor: cursor, + limit: limit, + purposes: includeReference ? null : _listPurposes, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.graph.getLists', headers), ), ); @@ -48,13 +58,13 @@ class ListRepository { } Future getList({required AtUri listUri, String? cursor, int limit = 50}) async { - final response = await _bluesky.graph.getList( - list: listUri, - cursor: cursor, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.graph.getList', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.graph.getList( + list: listUri, + cursor: cursor, + limit: limit, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.graph.getList', headers), ), ); @@ -66,13 +76,13 @@ class ListRepository { } Future getListFeed({required AtUri listUri, String? cursor, int limit = 50}) async { - final response = await _bluesky.feed.getListFeed( - list: listUri, - cursor: cursor, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.getListFeed', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.feed.getListFeed( + list: listUri, + cursor: cursor, + limit: limit, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getListFeed', headers), ), ); @@ -84,14 +94,14 @@ class ListRepository { String? cursor, int limit = 50, }) async { - final response = await _bluesky.graph.getListsWithMembership( - actor: actor, - cursor: cursor, - limit: limit, - purposes: _membershipPurposes, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.graph.getListsWithMembership', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.graph.getListsWithMembership( + actor: actor, + cursor: cursor, + limit: limit, + purposes: _membershipPurposes, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.graph.getListsWithMembership', headers), ), ); @@ -102,12 +112,12 @@ class ListRepository { } Future> searchActorsTypeahead({required String query, int limit = 10}) async { - final response = await _bluesky.actor.searchActorsTypeahead( - q: query, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.actor.searchActorsTypeahead', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.actor.searchActorsTypeahead( + q: query, + limit: limit, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.searchActorsTypeahead', headers), ), ); @@ -115,58 +125,72 @@ class ListRepository { } Future addListItem({required AtUri listUri, required String subjectDid}) async { - final response = await _bluesky.graph.listitem.create( - list: listUri, - subject: subjectDid, - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.graph.listitem.create( + list: listUri, + subject: subjectDid, + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); return response.data.uri.toString(); } Future removeListItem({required AtUri listItemUri}) async { - await _bluesky.graph.listitem.delete( - rkey: listItemUri.rkey, - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + await _authRecovery.run( + (client) => client.graph.listitem.delete( + rkey: listItemUri.rkey, + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); } Future muteList({required AtUri listUri}) async { - await _bluesky.graph.muteActorList( - list: listUri, - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + await _authRecovery.run( + (client) => + client.graph.muteActorList(list: listUri, $headers: _appViewContext.appBskyHeadersWithoutProxy(headers)), ); } Future unmuteList({required AtUri listUri}) async { - await _bluesky.graph.unmuteActorList( - list: listUri, - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + await _authRecovery.run( + (client) => + client.graph.unmuteActorList(list: listUri, $headers: _appViewContext.appBskyHeadersWithoutProxy(headers)), ); } Future blockList({required AtUri listUri}) async { - final response = await _bluesky.graph.listblock.create( - subject: listUri, - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.graph.listblock.create( + subject: listUri, + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); return response.data.uri.toString(); } Future unblockList({required AtUri blockUri}) async { - await _bluesky.graph.listblock.delete( - rkey: blockUri.rkey, - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + await _authRecovery.run( + (client) => client.graph.listblock.delete( + rkey: blockUri.rkey, + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); } Future uploadListAvatar({required List bytes, String mimeType = 'image/jpeg'}) async { - final response = await _bluesky.atproto.repo.uploadBlob( - bytes: Uint8List.fromList(bytes), - $headers: {'Content-Type': mimeType}, + final response = await _authRecovery.run( + (client) => + client.atproto.repo.uploadBlob(bytes: Uint8List.fromList(bytes), $headers: {'Content-Type': mimeType}), ); return response.data.blob; } @@ -186,10 +210,9 @@ class ListRepository { createdAt: DateTime.now().toUtc(), ); - final response = await _bluesky.atproto.repo.createRecord( - repo: userDid, - collection: 'app.bsky.graph.list', - record: record.toJson(), + final response = await _authRecovery.run( + (client) => + client.atproto.repo.createRecord(repo: userDid, collection: 'app.bsky.graph.list', record: record.toJson()), ); return response.data.uri; } @@ -210,16 +233,21 @@ class ListRepository { createdAt: DateTime.now().toUtc(), ); - await _bluesky.atproto.repo.putRecord( - repo: userDid, - collection: 'app.bsky.graph.list', - rkey: listUri.rkey, - record: record.toJson(), + await _authRecovery.run( + (client) => client.atproto.repo.putRecord( + repo: userDid, + collection: 'app.bsky.graph.list', + rkey: listUri.rkey, + record: record.toJson(), + ), ); } Future deleteList({required AtUri listUri, required String userDid}) async { - await _bluesky.atproto.repo.deleteRecord(repo: userDid, collection: 'app.bsky.graph.list', rkey: listUri.rkey); + await _authRecovery.run( + (client) => + client.atproto.repo.deleteRecord(repo: userDid, collection: 'app.bsky.graph.list', rkey: listUri.rkey), + ); } List _filterLists(List lists) { diff --git a/lib/features/moderation/data/moderation_service.dart b/lib/features/moderation/data/moderation_service.dart index eaadb96..3100ea2 100644 --- a/lib/features/moderation/data/moderation_service.dart +++ b/lib/features/moderation/data/moderation_service.dart @@ -10,6 +10,9 @@ 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/poptart_client_adapter.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/domain/moderation_models.dart' as moderation; import 'package:poptart_lex/com/atproto/label/defs.dart'; @@ -25,8 +28,9 @@ class ModerationService { String? userDid, String? appViewProvider, String Function()? appViewProviderResolver, - }) : _bluesky = bluesky, - _database = database, + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _database = database, _accountDid = accountDid, _userDid = userDid, _publicReadOnly = false, @@ -34,6 +38,11 @@ class ModerationService { appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, ) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); _headers = _appViewContext.appBskyHeadersForEndpoint( 'app.bsky.labeler.getServices', _buildLabelerHeaders(const []), @@ -45,8 +54,7 @@ class ModerationService { AppDatabase? database, String? appViewProvider, String Function()? appViewProviderResolver, - }) : _bluesky = bluesky, - _database = database, + }) : _database = database, _accountDid = null, _userDid = null, _publicReadOnly = true, @@ -54,13 +62,18 @@ class ModerationService { appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, ) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: null, + clientFactory: createBlueskyClient, + ); _headers = _appViewContext.appBskyHeadersForEndpoint( 'app.bsky.labeler.getServices', _buildLabelerHeaders(const []), ); } - final Bluesky _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final AppDatabase? _database; final String? _accountDid; final String? _userDid; @@ -119,11 +132,10 @@ class ModerationService { if (labelerDids.isEmpty) { return const []; } + final headers = await headersForRequest(); - final response = await _bluesky.labeler.getServices( - dids: labelerDids, - detailed: true, - $headers: await headersForRequest(), + final response = await _authRecovery.run( + (client) => client.labeler.getServices(dids: labelerDids, detailed: true, $headers: headers), ); await _cacheLabelerPolicies(response.data.views); @@ -131,10 +143,9 @@ class ModerationService { } Future getLabelerDetails(String did) async { - final response = await _bluesky.labeler.getServices( - dids: [did], - detailed: true, - $headers: await headersForRequest(), + final headers = await headersForRequest(); + final response = await _authRecovery.run( + (client) => client.labeler.getServices(dids: [did], detailed: true, $headers: headers), ); await _cacheLabelerPolicies(response.data.views); @@ -499,7 +510,7 @@ class ModerationService { final headers = _appViewContext.appBskyHeadersWithoutProxy(); try { - final prefsResponse = await _bluesky.actor.getPreferences($headers: headers); + final prefsResponse = await _authRecovery.run((client) => client.actor.getPreferences($headers: headers)); final preferences = prefsResponse.data.preferences; await _cachePreferences(preferences); return preferences; @@ -519,7 +530,7 @@ class ModerationService { final headers = _appViewContext.appBskyHeadersWithoutProxy( _buildLabelerHeaders(moderationPrefs.labelers.map((labeler) => labeler.did)), ); - await _bluesky.actor.putPreferences(preferences: preferences, $headers: headers); + await _authRecovery.run((client) => client.actor.putPreferences(preferences: preferences, $headers: headers)); await updatePreferences(preferences: preferences); } @@ -532,10 +543,9 @@ class ModerationService { }.where((did) => did.startsWith('did:')).toList(); try { - final response = await _bluesky.labeler.getServices( - dids: labelerDids, - detailed: true, - $headers: _buildHeadersForPrefs(prefs), + final response = await _authRecovery.run( + (client) => + client.labeler.getServices(dids: labelerDids, detailed: true, $headers: _buildHeadersForPrefs(prefs)), ); await _cacheLabelerPolicies(response.data.views); @@ -849,7 +859,8 @@ class ModerationService { return explicitUserDid; } - return _bluesky.oAuthSession?.sub ?? _bluesky.session?.did; + final client = _authRecovery.client; + return client.oAuthSession?.sub ?? client.session?.did; } LabelValueDefinition? _labelValueDefinitionForIdentifier(LabelerPolicies? policies, String identifier) { diff --git a/lib/features/notifications/background/notification_background_worker.dart b/lib/features/notifications/background/notification_background_worker.dart index fcfc62d..938d529 100644 --- a/lib/features/notifications/background/notification_background_worker.dart +++ b/lib/features/notifications/background/notification_background_worker.dart @@ -8,6 +8,7 @@ import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/features/auth/data/auth_repository.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/notifications/data/flutter_local_notification_adapter.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; @@ -143,11 +144,25 @@ class _BackgroundNotificationContext { final authRepository = AuthRepository(database: database); try { - final tokens = await authRepository.restoreSession(); + var tokens = await authRepository.restoreSession(); if (tokens == null) { await database.close(); return null; } + final accountDid = tokens.did; + + Future recoverSession() async { + final currentTokens = tokens; + if (currentTokens == null) { + return null; + } + final refreshed = await authRepository.refreshSession(currentTokens); + if (refreshed == null || refreshed.did != accountDid) { + return null; + } + tokens = refreshed; + return refreshed; + } final bluesky = createBlueskyClient(tokens); if (bluesky == null) { @@ -158,20 +173,25 @@ class _BackgroundNotificationContext { final moderationService = ModerationService( bluesky: bluesky, database: database, - accountDid: tokens.did, - userDid: tokens.did, + accountDid: accountDid, + userDid: accountDid, + onUnauthorized: recoverSession, ); await moderationService.ensureInitialized(); final localNotificationAdapter = FlutterLocalNotificationAdapter(); await localNotificationAdapter.initialize(onTap: (_) {}); - final notificationRepository = NotificationRepository(bluesky: bluesky, moderationService: moderationService); + final notificationRepository = NotificationRepository( + bluesky: bluesky, + moderationService: moderationService, + onUnauthorized: recoverSession, + ); final domainService = NotificationDomainService( notificationRepository: notificationRepository, database: database, - accountDid: tokens.did, + accountDid: accountDid, localNotificationAdapter: localNotificationAdapter, ); diff --git a/lib/features/profile/data/follow_audit_repository.dart b/lib/features/profile/data/follow_audit_repository.dart index 1a17aef..e726a96 100644 --- a/lib/features/profile/data/follow_audit_repository.dart +++ b/lib/features/profile/data/follow_audit_repository.dart @@ -2,11 +2,15 @@ import 'dart:async'; import 'package:bluesky_poptart/app/bsky/actor/defs.dart'; import 'package:bluesky_poptart/app/bsky/graph/follow.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore show UnauthorizedException; import 'package:poptart_lex/com/atproto/repo/apply_writes.dart'; import 'package:equatable/equatable.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; import 'package:lazurite/core/network/poptart_client_adapter.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'; enum FollowStatus { deleted, deactivated, suspended, blockedBy, blocking, mutualBlock, hidden, selfFollow } @@ -83,25 +87,39 @@ const _maxRetries = 3; const _unfollowBatchSize = 200; class FollowAuditRepository { - FollowAuditRepository({required Bluesky bluesky, String? appViewProvider, String Function()? appViewProviderResolver}) - : _bluesky = bluesky, - _appViewContext = AppViewRequestContext( - appViewProvider: appViewProvider, - appViewProviderResolver: appViewProviderResolver, - ); + FollowAuditRepository({ + required Bluesky bluesky, + String? appViewProvider, + String Function()? appViewProviderResolver, + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _appViewContext = AppViewRequestContext( + appViewProvider: appViewProvider, + appViewProviderResolver: appViewProviderResolver, + ) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); + } - final Bluesky _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final AppViewRequestContext _appViewContext; Future fetchFollowCount(String did) async { _assertCurrentSessionRepoAccess(did: did, operation: 'fetchFollowCount'); try { - final response = await _bluesky.actor.getProfile( - actor: did, - $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.getProfile'), + final response = await _authRecovery.run( + (client) => client.actor.getProfile( + actor: did, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.getProfile'), + ), ); final count = response.data.followsCount; return count is int && count >= 0 ? count : null; + } on atcore.UnauthorizedException { + rethrow; } catch (error, stackTrace) { log.w('FollowAuditRepository: failed to fetch followsCount for $did', error: error, stackTrace: stackTrace); return null; @@ -110,11 +128,13 @@ class FollowAuditRepository { Future fetchFollowPage(String did, {String? cursor, int limit = 100}) async { _assertCurrentSessionRepoAccess(did: did, operation: 'fetchFollowPage'); - final response = await _bluesky.atproto.repo.listRecords( - repo: did, - collection: 'app.bsky.graph.follow', - limit: limit.clamp(1, 100), - cursor: cursor, + final response = await _authRecovery.run( + (client) => client.atproto.repo.listRecords( + repo: did, + collection: 'app.bsky.graph.follow', + limit: limit.clamp(1, 100), + cursor: cursor, + ), ); final records = []; @@ -253,9 +273,11 @@ class FollowAuditRepository { Future> _fetchBatchWithRetry(List batch) async { for (var attempt = 0; attempt <= _maxRetries; attempt++) { try { - final response = await _bluesky.actor.getProfiles( - actors: batch, - $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.getProfiles'), + final response = await _authRecovery.run( + (client) => client.actor.getProfiles( + actors: batch, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.getProfiles'), + ), ); final result = {}; for (final profile in response.data.profiles) { @@ -265,6 +287,8 @@ class FollowAuditRepository { } } return result; + } on atcore.UnauthorizedException { + rethrow; } catch (error, stackTrace) { if (attempt >= _maxRetries || !_isRetryable(error)) { log.w( @@ -283,12 +307,16 @@ class FollowAuditRepository { Future<_SingleResult> _fetchSingleWithRetry(String did) async { for (var attempt = 0; attempt <= _maxRetries; attempt++) { try { - final response = await _bluesky.actor.getProfile( - actor: did, - $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.getProfile'), + final response = await _authRecovery.run( + (client) => client.actor.getProfile( + actor: did, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.getProfile'), + ), ); final view = _asProfileView(response.data); return _SingleResult(profile: view, status: null, failed: view == null); + } on atcore.UnauthorizedException { + rethrow; } catch (error, stackTrace) { if (attempt >= _maxRetries || !_isRetryable(error)) { final status = _classifyError(error); @@ -325,7 +353,7 @@ class FollowAuditRepository { ) .toList(); - await _bluesky.atproto.repo.applyWrites(repo: ownDid, writes: writes); + await _authRecovery.run((client) => client.atproto.repo.applyWrites(repo: ownDid, writes: writes)); deletedCount += chunk.length; } @@ -441,12 +469,12 @@ class FollowAuditRepository { } String? _currentSessionDid() { - final sessionDid = _bluesky.session?.did.trim().toLowerCase(); + final sessionDid = _authRecovery.client.session?.did.trim().toLowerCase(); if (sessionDid != null && sessionDid.isNotEmpty) { return sessionDid; } - final oauthDid = _bluesky.oAuthSession?.sub.trim().toLowerCase(); + final oauthDid = _authRecovery.client.oAuthSession?.sub.trim().toLowerCase(); if (oauthDid != null && oauthDid.isNotEmpty) { return oauthDid; } diff --git a/lib/features/profile/data/profile_action_repository.dart b/lib/features/profile/data/profile_action_repository.dart index 5e95481..0a33fd8 100644 --- a/lib/features/profile/data/profile_action_repository.dart +++ b/lib/features/profile/data/profile_action_repository.dart @@ -1,5 +1,8 @@ import 'package:lazurite/core/network/app_view_request_context.dart'; import 'package:lazurite/core/network/poptart_client_adapter.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:poptart_lex/com/atproto/admin/defs.dart'; import 'package:poptart_lex/com/atproto/moderation/create_report.dart'; import 'package:poptart_lex/com/atproto/moderation/defs.dart'; @@ -10,20 +13,29 @@ class ProfileActionRepository { required Bluesky bluesky, String? appViewProvider, String Function()? appViewProviderResolver, - }) : _bluesky = bluesky, - _appViewContext = AppViewRequestContext( + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _appViewContext = AppViewRequestContext( appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, - ); + ) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); + } - final Bluesky _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final AppViewRequestContext _appViewContext; Future followActor({required String did}) async { - final response = await _bluesky.graph.follow.create( - subject: did, - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(), + final response = await _authRecovery.run( + (client) => client.graph.follow.create( + subject: did, + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(), + ), ); return response.data.uri.toString(); @@ -31,22 +43,30 @@ class ProfileActionRepository { Future unfollowActor({required String followUri}) async { final rkey = _extractRkey(followUri); - await _bluesky.graph.follow.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); + await _authRecovery.run( + (client) => client.graph.follow.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()), + ); } Future muteActor({required String did}) async { - await _bluesky.graph.muteActor(actor: did, $headers: _appViewContext.appBskyHeadersWithoutProxy()); + await _authRecovery.run( + (client) => client.graph.muteActor(actor: did, $headers: _appViewContext.appBskyHeadersWithoutProxy()), + ); } Future unmuteActor({required String did}) async { - await _bluesky.graph.unmuteActor(actor: did, $headers: _appViewContext.appBskyHeadersWithoutProxy()); + await _authRecovery.run( + (client) => client.graph.unmuteActor(actor: did, $headers: _appViewContext.appBskyHeadersWithoutProxy()), + ); } Future blockActor({required String did}) async { - final response = await _bluesky.graph.block.create( - subject: did, - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(), + final response = await _authRecovery.run( + (client) => client.graph.block.create( + subject: did, + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(), + ), ); return response.data.uri.toString(); @@ -54,7 +74,9 @@ class ProfileActionRepository { Future unblockActor({required String blockUri}) async { final rkey = _extractRkey(blockUri); - await _bluesky.graph.block.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()); + await _authRecovery.run( + (client) => client.graph.block.delete(rkey: rkey, $headers: _appViewContext.appBskyHeadersWithoutProxy()), + ); } Future reportPost({ @@ -63,22 +85,26 @@ class ProfileActionRepository { required ReasonType reasonType, String? reason, }) async { - final response = await _bluesky.atproto.moderation.createReport( - reasonType: reasonType, - subject: UModerationCreateReportSubject.repoStrongRef( - data: RepoStrongRef(cid: cid, uri: postUri), + final response = await _authRecovery.run( + (client) => client.atproto.moderation.createReport( + reasonType: reasonType, + subject: UModerationCreateReportSubject.repoStrongRef( + data: RepoStrongRef(cid: cid, uri: postUri), + ), + reason: reason, ), - reason: reason, ); return response.data.id.toString(); } Future reportActor({required String did, required ReasonType reasonType, String? reason}) async { - final response = await _bluesky.atproto.moderation.createReport( - reasonType: reasonType, - subject: UModerationCreateReportSubject.repoRef(data: RepoRef(did: did)), - reason: reason, + final response = await _authRecovery.run( + (client) => client.atproto.moderation.createReport( + reasonType: reasonType, + subject: UModerationCreateReportSubject.repoRef(data: RepoRef(did: did)), + reason: reason, + ), ); return response.data.id.toString(); diff --git a/lib/features/profile/data/profile_context_repository.dart b/lib/features/profile/data/profile_context_repository.dart index f51ae37..3270049 100644 --- a/lib/features/profile/data/profile_context_repository.dart +++ b/lib/features/profile/data/profile_context_repository.dart @@ -6,6 +6,9 @@ import 'package:equatable/equatable.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/constellation_client.dart'; import 'package:lazurite/core/network/poptart_client_adapter.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'; const _blockedByPageSize = 16; const _listsPageSize = 16; @@ -40,11 +43,18 @@ class ProfileContextRepository { required Bluesky bluesky, Bluesky? publicBluesky, required ConstellationClient constellationClient, - }) : _bluesky = bluesky, - _publicBluesky = publicBluesky ?? bluesky, - _constellation = constellationClient; + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _publicBluesky = publicBluesky ?? bluesky, + _constellation = constellationClient { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); + } - final Bluesky _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final Bluesky _publicBluesky; final ConstellationClient _constellation; @@ -104,11 +114,9 @@ class ProfileContextRepository { String? cursor; do { - final response = await _bluesky.atproto.repo.listRecords( - repo: did, - collection: 'app.bsky.graph.block', - limit: 100, - cursor: cursor, + final response = await _authRecovery.run( + (client) => + client.atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.block', limit: 100, cursor: cursor), ); total += response.data.records.length; @@ -124,11 +132,9 @@ class ProfileContextRepository { Future<({List profiles, List unavailable, String? cursor, int total})> getBlockingProfiles(String did, {String? cursor}) async { _assertCurrentSessionRepoAccess(did: did, operation: 'getBlockingProfiles'); - final response = await _bluesky.atproto.repo.listRecords( - repo: did, - collection: 'app.bsky.graph.block', - limit: 50, - cursor: cursor, + final response = await _authRecovery.run( + (client) => + client.atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.block', limit: 50, cursor: cursor), ); final subjectDids = response.data.records.map(_blockSubjectDid).whereType().toList(growable: false); @@ -354,12 +360,12 @@ class ProfileContextRepository { } String? _currentSessionDid() { - final sessionDid = _bluesky.session?.did.trim().toLowerCase(); + final sessionDid = _authRecovery.client.session?.did.trim().toLowerCase(); if (sessionDid != null && sessionDid.isNotEmpty) { return sessionDid; } - final oauthDid = _bluesky.oAuthSession?.sub.trim().toLowerCase(); + final oauthDid = _authRecovery.client.oAuthSession?.sub.trim().toLowerCase(); if (oauthDid != null && oauthDid.isNotEmpty) { return oauthDid; } diff --git a/lib/features/search/data/search_repository.dart b/lib/features/search/data/search_repository.dart index ae2a801..4ae2f76 100644 --- a/lib/features/search/data/search_repository.dart +++ b/lib/features/search/data/search_repository.dart @@ -5,10 +5,14 @@ import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/search_posts.dart'; import 'package:bluesky_poptart/app/bsky/graph/defs.dart'; import 'package:flutter/foundation.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/poptart_client_adapter.dart'; +import 'package:lazurite/core/network/unauthorized_recovery_runner.dart'; +import 'package:lazurite/core/network/xrpc_client_factory.dart'; import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/search/data/post_search_filters.dart'; @@ -23,8 +27,9 @@ class SearchRepository { AppViewFallbackService? appViewFallbackService, int routingEpoch = 0, int Function()? routingEpochResolver, - }) : _bluesky = bluesky, - _moderationService = moderationService, + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _moderationService = moderationService, _appViewContext = AppViewRequestContext( appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, @@ -33,9 +38,15 @@ class SearchRepository { _crossProviderFallbackEnabledResolver = crossProviderFallbackEnabledResolver, _appViewFallbackService = appViewFallbackService ?? AppViewFallbackService(), _routingEpoch = routingEpoch, - _routingEpochResolver = routingEpochResolver; + _routingEpochResolver = routingEpochResolver { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); + } - final Bluesky _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final ModerationService? _moderationService; final AppViewRequestContext _appViewContext; final bool _crossProviderFallbackEnabled; @@ -63,23 +74,22 @@ class SearchRepository { final sortValue = normalized.sort == 'latest' ? const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.latest) : const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.top); - - final response = await _bluesky.feed.searchPosts( - q: normalized.query.isEmpty ? '*' : normalized.query, - sort: sortValue, - since: normalized.filters.sinceIso, - until: normalized.filters.untilIso, - mentions: normalized.filters.mentions, - author: normalized.filters.author, - lang: normalized.filters.lang, - domain: normalized.filters.domain, - url: normalized.filters.url, - tag: normalized.filters.tags.isEmpty ? null : normalized.filters.tags, - cursor: normalized.cursor, - limit: normalized.limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.searchPosts', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.feed.searchPosts( + q: normalized.query.isEmpty ? '*' : normalized.query, + sort: sortValue, + since: normalized.filters.sinceIso, + until: normalized.filters.untilIso, + mentions: normalized.filters.mentions, + author: normalized.filters.author, + lang: normalized.filters.lang, + domain: normalized.filters.domain, + url: normalized.filters.url, + tag: normalized.filters.tags.isEmpty ? null : normalized.filters.tags, + cursor: normalized.cursor, + limit: normalized.limit, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.searchPosts', headers), ), ); @@ -91,13 +101,13 @@ class SearchRepository { } Future searchActors({required String query, String? cursor, int limit = 50}) async { - final response = await _bluesky.actor.searchActors( - q: query, - cursor: cursor, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.actor.searchActors', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.actor.searchActors( + q: query, + cursor: cursor, + limit: limit, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.searchActors', headers), ), ); @@ -108,12 +118,14 @@ class SearchRepository { final response = await _runPublicReadWithFallback( endpointId: 'app.bsky.graph.searchStarterPacks', request: (context, headers, {required fallbackUsed}) { - return _bluesky.graph.searchStarterPacks( - q: query, - cursor: cursor, - limit: limit, - $service: context.publicServiceHost(), - $headers: headers, + return _authRecovery.run( + (client) => client.graph.searchStarterPacks( + q: query, + cursor: cursor, + limit: limit, + $service: context.publicServiceHost(), + $headers: headers, + ), ); }, ); @@ -125,12 +137,14 @@ class SearchRepository { final response = await _runPublicReadWithFallback( endpointId: 'app.bsky.unspecced.getPopularFeedGenerators', request: (context, headers, {required fallbackUsed}) { - return _bluesky.unspecced.getPopularFeedGenerators( - query: query, - cursor: cursor, - limit: limit, - $service: context.publicServiceHost(), - $headers: headers, + return _authRecovery.run( + (client) => client.unspecced.getPopularFeedGenerators( + query: query, + cursor: cursor, + limit: limit, + $service: context.publicServiceHost(), + $headers: headers, + ), ); }, ); @@ -139,12 +153,12 @@ class SearchRepository { } Future> searchActorsTypeahead({required String query, int limit = 10}) async { - final response = await _bluesky.actor.searchActorsTypeahead( - q: query, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.actor.searchActorsTypeahead', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.actor.searchActorsTypeahead( + q: query, + limit: limit, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.searchActorsTypeahead', headers), ), ); @@ -202,7 +216,9 @@ class SearchRepository { } try { atUris.add(AtUri.parse(value)); - } catch (_) {} + } catch (error, stackTrace) { + log.d('Skipping invalid Blacksky topic feed post URI: $value', error: error, stackTrace: stackTrace); + } } if (atUris.isEmpty) { @@ -212,13 +228,13 @@ class SearchRepository { topicName: _topicNameFromDecoded(decoded), ); } + final updatedHeaders = await _moderationService?.headersForRequest(); - final hydrated = await _bluesky.feed.getPosts( - uris: atUris, - $service: _appViewContext.publicServiceHost(), - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.getPosts', - await _moderationService?.headersForRequest(), + final hydrated = await _authRecovery.run( + (client) => client.feed.getPosts( + uris: atUris, + $service: _appViewContext.publicServiceHost(), + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getPosts', updatedHeaders), ), ); diff --git a/lib/features/settings/data/video_repository.dart b/lib/features/settings/data/video_repository.dart index 88ce0e9..4d0cafb 100644 --- a/lib/features/settings/data/video_repository.dart +++ b/lib/features/settings/data/video_repository.dart @@ -1,5 +1,9 @@ import 'package:bluesky_poptart/app/bsky/video/get_upload_limits.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/poptart_client_adapter.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'; abstract interface class VideoUploadLimitsApi { Future getUploadLimits(); @@ -33,10 +37,56 @@ final class BlueskyVideoUploadLimitsApi implements VideoUploadLimitsApi { } } +final class RecoveringVideoUploadLimitsApi implements VideoUploadLimitsApi { + RecoveringVideoUploadLimitsApi({ + required VideoUploadLimitsApi initialApi, + required Future Function()? onUnauthorized, + required VideoUploadLimitsApi? Function(AuthTokens tokens) apiFactory, + }) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: initialApi, + onUnauthorized: onUnauthorized, + clientFactory: apiFactory, + ); + } + + late final UnauthorizedRecoveryRunner _authRecovery; + + @override + Future getUploadLimits() { + return _authRecovery.run((api) => api.getUploadLimits()); + } + + @override + Future getUploadLimitsAuthToken() { + return _authRecovery.run((api) => api.getUploadLimitsAuthToken()); + } + + @override + Future getUploadLimitsWithAuthToken(String authToken) { + return _authRecovery.run((api) => api.getUploadLimitsWithAuthToken(authToken)); + } +} + class VideoRepository { - VideoRepository({Bluesky? bluesky, VideoUploadLimitsApi? api}) - : assert(bluesky != null || api != null, 'Provide either bluesky or api'), - _api = api ?? BlueskyVideoUploadLimitsApi(bluesky: bluesky!); + VideoRepository({ + Bluesky? bluesky, + VideoUploadLimitsApi? api, + Future Function()? onUnauthorized, + VideoUploadLimitsApi? Function(AuthTokens tokens)? apiFactory, + }) : assert(bluesky != null || api != null, 'Provide either bluesky or api'), + _api = onUnauthorized == null + ? api ?? BlueskyVideoUploadLimitsApi(bluesky: bluesky!) + : RecoveringVideoUploadLimitsApi( + initialApi: api ?? BlueskyVideoUploadLimitsApi(bluesky: bluesky!), + onUnauthorized: onUnauthorized, + apiFactory: + apiFactory ?? + (tokens) { + final refreshedBluesky = createBlueskyClient(tokens); + return refreshedBluesky == null ? null : BlueskyVideoUploadLimitsApi(bluesky: refreshedBluesky); + }, + ); final VideoUploadLimitsApi _api; @@ -49,7 +99,12 @@ class VideoRepository { final authToken = await _api.getUploadLimitsAuthToken(); final limits = await _api.getUploadLimitsWithAuthToken(authToken); return _mapLimits(limits); - } catch (_) { + } catch (fallbackError, fallbackStackTrace) { + log.d( + 'Video upload limits fallback failed; rethrowing original limits request error', + error: fallbackError, + stackTrace: fallbackStackTrace, + ); Error.throwWithStackTrace(error, stackTrace); } } diff --git a/lib/features/starter_packs/data/starter_pack_repository.dart b/lib/features/starter_packs/data/starter_pack_repository.dart index 8d18517..0bd462a 100644 --- a/lib/features/starter_packs/data/starter_pack_repository.dart +++ b/lib/features/starter_packs/data/starter_pack_repository.dart @@ -5,7 +5,11 @@ import 'package:bluesky_poptart/app/bsky/graph/starterpack.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; import 'package:lazurite/core/network/poptart_client_adapter.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 'package:poptart_core/poptart_core.dart' as atcore show UnauthorizedException; class StarterPackRepository { StarterPackRepository({ @@ -13,25 +17,32 @@ class StarterPackRepository { ModerationService? moderationService, String? appViewProvider, String Function()? appViewProviderResolver, - }) : _bluesky = bluesky, - _moderationService = moderationService, + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _moderationService = moderationService, _appViewContext = AppViewRequestContext( appViewProvider: appViewProvider, appViewProviderResolver: appViewProviderResolver, - ); + ) { + _authRecovery = UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); + } - final Bluesky _bluesky; + late final UnauthorizedRecoveryRunner _authRecovery; final ModerationService? _moderationService; final AppViewRequestContext _appViewContext; Future getActorStarterPacks({required String actor, String? cursor, int limit = 50}) async { - final response = await _bluesky.graph.getActorStarterPacks( - actor: actor, - cursor: cursor, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.graph.getActorStarterPacks', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.graph.getActorStarterPacks( + actor: actor, + cursor: cursor, + limit: limit, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.graph.getActorStarterPacks', headers), ), ); @@ -39,11 +50,11 @@ class StarterPackRepository { } Future getStarterPack({required AtUri starterPackUri}) async { - final response = await _bluesky.graph.getStarterPack( - starterPack: starterPackUri, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.graph.getStarterPack', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.graph.getStarterPack( + starterPack: starterPackUri, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.graph.getStarterPack', headers), ), ); @@ -51,11 +62,11 @@ class StarterPackRepository { } Future> getSuggestedFeeds({int limit = 50}) async { - final response = await _bluesky.feed.getSuggestedFeeds( - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.feed.getSuggestedFeeds', - await _moderationService?.headersForRequest(), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.feed.getSuggestedFeeds( + limit: limit, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getSuggestedFeeds', headers), ), ); return response.data.feeds; @@ -79,14 +90,17 @@ class StarterPackRepository { } final feeds = feedUris.map((uri) => FeedItem(uri: uri)).toList(); - - final response = await _bluesky.graph.starterpack.create( - name: name, - description: description, - list: refListUri, - feeds: feeds.isEmpty ? null : feeds, - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + + final response = await _authRecovery.run( + (client) => client.graph.starterpack.create( + name: name, + description: description, + list: refListUri, + feeds: feeds.isEmpty ? null : feeds, + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); return response.data.uri; @@ -100,15 +114,18 @@ class StarterPackRepository { List feedUris = const [], }) async { final feeds = feedUris.map((uri) => FeedItem(uri: uri)).toList(); + final headers = await _moderationService?.headersForRequest(); - await _bluesky.graph.starterpack.put( - rkey: packUri.rkey, - name: name, - description: description, - list: referenceListUri, - feeds: feeds.isEmpty ? null : feeds, - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + await _authRecovery.run( + (client) => client.graph.starterpack.put( + rkey: packUri.rkey, + name: name, + description: description, + list: referenceListUri, + feeds: feeds.isEmpty ? null : feeds, + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); } @@ -117,32 +134,43 @@ class StarterPackRepository { required AtUri referenceListUri, required String userDid, }) async { - await _bluesky.graph.starterpack.delete( - rkey: packUri.rkey, - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + await _authRecovery.run( + (client) => client.graph.starterpack.delete( + rkey: packUri.rkey, + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); - await _bluesky.atproto.repo.deleteRecord( - repo: userDid, - collection: 'app.bsky.graph.list', - rkey: referenceListUri.rkey, + await _authRecovery.run( + (client) => client.atproto.repo.deleteRecord( + repo: userDid, + collection: 'app.bsky.graph.list', + rkey: referenceListUri.rkey, + ), ); } Future addMember({required AtUri listUri, required String subjectDid}) async { - final response = await _bluesky.graph.listitem.create( - list: listUri, - subject: subjectDid, - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + final response = await _authRecovery.run( + (client) => client.graph.listitem.create( + list: listUri, + subject: subjectDid, + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); return response.data.uri.toString(); } Future removeMember({required AtUri listItemUri}) async { - await _bluesky.graph.listitem.delete( - rkey: listItemUri.rkey, - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + final headers = await _moderationService?.headersForRequest(); + await _authRecovery.run( + (client) => client.graph.listitem.delete( + rkey: listItemUri.rkey, + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); } @@ -152,27 +180,31 @@ class StarterPackRepository { Future followAll({required AtUri referenceListUri}) async { int count = 0; String? cursor; - + final headers = await _moderationService?.headersForRequest(); do { - final response = await _bluesky.graph.getList( - list: referenceListUri, - cursor: cursor, - limit: 100, - $headers: _appViewContext.appBskyHeadersForEndpoint( - 'app.bsky.graph.getList', - await _moderationService?.headersForRequest(), + final response = await _authRecovery.run( + (client) => client.graph.getList( + list: referenceListUri, + cursor: cursor, + limit: 100, + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.graph.getList', headers), ), ); for (final item in response.data.items as List) { try { - await _bluesky.graph.follow.create( - subject: item.subject.did as String, - createdAt: DateTime.now(), - $headers: _appViewContext.appBskyHeadersWithoutProxy(await _moderationService?.headersForRequest()), + await _authRecovery.run( + (client) => client.graph.follow.create( + subject: item.subject.did as String, + createdAt: DateTime.now(), + $headers: _appViewContext.appBskyHeadersWithoutProxy(headers), + ), ); count++; - } catch (_) { + } on atcore.UnauthorizedException { + rethrow; + } catch (error, stackTrace) { + log.d('Follow-all member follow failed', error: error, stackTrace: stackTrace); log.w('Failed to follow ${item.subject.did} (already followed or blocked)'); } } @@ -184,14 +216,16 @@ class StarterPackRepository { } Future _createReferenceList({required String userDid}) async { - final response = await _bluesky.atproto.repo.createRecord( - repo: userDid, - collection: 'app.bsky.graph.list', - record: GraphListRecord( - purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsReferencelist), - name: 'Starter Pack Members', - createdAt: DateTime.now().toUtc(), - ).toJson(), + final response = await _authRecovery.run( + (client) => client.atproto.repo.createRecord( + repo: userDid, + collection: 'app.bsky.graph.list', + record: GraphListRecord( + purpose: const ListPurpose.knownValue(data: KnownListPurpose.appBskyGraphDefsReferencelist), + name: 'Starter Pack Members', + createdAt: DateTime.now().toUtc(), + ).toJson(), + ), ); return response.data.uri; diff --git a/lib/features/typeahead/data/typeahead_repository.dart b/lib/features/typeahead/data/typeahead_repository.dart index 35ba818..df7c7b3 100644 --- a/lib/features/typeahead/data/typeahead_repository.dart +++ b/lib/features/typeahead/data/typeahead_repository.dart @@ -6,6 +6,9 @@ import 'package:http/http.dart' as http; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_request_context.dart'; import 'package:lazurite/core/network/poptart_client_adapter.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 'package:lazurite/features/typeahead/data/typeahead_result.dart'; @@ -18,8 +21,9 @@ class TypeaheadRepository { String Function()? appViewProviderResolver, ModerationService? moderationService, http.Client? httpClient, - }) : _bluesky = bluesky, - _provider = provider?.trim().toLowerCase(), + Future Function()? onUnauthorized, + Bluesky? Function(AuthTokens tokens)? blueskyClientFactory, + }) : _provider = provider?.trim().toLowerCase(), _providerResolver = providerResolver, _moderationService = moderationService, _httpClient = httpClient ?? http.Client(), @@ -34,6 +38,14 @@ class TypeaheadRepository { if (_provider != null && !_isSupportedProvider(_provider)) { throw ArgumentError.value(provider, 'provider', 'Supported providers are "bluesky" and "community".'); } + + _authRecovery = bluesky == null + ? null + : UnauthorizedRecoveryRunner( + initialClient: bluesky, + onUnauthorized: onUnauthorized, + clientFactory: blueskyClientFactory ?? createBlueskyClient, + ); } static const String blueskyProvider = 'bluesky'; @@ -43,7 +55,7 @@ class TypeaheadRepository { static const String _communityPath = '/xrpc/app.bsky.actor.searchActorsTypeahead'; static const String _searchActorsTypeaheadEndpoint = 'app.bsky.actor.searchActorsTypeahead'; - final Bluesky? _bluesky; + late final UnauthorizedRecoveryRunner? _authRecovery; final String? _provider; final String Function()? _providerResolver; final ModerationService? _moderationService; @@ -66,7 +78,7 @@ class TypeaheadRepository { try { return await _searchCommunity(query: normalizedQuery, limit: normalizedLimit); } catch (error, stackTrace) { - if (_bluesky == null) { + if (_authRecovery == null) { rethrow; } @@ -101,17 +113,17 @@ class TypeaheadRepository { } Future> _searchBluesky({required String query, required int limit}) async { - final bluesky = _bluesky; - if (bluesky == null) { + final authRecovery = _authRecovery; + if (authRecovery == null) { return _searchBlueskyPublicHttp(query: query, limit: limit); } + final headers = await _moderationService?.headersForRequest(); - final response = await bluesky.actor.searchActorsTypeahead( - q: query, - limit: limit, - $headers: _appViewContext.appBskyHeadersForEndpoint( - _searchActorsTypeaheadEndpoint, - await _moderationService?.headersForRequest(), + final response = await authRecovery.run( + (client) => client.actor.searchActorsTypeahead( + q: query, + limit: limit, + $headers: _appViewContext.appBskyHeadersForEndpoint(_searchActorsTypeaheadEndpoint, headers), ), ); diff --git a/lib/shared/utils/test_utils.dart b/lib/shared/utils/test_utils.dart index c116d10..5072333 100644 --- a/lib/shared/utils/test_utils.dart +++ b/lib/shared/utils/test_utils.dart @@ -1,5 +1,8 @@ import 'dart:convert'; +import 'package:lazurite/features/auth/data/models/auth_models.dart'; +import 'package:poptart_core/poptart_core.dart' as atcore; + String base64UrlEncode(Map value) => base64Url.encode(utf8.encode(jsonEncode(value))).replaceAll('=', ''); @@ -26,3 +29,32 @@ String buildJwt({ return '$header.$payload.signature'; } + +AuthTokens testAuthTokens({ + String accessToken = 'access-token', + String? refreshToken = 'refresh-token', + String did = 'did:plc:test', + String handle = 'test.bsky.social', + String? service = 'bsky.social', + DateTime? expiresAt, +}) => AuthTokens( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: expiresAt ?? DateTime.now().toUtc().add(const Duration(hours: 1)), + did: did, + handle: handle, + service: service, +); + +atcore.UnauthorizedException testUnauthorizedException( + String methodId, { + atcore.HttpMethod method = atcore.HttpMethod.get, +}) => atcore.UnauthorizedException( + atcore.XRPCResponse( + headers: const {}, + status: atcore.HttpStatus.unauthorized, + request: atcore.XRPCRequest(method: method, url: Uri.https('bsky.social', '/xrpc/$methodId')), + rateLimit: atcore.RateLimit.unlimited(), + data: const atcore.XRPCError(error: 'Unauthorized', message: 'exp claim timestamp check failed'), + ), +); diff --git a/pubspec.lock b/pubspec.lock index 05692e0..d172d94 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -929,10 +929,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1550,26 +1550,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.31.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.17" tflite_flutter: dependency: "direct main" description: diff --git a/test/features/feed/cubit/feed_preferences_cubit_test.dart b/test/features/feed/cubit/feed_preferences_cubit_test.dart index 97d941f..6021e90 100644 --- a/test/features/feed/cubit/feed_preferences_cubit_test.dart +++ b/test/features/feed/cubit/feed_preferences_cubit_test.dart @@ -350,7 +350,7 @@ void main() { when(() => mockFeedRepository.getPreferences()).thenAnswer((_) async => PreferencesResult(preferences: [])); when(() => mockFeedRepository.putPreferences(preferences: any(named: 'preferences'))).thenAnswer((_) async {}); }, - act: (cubit) => cubit.reorderPinnedFeeds(0, 3), + act: (cubit) => cubit.reorderPinnedFeeds(0, 2), expect: () => [ isA().having((s) => s.status, 'status', FeedPreferencesStatus.saving), predicate( diff --git a/test/features/feed/data/feed_repository_cache_test.dart b/test/features/feed/data/feed_repository_cache_test.dart index 9faaeac..ef75ece 100644 --- a/test/features/feed/data/feed_repository_cache_test.dart +++ b/test/features/feed/data/feed_repository_cache_test.dart @@ -10,8 +10,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; 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'; +import 'package:lazurite/shared/utils/test_utils.dart'; import '../../../helpers/test_bluesky_client.dart'; @@ -174,7 +174,7 @@ void main() { final primaryFeedApi = _HandlerFeedTransport( getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { primaryCalls += 1; - throw _unauthorizedException('app.bsky.feed.getTimeline'); + throw testUnauthorizedException('app.bsky.feed.getTimeline'); }, ); final fallbackFeedApi = _HandlerFeedTransport( @@ -189,7 +189,7 @@ void main() { accountDid: 'did:plc:test', onUnauthorized: () async { refreshCalls += 1; - return _testTokens(); + return testAuthTokens(); }, blueskyClientFactory: (_) => testBluesky(getClient: fallbackFeedApi.get), ); @@ -203,13 +203,62 @@ void main() { expect(result.posts.first.post.uri.toString(), _post(1).post.uri.toString()); }); + test('retries trending topics request once after unauthorized recovery', () async { + var refreshCalls = 0; + var primaryCalls = 0; + var fallbackCalls = 0; + + Future primaryGet(Uri url, {Map? headers}) async { + if (url.pathSegments.last != 'app.bsky.unspecced.getTrendingTopics') { + return unexpectedGetClient(url, headers: headers); + } + primaryCalls += 1; + return http.Response( + '{"error":"Unauthorized","message":"\\"exp\\" claim timestamp check failed"}', + 401, + request: http.Request('GET', url), + ); + } + + Future fallbackGet(Uri url, {Map? headers}) async { + if (url.pathSegments.last != 'app.bsky.unspecced.getTrendingTopics') { + return unexpectedGetClient(url, headers: headers); + } + fallbackCalls += 1; + return jsonResponse(url, 'GET', { + 'topics': [ + {r'$type': 'app.bsky.unspecced.defs#trendingTopic', 'topic': 'Dart', 'link': '/topic/dart'}, + ], + 'suggested': >[], + }); + } + + final repository = FeedRepository( + bluesky: testBluesky(getClient: primaryGet), + database: database, + accountDid: 'did:plc:test', + onUnauthorized: () async { + refreshCalls += 1; + return testAuthTokens(); + }, + blueskyClientFactory: (_) => testBluesky(getClient: fallbackGet), + ); + + final result = await repository.getTrendingTopics(); + + expect(refreshCalls, 1); + expect(primaryCalls, 1); + expect(fallbackCalls, 1); + expect(result.topics.single.topic, 'Dart'); + }); + test('rethrows unauthorized when recovery callback returns null tokens', () async { var refreshCalls = 0; var primaryCalls = 0; final primaryFeedApi = _HandlerFeedTransport( getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { primaryCalls += 1; - throw _unauthorizedException('app.bsky.feed.getTimeline'); + throw testUnauthorizedException('app.bsky.feed.getTimeline'); }, ); final repository = FeedRepository( @@ -232,7 +281,7 @@ void main() { final primaryFeedApi = _HandlerFeedTransport( getTimelineHandler: ({String? cursor, int? limit, Map? headers}) async { primaryCalls += 1; - throw _unauthorizedException('app.bsky.feed.getTimeline'); + throw testUnauthorizedException('app.bsky.feed.getTimeline'); }, ); final repository = FeedRepository( @@ -262,27 +311,3 @@ 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 77f081f..08899c3 100644 --- a/test/features/feed/data/post_thread_repository_cache_test.dart +++ b/test/features/feed/data/post_thread_repository_cache_test.dart @@ -9,8 +9,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; 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'; +import 'package:lazurite/shared/utils/test_utils.dart'; import '../../../helpers/test_bluesky_client.dart'; @@ -132,7 +132,7 @@ void main() { final primaryFeedApi = _FakeThreadFeedTransport( getPostThreadHandler: ({required uri}) async { primaryCalls += 1; - throw _unauthorizedException('app.bsky.feed.getPostThread'); + throw testUnauthorizedException('app.bsky.feed.getPostThread'); }, ); final fallbackFeedApi = _FakeThreadFeedTransport( @@ -147,7 +147,7 @@ void main() { accountDid: 'did:plc:test', onUnauthorized: () async { refreshCalls += 1; - return _testTokens(); + return testAuthTokens(); }, blueskyClientFactory: (_) => testBluesky(getClient: fallbackFeedApi.get), ); @@ -179,27 +179,3 @@ 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/presentation/convo_list_screen_test.dart b/test/features/messages/presentation/convo_list_screen_test.dart index f8f3a8c..d3a2800 100644 --- a/test/features/messages/presentation/convo_list_screen_test.dart +++ b/test/features/messages/presentation/convo_list_screen_test.dart @@ -1,6 +1,6 @@ +import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky_poptart/chat/bsky/actor/defs.dart'; import 'package:bluesky_poptart/chat/bsky/convo/defs.dart'; -import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/features/notifications/domain/push_registration_service_test.dart b/test/features/notifications/domain/push_registration_service_test.dart index 94a0769..7e9da9f 100644 --- a/test/features/notifications/domain/push_registration_service_test.dart +++ b/test/features/notifications/domain/push_registration_service_test.dart @@ -1,12 +1,12 @@ import 'dart:async'; -import 'package:poptart_core/poptart_core.dart' - show HttpMethod, HttpStatus, RateLimit, UnauthorizedException, XRPCError, XRPCRequest, XRPCResponse; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/notifications/data/notification_repository.dart'; import 'package:lazurite/features/notifications/domain/push_registration_service.dart'; import 'package:lazurite/features/notifications/domain/push_token_provider.dart'; +import 'package:lazurite/shared/utils/test_utils.dart'; +import 'package:poptart_core/poptart_core.dart' show HttpMethod; import 'package:mocktail/mocktail.dart'; class MockNotificationRepository extends Mock implements NotificationRepository {} @@ -253,7 +253,7 @@ void main() { platform: any(named: 'platform'), ageRestricted: any(named: 'ageRestricted'), ), - ).thenThrow(_unauthorizedException()); + ).thenThrow(testUnauthorizedException('app.bsky.notification.registerPush', method: HttpMethod.post)); await service.start(initialTokens: accountATokens); @@ -303,18 +303,3 @@ void main() { }); }); } - -UnauthorizedException _unauthorizedException() { - return UnauthorizedException( - XRPCResponse( - headers: const {}, - status: HttpStatus.unauthorized, - request: XRPCRequest( - method: HttpMethod.post, - url: Uri.parse('https://example.com/xrpc/app.bsky.notification.registerPush'), - ), - rateLimit: RateLimit.unlimited(), - data: const XRPCError(error: 'Unauthorized', message: '"exp" claim timestamp check failed'), - ), - ); -} diff --git a/test/features/profile/data/profile_repository_test.dart b/test/features/profile/data/profile_repository_test.dart index 2ce51e4..091915e 100644 --- a/test/features/profile/data/profile_repository_test.dart +++ b/test/features/profile/data/profile_repository_test.dart @@ -12,8 +12,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/network/poptart_client_adapter.dart' show Bluesky; -import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:lazurite/shared/utils/test_utils.dart'; import 'package:poptart_core/poptart_core.dart' as atp_core; import 'package:poptart_lex/com/atproto/repo/get_record.dart'; import 'package:poptart_lex/com/atproto/repo/put_record.dart'; @@ -162,7 +162,9 @@ void main() { test('refreshes and retries getProfile after unauthorized response', () async { final profile = _buildProfile(); - final initialActor = _FakeActorService(onGetProfile: (_) async => throw _unauthorizedException()); + final initialActor = _FakeActorService( + onGetProfile: (_) async => throw testUnauthorizedException('app.bsky.actor.getProfile'), + ); final refreshedActor = _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile)); final initialClient = _testBlueskyClient(actor: initialActor); final refreshedClient = _testBlueskyClient(actor: refreshedActor); @@ -173,11 +175,12 @@ void main() { bluesky: initialClient, onUnauthorized: () async { recoveryCalls += 1; - return const AuthTokens( + return testAuthTokens( accessToken: 'fresh-access', refreshToken: 'fresh-refresh', did: 'did:plc:alice', handle: 'alice.bsky.social', + service: null, ); }, blueskyClientFactory: (_) => refreshedClient, @@ -795,18 +798,3 @@ class _FakeFollowersData { final List followers; final String? cursor; } - -atp_core.UnauthorizedException _unauthorizedException() { - return atp_core.UnauthorizedException( - atp_core.XRPCResponse( - headers: const {}, - status: atp_core.HttpStatus.unauthorized, - request: atp_core.XRPCRequest( - method: atp_core.HttpMethod.get, - url: Uri.parse('https://example.com/xrpc/app.bsky.actor.getProfile'), - ), - rateLimit: atp_core.RateLimit.unlimited(), - data: const atp_core.XRPCError(error: 'Unauthorized', message: '"exp" claim timestamp check failed'), - ), - ); -} diff --git a/test/features/search/data/search_repository_post_filters_test.dart b/test/features/search/data/search_repository_post_filters_test.dart index 9e2d653..76104d6 100644 --- a/test/features/search/data/search_repository_post_filters_test.dart +++ b/test/features/search/data/search_repository_post_filters_test.dart @@ -4,6 +4,7 @@ import 'package:bluesky_poptart/app/bsky/feed/defs.dart'; import 'package:bluesky_poptart/app/bsky/feed/search_posts.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/search/data/post_search_filters.dart'; import 'package:lazurite/features/search/data/search_repository.dart'; @@ -120,6 +121,43 @@ void main() { expect(feed.lastAuthor, 'did:plc:author'); }); + test('refreshes and retries search after unauthorized response', () async { + var initialRequests = 0; + var recoveryCalls = 0; + final refreshedFeed = _FakeFeedService(); + final initialClient = testBluesky( + getClient: (url, {headers}) async { + initialRequests += 1; + return http.Response( + '{"error":"Unauthorized","message":"\\"exp\\" claim timestamp check failed"}', + 401, + request: http.Request('GET', url), + ); + }, + ); + final refreshedClient = testBluesky(getClient: refreshedFeed.get); + final recoveringRepository = SearchRepository( + bluesky: initialClient, + onUnauthorized: () async { + recoveryCalls += 1; + return const AuthTokens( + accessToken: 'fresh-access', + refreshToken: 'fresh-refresh', + did: 'did:plc:test', + handle: 'test.bsky.social', + ); + }, + blueskyClientFactory: (_) => refreshedClient, + ); + + final result = await recoveringRepository.searchPosts(query: 'flutter'); + + expect(result.posts, hasLength(1)); + expect(initialRequests, 1); + expect(recoveryCalls, 1); + expect(refreshedFeed.lastQ, 'flutter'); + }); + test('throws validation exception when query and filters are empty', () { expect( () => repository.searchPosts(query: ' ', filters: const PostSearchFilters()), diff --git a/test/features/settings/data/video_repository_test.dart b/test/features/settings/data/video_repository_test.dart index dd1f6c8..2231271 100644 --- a/test/features/settings/data/video_repository_test.dart +++ b/test/features/settings/data/video_repository_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:bluesky_poptart/app/bsky/video/get_upload_limits.dart'; import 'package:lazurite/features/settings/data/video_repository.dart'; +import 'package:lazurite/shared/utils/test_utils.dart'; class FakeVideoUploadLimitsApi implements VideoUploadLimitsApi { FakeVideoUploadLimitsApi({ @@ -83,6 +84,40 @@ void main() { expect(result.error, 'DAILY_LIMIT_EXCEEDED'); }); + test('refreshes and retries direct limits request after unauthorized response', () async { + var initialCalls = 0; + var recoveryCalls = 0; + var refreshedCalls = 0; + final initialApi = FakeVideoUploadLimitsApi( + getUploadLimitsHandler: () async { + initialCalls += 1; + throw testUnauthorizedException('app.bsky.video.getUploadLimits'); + }, + ); + final refreshedApi = FakeVideoUploadLimitsApi( + getUploadLimitsHandler: () async { + refreshedCalls += 1; + return const VideoGetUploadLimitsOutput(canUpload: true, remainingDailyVideos: 8, remainingDailyBytes: 1000); + }, + ); + final repository = VideoRepository( + api: initialApi, + onUnauthorized: () async { + recoveryCalls += 1; + return testAuthTokens(accessToken: 'fresh-access', refreshToken: 'fresh-refresh', service: null); + }, + apiFactory: (_) => refreshedApi, + ); + + final result = await repository.getUploadLimits(); + + expect(result.canUpload, isTrue); + expect(result.remainingDailyVideos, 8); + expect(initialCalls, 1); + expect(recoveryCalls, 1); + expect(refreshedCalls, 1); + }); + test('rethrows the original error when direct and fallback requests fail', () async { final api = FakeVideoUploadLimitsApi( getUploadLimitsHandler: () async => throw Exception('invalid token'),