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

Configure Feed

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

feat: update routing policy

+368 -99
-7
docs/tasks/routing.md
··· 65 65 - [x] Resolve OAuth entryway from account authority first (PDS `authorization_servers`), with provider/default fallbacks 66 66 - [x] Ensure provider switch rebuilds DI/blocs/services before new requests 67 67 - [x] Add routing epoch/version guard to drop stale pre-reset responses 68 - 69 - ## M9 - Hardening + Release 70 - 71 - - [ ] Run provider health probes at startup 72 - - [ ] Gate retries by capability matrix (`getTrends`, `getTrendingTopics`, etc.) 73 - - [ ] Add end-to-end regression coverage for routing + trending + fallback flows 74 - - [ ] Stage rollout behind feature flag if telemetry indicates instability
+19 -9
lib/core/logging/daily_log_file_output.dart
··· 11 11 final int retentionDays; 12 12 13 13 String? _lastCleanupDateKey; 14 + Future<void> _pendingWrites = Future<void>.value(); 14 15 15 16 @override 16 17 Future<void> init() async { 17 18 final directory = Directory(directoryPath); 18 - if (!directory.existsSync()) { 19 - directory.createSync(recursive: true); 19 + if (!await directory.exists()) { 20 + await directory.create(recursive: true); 20 21 } 21 22 22 23 await cleanupOldLogs(); ··· 32 33 unawaited(cleanupOldLogs(referenceTime: localTime)); 33 34 } 34 35 35 - final file = File(p.join(directoryPath, fileNameFor(localTime))); 36 - if (!file.parent.existsSync()) { 37 - file.parent.createSync(recursive: true); 38 - } 39 - 40 36 final separator = Platform.isWindows ? '\r\n' : '\n'; 41 37 final content = '${event.lines.join(separator)}$separator'; 42 - file.writeAsStringSync(content, mode: FileMode.writeOnlyAppend, flush: event.level.index >= Level.warning.index); 38 + final filePath = p.join(directoryPath, fileNameFor(localTime)); 39 + final shouldFlush = event.level.index >= Level.warning.index; 40 + _pendingWrites = _pendingWrites 41 + .then((_) => _appendLine(filePath: filePath, content: content, flush: shouldFlush)) 42 + .catchError((_) {}); 43 43 } 44 44 45 45 Future<void> clearAllLogs() async { ··· 77 77 } 78 78 79 79 @override 80 - Future<void> destroy() async {} 80 + Future<void> destroy() async { 81 + await _pendingWrites; 82 + } 83 + 84 + Future<void> _appendLine({required String filePath, required String content, required bool flush}) async { 85 + final file = File(filePath); 86 + if (!await file.parent.exists()) { 87 + await file.parent.create(recursive: true); 88 + } 89 + await file.writeAsString(content, mode: FileMode.writeOnlyAppend, flush: flush); 90 + } 81 91 82 92 static String fileNameFor(DateTime timestamp) { 83 93 return 'lazurite_${_dateKey(timestamp.toLocal())}.log';
+64
lib/core/network/app_bsky_routing_policy.dart
··· 17 17 /// 18 18 /// Feed/bookmark mutations: avoid explicit proxy. 19 19 static const Map<String, AppBskyProxyMode> _policyByEndpoint = { 20 + /// Explicit service-routed endpoints (non-appview service fragments) 21 + /// 22 + /// #bsky_chat 23 + 'chat.bsky.convo.listConvos': AppBskyProxyMode.useProxy, 24 + 'chat.bsky.convo.getConvoForMembers': AppBskyProxyMode.useProxy, 25 + 'chat.bsky.convo.getMessages': AppBskyProxyMode.useProxy, 26 + 'chat.bsky.convo.sendMessage': AppBskyProxyMode.useProxy, 27 + 'chat.bsky.convo.deleteMessageForSelf': AppBskyProxyMode.useProxy, 28 + 'chat.bsky.convo.muteConvo': AppBskyProxyMode.useProxy, 29 + 'chat.bsky.convo.unmuteConvo': AppBskyProxyMode.useProxy, 30 + 'chat.bsky.convo.updateRead': AppBskyProxyMode.useProxy, 31 + 32 + /// #bsky_fg (feed generator management) 33 + 'app.bsky.feed.sendInteractions': AppBskyProxyMode.useProxy, 34 + 35 + /// #bsky_notif 36 + 'app.bsky.notification.registerPush': AppBskyProxyMode.useProxy, 37 + 'app.bsky.notification.unregisterPush': AppBskyProxyMode.useProxy, 38 + 'app.bsky.notification.updateSeen': AppBskyProxyMode.useProxy, 39 + 'app.bsky.notification.listNotifications': AppBskyProxyMode.useProxy, 40 + 'app.bsky.notification.getUnreadCount': AppBskyProxyMode.useProxy, 41 + 42 + /// #atproto_labeler 43 + 'app.bsky.labeler.getServices': AppBskyProxyMode.useProxy, 44 + 45 + /// Explicit provider-sensitive endpoints. 46 + 'app.bsky.feed.getTimeline': AppBskyProxyMode.useProxy, 47 + 'app.bsky.feed.getFeed': AppBskyProxyMode.useProxy, 48 + 'app.bsky.feed.searchPosts': AppBskyProxyMode.useProxy, 49 + 'app.bsky.feed.getPostThread': AppBskyProxyMode.useProxy, 50 + 'app.bsky.feed.getAuthorFeed': AppBskyProxyMode.useProxy, 51 + 52 + /// Actor/profile endpoints (public or account-context reads). 53 + 'app.bsky.actor.getProfile': AppBskyProxyMode.bypassProxy, 54 + 'app.bsky.actor.getProfiles': AppBskyProxyMode.bypassProxy, 20 55 'app.bsky.actor.getPreferences': AppBskyProxyMode.bypassProxy, 21 56 'app.bsky.actor.putPreferences': AppBskyProxyMode.bypassProxy, 57 + 'app.bsky.actor.searchActors': AppBskyProxyMode.bypassProxy, 58 + 'app.bsky.actor.searchActorsTypeahead': AppBskyProxyMode.bypassProxy, 59 + 60 + /// Graph endpoints tied to account graph records, list management, and starter packs. 22 61 'app.bsky.graph.follow': AppBskyProxyMode.bypassProxy, 23 62 'app.bsky.graph.block': AppBskyProxyMode.bypassProxy, 24 63 'app.bsky.graph.muteActor': AppBskyProxyMode.bypassProxy, 25 64 'app.bsky.graph.unmuteActor': AppBskyProxyMode.bypassProxy, 65 + 'app.bsky.graph.getSuggestedFollowsByActor': AppBskyProxyMode.bypassProxy, 66 + 'app.bsky.graph.getLists': AppBskyProxyMode.bypassProxy, 67 + 'app.bsky.graph.getList': AppBskyProxyMode.bypassProxy, 68 + 'app.bsky.graph.getListsWithMembership': AppBskyProxyMode.bypassProxy, 69 + 'app.bsky.graph.getActorStarterPacks': AppBskyProxyMode.bypassProxy, 70 + 'app.bsky.graph.getStarterPack': AppBskyProxyMode.bypassProxy, 26 71 'app.bsky.graph.listitem': AppBskyProxyMode.bypassProxy, 27 72 'app.bsky.graph.listblock': AppBskyProxyMode.bypassProxy, 28 73 'app.bsky.graph.muteActorList': AppBskyProxyMode.bypassProxy, 29 74 'app.bsky.graph.unmuteActorList': AppBskyProxyMode.bypassProxy, 30 75 'app.bsky.graph.starterpack': AppBskyProxyMode.bypassProxy, 76 + 77 + /// Feed/bookmark endpoints with account-local semantics. 31 78 'app.bsky.feed.like': AppBskyProxyMode.bypassProxy, 32 79 'app.bsky.feed.repost': AppBskyProxyMode.bypassProxy, 33 80 'app.bsky.feed.post': AppBskyProxyMode.bypassProxy, 81 + 'app.bsky.feed.getLikes': AppBskyProxyMode.bypassProxy, 82 + 'app.bsky.feed.getRepostedBy': AppBskyProxyMode.bypassProxy, 83 + 'app.bsky.feed.getActorLikes': AppBskyProxyMode.bypassProxy, 84 + 'app.bsky.feed.getListFeed': AppBskyProxyMode.bypassProxy, 85 + 'app.bsky.feed.getPosts': AppBskyProxyMode.bypassProxy, 34 86 'app.bsky.bookmark.createBookmark': AppBskyProxyMode.bypassProxy, 35 87 'app.bsky.bookmark.deleteBookmark': AppBskyProxyMode.bypassProxy, 88 + 'app.bsky.bookmark.getBookmarks': AppBskyProxyMode.bypassProxy, 89 + 90 + /// Public unspecced endpoints should avoid proxy header attachment. 91 + 'app.bsky.unspecced.getTopicFeed': AppBskyProxyMode.bypassProxy, 92 + 'app.bsky.unspecced.getTrends': AppBskyProxyMode.bypassProxy, 93 + 'app.bsky.unspecced.getTrendingTopics': AppBskyProxyMode.bypassProxy, 94 + 'app.bsky.unspecced.getPopularFeedGenerators': AppBskyProxyMode.bypassProxy, 95 + 96 + /// Feed metadata reads that are effectively public and can tolerate default AppView routing. 97 + 'app.bsky.feed.getFeedGenerator': AppBskyProxyMode.bypassProxy, 98 + 'app.bsky.feed.getFeedGenerators': AppBskyProxyMode.bypassProxy, 99 + 'app.bsky.feed.getSuggestedFeeds': AppBskyProxyMode.bypassProxy, 36 100 }; 37 101 38 102 static AppBskyProxyMode modeForEndpoint(String endpointId) {
+1 -1
lib/core/network/app_view_fallback_service.dart
··· 66 66 } 67 67 68 68 final context = AppViewRequestContext(appViewProvider: provider); 69 - final headers = context.appBskyHeaders(baseHeaders); 69 + final headers = context.appBskyHeadersWithoutProxy(baseHeaders); 70 70 log.i( 71 71 'appview.public_read endpoint=$endpointId provider=$provider ' 72 72 'fallbackUsed=$fallbackUsed fallbackEnabled=$fallbackEnabled action=attempt',
+25 -20
lib/core/network/xrpc_client_factory.dart
··· 3 3 import 'package:atproto_oauth/atproto_oauth.dart' as atp_oauth; 4 4 import 'package:bluesky/bluesky.dart'; 5 5 import 'package:bluesky/bluesky_chat.dart'; 6 + import 'package:flutter/foundation.dart'; 6 7 import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; 7 8 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 8 9 ··· 30 31 31 32 return Bluesky.fromOAuthSession( 32 33 oauthSession, 33 - getClient: XrpcNetworkInterceptor.wrapGetClient(), 34 - postClient: XrpcNetworkInterceptor.wrapPostClient(), 34 + getClient: _debugWrappedGetClient(), 35 + postClient: _debugWrappedPostClient(), 35 36 ); 36 37 } 37 38 ··· 49 50 return Bluesky.fromSession( 50 51 session, 51 52 service: tokens.service, 52 - getClient: XrpcNetworkInterceptor.wrapGetClient(), 53 - postClient: XrpcNetworkInterceptor.wrapPostClient(), 53 + getClient: _debugWrappedGetClient(), 54 + postClient: _debugWrappedPostClient(), 54 55 ); 55 56 } 56 57 ··· 72 73 73 74 return BlueskyChat.fromOAuthSession( 74 75 oauthSession, 75 - getClient: XrpcNetworkInterceptor.wrapGetClient(), 76 - postClient: XrpcNetworkInterceptor.wrapPostClient(), 76 + getClient: _debugWrappedGetClient(), 77 + postClient: _debugWrappedPostClient(), 77 78 ); 78 79 } 79 80 ··· 89 90 return BlueskyChat.fromSession( 90 91 session, 91 92 service: tokens.service, 92 - getClient: XrpcNetworkInterceptor.wrapGetClient(), 93 - postClient: XrpcNetworkInterceptor.wrapPostClient(), 93 + getClient: _debugWrappedGetClient(), 94 + postClient: _debugWrappedPostClient(), 94 95 ); 95 96 } 96 97 97 - atp.ATProto createAtProtoForOAuthSession(atp_oauth.OAuthSession session) { 98 - return atp.ATProto.fromOAuthSession( 99 - session, 100 - getClient: XrpcNetworkInterceptor.wrapGetClient(), 101 - postClient: XrpcNetworkInterceptor.wrapPostClient(), 102 - ); 98 + atp.ATProto createAtProtoForOAuthSession(atp_oauth.OAuthSession session) => 99 + atp.ATProto.fromOAuthSession(session, getClient: _debugWrappedGetClient(), postClient: _debugWrappedPostClient()); 100 + 101 + Bluesky createBlueskyForOAuthSession(atp_oauth.OAuthSession session) => 102 + Bluesky.fromOAuthSession(session, getClient: _debugWrappedGetClient(), postClient: _debugWrappedPostClient()); 103 + 104 + atp_core.GetClient? _debugWrappedGetClient() { 105 + if (!kDebugMode) { 106 + return null; 107 + } 108 + return XrpcNetworkInterceptor.wrapGetClient(); 103 109 } 104 110 105 - Bluesky createBlueskyForOAuthSession(atp_oauth.OAuthSession session) { 106 - return Bluesky.fromOAuthSession( 107 - session, 108 - getClient: XrpcNetworkInterceptor.wrapGetClient(), 109 - postClient: XrpcNetworkInterceptor.wrapPostClient(), 110 - ); 111 + atp_core.PostClient? _debugWrappedPostClient() { 112 + if (!kDebugMode) { 113 + return null; 114 + } 115 + return XrpcNetworkInterceptor.wrapPostClient(); 111 116 }
+28 -7
lib/features/feed/data/feed_repository.dart
··· 71 71 int limit = 50, 72 72 }) async { 73 73 final bskyFilter = filter.bskyFilter; 74 - final headers = _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()); 74 + final headers = _appViewContext.appBskyHeadersForEndpoint( 75 + 'app.bsky.feed.getAuthorFeed', 76 + await _moderationService?.headersForRequest(), 77 + ); 75 78 76 79 final response = await _bluesky.feed.getAuthorFeed( 77 80 actor: actor, ··· 88 91 final response = await _bluesky.feed.getTimeline( 89 92 cursor: cursor, 90 93 limit: limit, 91 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 94 + $headers: _appViewContext.appBskyHeadersForEndpoint( 95 + 'app.bsky.feed.getTimeline', 96 + await _moderationService?.headersForRequest(), 97 + ), 92 98 ); 93 99 94 100 final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); ··· 101 107 feed: feedUri, 102 108 cursor: cursor, 103 109 limit: limit, 104 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 110 + $headers: _appViewContext.appBskyHeadersForEndpoint( 111 + 'app.bsky.feed.getFeed', 112 + await _moderationService?.headersForRequest(), 113 + ), 105 114 ); 106 115 107 116 final result = FeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); ··· 139 148 final response = await _bluesky.feed.getSuggestedFeeds( 140 149 cursor: cursor, 141 150 limit: limit, 142 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 151 + $headers: _appViewContext.appBskyHeadersForEndpoint( 152 + 'app.bsky.feed.getSuggestedFeeds', 153 + await _moderationService?.headersForRequest(), 154 + ), 143 155 ); 144 156 return response.data.feeds; 145 157 } ··· 157 169 158 170 final response = await _bluesky.actor.getProfile( 159 171 actor: normalizedActor, 160 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 172 + $headers: _appViewContext.appBskyHeadersForEndpoint( 173 + 'app.bsky.actor.getProfile', 174 + await _moderationService?.headersForRequest(), 175 + ), 161 176 ); 162 177 final did = response.data.did.trim(); 163 178 if (did.isEmpty) { ··· 266 281 Future<GeneratorView> getFeedGenerator(AtUri feedUri) async { 267 282 final response = await _bluesky.feed.getFeedGenerator( 268 283 feed: feedUri, 269 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 284 + $headers: _appViewContext.appBskyHeadersForEndpoint( 285 + 'app.bsky.feed.getFeedGenerator', 286 + await _moderationService?.headersForRequest(), 287 + ), 270 288 ); 271 289 return response.data.view; 272 290 } ··· 275 293 if (feedUris.isEmpty) return []; 276 294 final response = await _bluesky.feed.getFeedGenerators( 277 295 feeds: feedUris, 278 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 296 + $headers: _appViewContext.appBskyHeadersForEndpoint( 297 + 'app.bsky.feed.getFeedGenerators', 298 + await _moderationService?.headersForRequest(), 299 + ), 279 300 ); 280 301 return response.data.feeds; 281 302 }
+1 -1
lib/features/feed/data/liked_posts_repository.dart
··· 44 44 actor: accountDid, 45 45 limit: _pageSize, 46 46 cursor: cursor, 47 - $headers: _appViewContext.appBskyHeaders(), 47 + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getActorLikes'), 48 48 ); 49 49 50 50 final data = response.data;
+3 -3
lib/features/feed/data/post_action_repository.dart
··· 64 64 final response = await _bluesky.bookmark.getBookmarks( 65 65 limit: limit, 66 66 cursor: cursor, 67 - $headers: _appViewContext.appBskyHeaders(), 67 + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.bookmark.getBookmarks'), 68 68 ); 69 69 return response.data; 70 70 } ··· 74 74 uri: uri, 75 75 limit: 25, 76 76 cursor: cursor, 77 - $headers: _appViewContext.appBskyHeaders(), 77 + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getLikes'), 78 78 ); 79 79 return response.data; 80 80 } ··· 84 84 uri: uri, 85 85 limit: 25, 86 86 cursor: cursor, 87 - $headers: _appViewContext.appBskyHeaders(), 87 + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getRepostedBy'), 88 88 ); 89 89 return response.data; 90 90 }
+4 -1
lib/features/feed/data/post_thread_repository.dart
··· 25 25 Future<ThreadViewPost> getPostThread(String uri) async { 26 26 final response = await _bluesky.feed.getPostThread( 27 27 uri: AtUri.parse(uri), 28 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 28 + $headers: _appViewContext.appBskyHeadersForEndpoint( 29 + 'app.bsky.feed.getPostThread', 30 + await _moderationService?.headersForRequest(), 31 + ), 29 32 ); 30 33 final thread = response.data.thread; 31 34
+20 -5
lib/features/lists/data/list_repository.dart
··· 37 37 cursor: cursor, 38 38 limit: limit, 39 39 purposes: includeReference ? null : _listPurposes, 40 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 40 + $headers: _appViewContext.appBskyHeadersForEndpoint( 41 + 'app.bsky.graph.getLists', 42 + await _moderationService?.headersForRequest(), 43 + ), 41 44 ); 42 45 43 46 return ListsResult(lists: _filterLists(response.data.lists), cursor: response.data.cursor); ··· 48 51 list: listUri, 49 52 cursor: cursor, 50 53 limit: limit, 51 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 54 + $headers: _appViewContext.appBskyHeadersForEndpoint( 55 + 'app.bsky.graph.getList', 56 + await _moderationService?.headersForRequest(), 57 + ), 52 58 ); 53 59 54 60 return ListDetailResult( ··· 63 69 list: listUri, 64 70 cursor: cursor, 65 71 limit: limit, 66 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 72 + $headers: _appViewContext.appBskyHeadersForEndpoint( 73 + 'app.bsky.feed.getListFeed', 74 + await _moderationService?.headersForRequest(), 75 + ), 67 76 ); 68 77 69 78 return ListFeedResult(posts: _filterFeedPosts(response.data.feed), cursor: response.data.cursor); ··· 79 88 cursor: cursor, 80 89 limit: limit, 81 90 purposes: _membershipPurposes, 82 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 91 + $headers: _appViewContext.appBskyHeadersForEndpoint( 92 + 'app.bsky.graph.getListsWithMembership', 93 + await _moderationService?.headersForRequest(), 94 + ), 83 95 ); 84 96 85 97 return ListsWithMembershipResult( ··· 92 104 final response = await _bluesky.actor.searchActorsTypeahead( 93 105 q: query, 94 106 limit: limit, 95 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 107 + $headers: _appViewContext.appBskyHeadersForEndpoint( 108 + 'app.bsky.actor.searchActorsTypeahead', 109 + await _moderationService?.headersForRequest(), 110 + ), 96 111 ); 97 112 98 113 return _filterProfiles(response.data.actors);
+8 -2
lib/features/moderation/data/moderation_service.dart
··· 33 33 appViewProvider: appViewProvider, 34 34 appViewProviderResolver: appViewProviderResolver, 35 35 ) { 36 - _headers = _appViewContext.appBskyHeaders(_buildLabelerHeaders(const [])); 36 + _headers = _appViewContext.appBskyHeadersForEndpoint( 37 + 'app.bsky.labeler.getServices', 38 + _buildLabelerHeaders(const []), 39 + ); 37 40 } 38 41 39 42 final dynamic _bluesky; ··· 605 608 } 606 609 607 610 Map<String, String> _buildHeadersForPrefs(bsky_moderation.ModerationPrefs prefs) { 608 - return _appViewContext.appBskyHeaders(_buildLabelerHeaders(prefs.labelers.map((labeler) => labeler.did))); 611 + return _appViewContext.appBskyHeadersForEndpoint( 612 + 'app.bsky.labeler.getServices', 613 + _buildLabelerHeaders(prefs.labelers.map((labeler) => labeler.did)), 614 + ); 609 615 } 610 616 611 617 String? get _preferencesCacheKey {
+12 -3
lib/features/notifications/data/notification_repository.dart
··· 24 24 final response = await _bluesky.notification.listNotifications( 25 25 cursor: cursor, 26 26 limit: limit, 27 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 27 + $headers: _appViewContext.appBskyHeadersForEndpoint( 28 + 'app.bsky.notification.listNotifications', 29 + await _moderationService?.headersForRequest(), 30 + ), 28 31 ); 29 32 30 33 return NotificationListResult( ··· 36 39 37 40 Future<int> getUnreadCount() async { 38 41 final response = await _bluesky.notification.getUnreadCount( 39 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 42 + $headers: _appViewContext.appBskyHeadersForEndpoint( 43 + 'app.bsky.notification.getUnreadCount', 44 + await _moderationService?.headersForRequest(), 45 + ), 40 46 ); 41 47 return response.data.count; 42 48 } ··· 44 50 Future<void> updateSeen() async { 45 51 await _bluesky.notification.updateSeen( 46 52 seenAt: DateTime.now(), 47 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 53 + $headers: _appViewContext.appBskyHeadersForEndpoint( 54 + 'app.bsky.notification.updateSeen', 55 + await _moderationService?.headersForRequest(), 56 + ), 48 57 ); 49 58 } 50 59
+8 -2
lib/features/profile/data/follow_audit_repository.dart
··· 173 173 Future<Map<String, ProfileView>> _fetchBatchWithRetry(List<String> batch) async { 174 174 for (var attempt = 0; attempt <= _maxRetries; attempt++) { 175 175 try { 176 - final response = await _bluesky.actor.getProfiles(actors: batch, $headers: _appViewContext.appBskyHeaders()); 176 + final response = await _bluesky.actor.getProfiles( 177 + actors: batch, 178 + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.getProfiles'), 179 + ); 177 180 final result = <String, ProfileView>{}; 178 181 for (final profile in response.data.profiles as List<dynamic>) { 179 182 final view = _asProfileView(profile); ··· 200 203 Future<_SingleResult> _fetchSingleWithRetry(String did) async { 201 204 for (var attempt = 0; attempt <= _maxRetries; attempt++) { 202 205 try { 203 - final response = await _bluesky.actor.getProfile(actor: did, $headers: _appViewContext.appBskyHeaders()); 206 + final response = await _bluesky.actor.getProfile( 207 + actor: did, 208 + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.actor.getProfile'), 209 + ); 204 210 final view = _asProfileView(response.data); 205 211 return _SingleResult(profile: view, status: null, failed: view == null); 206 212 } catch (error, stackTrace) {
+64 -21
lib/features/profile/data/profile_repository.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:convert'; 2 3 3 4 import 'package:atproto_core/atproto_core.dart' as atp_core; ··· 32 33 Future<ProfileViewDetailed> getProfile(String actor) async { 33 34 log.d('ProfileRepository: Loading profile for $actor via ${_describeClientContext()}'); 34 35 36 + ProfileViewDetailed profile; 35 37 try { 36 - final response = await _bluesky.actor.getProfile( 37 - actor: actor, 38 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 38 + final headers = _appViewContext.appBskyHeadersForEndpoint( 39 + 'app.bsky.actor.getProfile', 40 + await _moderationService?.headersForRequest(), 41 + ); 42 + log.i( 43 + 'ProfileRepository: getProfile request actor=$actor atproto-proxy=${_headerValue(headers, 'atproto-proxy') ?? 'none'}', 39 44 ); 40 - final profile = response.data; 45 + final response = await _bluesky.actor.getProfile(actor: actor, $headers: headers); 46 + profile = response.data; 41 47 log.i('ProfileRepository: Loaded profile ${profile.did} (${profile.handle})'); 42 - 43 - await _database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); 44 - log.d('ProfileRepository: Cached profile ${profile.did} (${profile.handle})'); 45 - 46 - if (_moderationService?.shouldFilterProfileDetailedInView(profile) ?? false) { 47 - throw Exception('Profile hidden by moderation preferences'); 48 - } 49 - 50 - return profile; 51 48 } catch (error, stackTrace) { 52 - log.e('ProfileRepository: Failed to load profile for $actor', error: error, stackTrace: stackTrace); 49 + log.e('ProfileRepository: Failed to fetch profile from network for $actor', error: error, stackTrace: stackTrace); 53 50 final cachedProfile = await _getCachedProfile(actor); 54 51 if (cachedProfile != null) { 55 52 log.w('ProfileRepository: Using cached profile for $actor after request failure'); 53 + log.w('ProfileRepository: getProfile cached JSON ${jsonEncode(cachedProfile.toJson())}'); 56 54 if (_moderationService?.shouldFilterProfileDetailedInView(cachedProfile) ?? false) { 57 55 throw Exception('Profile hidden by moderation preferences'); 58 56 } ··· 61 59 62 60 rethrow; 63 61 } 62 + 63 + // Cache failures should not downgrade a fresh network response into stale fallback data. 64 + unawaited(_cacheProfileSafely(profile)); 65 + 66 + if (_moderationService?.shouldFilterProfileDetailedInView(profile) ?? false) { 67 + throw Exception('Profile hidden by moderation preferences'); 68 + } 69 + 70 + return profile; 64 71 } 65 72 66 73 Future<List<ProfileView>> getProfiles(List<String> actors) async { 67 74 log.d('ProfileRepository: Loading ${actors.length} profiles via ${_describeClientContext()}'); 68 - final response = await _bluesky.actor.getProfiles( 69 - actors: actors, 70 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 75 + final headers = _appViewContext.appBskyHeadersForEndpoint( 76 + 'app.bsky.actor.getProfiles', 77 + await _moderationService?.headersForRequest(), 78 + ); 79 + log.i( 80 + 'ProfileRepository: getProfiles request actors=${actors.length} atproto-proxy=${_headerValue(headers, 'atproto-proxy') ?? 'none'}', 71 81 ); 82 + final response = await _bluesky.actor.getProfiles(actors: actors, $headers: headers); 72 83 final profiles = response.data.profiles 73 84 .where((profile) => !(_moderationService?.shouldFilterProfileInList(profile) ?? false)) 74 85 .toList(); ··· 79 90 Future<List<ProfileView>> getSuggestedFollows(String actor) async { 80 91 final response = await _bluesky.graph.getSuggestedFollowsByActor( 81 92 actor: actor, 82 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 93 + $headers: _appViewContext.appBskyHeadersForEndpoint( 94 + 'app.bsky.graph.getSuggestedFollowsByActor', 95 + await _moderationService?.headersForRequest(), 96 + ), 83 97 ); 84 98 final suggestions = response.data.suggestions; 85 99 final moderationService = _moderationService; ··· 91 105 log.d('ProfileRepository: Loading current user profile for ${tokens.did} via ${_describeClientContext()}'); 92 106 93 107 try { 94 - final response = await _bluesky.actor.getProfile( 95 - actor: tokens.did, 96 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 108 + final headers = _appViewContext.appBskyHeadersForEndpoint( 109 + 'app.bsky.actor.getProfile', 110 + await _moderationService?.headersForRequest(), 111 + ); 112 + log.i( 113 + 'ProfileRepository: getCurrentUserProfile request did=${tokens.did} atproto-proxy=${_headerValue(headers, 'atproto-proxy') ?? 'none'}', 97 114 ); 115 + final response = await _bluesky.actor.getProfile(actor: tokens.did, $headers: headers); 98 116 log.i('ProfileRepository: Loaded current user profile ${response.data.did} (${response.data.handle})'); 99 117 return response.data; 100 118 } catch (error, stackTrace) { ··· 126 144 return ProfileViewDetailed.fromJson(jsonDecode(cachedProfile.payload) as Map<String, dynamic>); 127 145 } 128 146 147 + Future<void> _cacheProfileSafely(ProfileViewDetailed profile) async { 148 + try { 149 + await _database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); 150 + log.d('ProfileRepository: Cached profile ${profile.did} (${profile.handle})'); 151 + } catch (error, stackTrace) { 152 + log.w( 153 + 'ProfileRepository: Failed to cache profile ${profile.did} (${profile.handle})', 154 + error: error, 155 + stackTrace: stackTrace, 156 + ); 157 + } 158 + } 159 + 129 160 String _describeClientContext() { 130 161 final bluesky = _bluesky; 131 162 if (bluesky is! Bluesky) { ··· 145 176 } 146 177 147 178 return 'anonymous service=$configuredService'; 179 + } 180 + 181 + String? _headerValue(Map<String, String>? headers, String key) { 182 + if (headers == null) { 183 + return null; 184 + } 185 + for (final entry in headers.entries) { 186 + if (entry.key.toLowerCase() == key.toLowerCase()) { 187 + return entry.value; 188 + } 189 + } 190 + return null; 148 191 } 149 192 }
+17 -11
lib/features/search/data/search_repository.dart
··· 59 59 sort: sortValue, 60 60 cursor: cursor, 61 61 limit: limit, 62 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 62 + $headers: _appViewContext.appBskyHeadersForEndpoint( 63 + 'app.bsky.feed.searchPosts', 64 + await _moderationService?.headersForRequest(), 65 + ), 63 66 ); 64 67 65 68 return SearchPostsResult( ··· 74 77 q: query, 75 78 cursor: cursor, 76 79 limit: limit, 77 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 80 + $headers: _appViewContext.appBskyHeadersForEndpoint( 81 + 'app.bsky.actor.searchActors', 82 + await _moderationService?.headersForRequest(), 83 + ), 78 84 ); 79 85 80 86 return SearchActorsResult(actors: _filterProfiles(response.data.actors), cursor: response.data.cursor); ··· 118 124 final response = await _bluesky.actor.searchActorsTypeahead( 119 125 q: query, 120 126 limit: limit, 121 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 127 + $headers: _appViewContext.appBskyHeadersForEndpoint( 128 + 'app.bsky.actor.searchActorsTypeahead', 129 + await _moderationService?.headersForRequest(), 130 + ), 122 131 ); 123 132 124 133 return _filterBasicProfiles(response.data.actors); ··· 159 168 160 169 final uri = Uri.https(_appViewContext.publicServiceHost(), '/xrpc/app.bsky.unspecced.getTopicFeed', params); 161 170 final baseHeaders = await _moderationService?.headersForRequest(); 162 - final headers = _withoutAppViewProxyHeader(_appViewContext.appBskyHeaders(baseHeaders)); 171 + final headers = _appViewContext.appBskyHeadersForEndpoint('app.bsky.unspecced.getTopicFeed', baseHeaders); 163 172 final response = await XrpcNetworkInterceptor.wrapGetClient()(uri, headers: headers); 164 173 if (response.statusCode >= 400) { 165 174 throw Exception('Failed to fetch Blacksky topic feed (status ${response.statusCode})'); ··· 188 197 189 198 final hydrated = await _bluesky.feed.getPosts( 190 199 uris: atUris, 191 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 200 + $headers: _appViewContext.appBskyHeadersForEndpoint( 201 + 'app.bsky.feed.getPosts', 202 + await _moderationService?.headersForRequest(), 203 + ), 192 204 ); 193 205 194 206 return TopicPostsResult( ··· 207 219 } 208 220 } 209 221 return null; 210 - } 211 - 212 - Map<String, String> _withoutAppViewProxyHeader(Map<String, String> headers) { 213 - final copy = Map<String, String>.from(headers); 214 - copy.removeWhere((key, _) => key.toLowerCase() == 'atproto-proxy'); 215 - return copy; 216 222 } 217 223 218 224 Future<T> _runPublicReadWithFallback<T>({
+16 -4
lib/features/starter_packs/data/starter_pack_repository.dart
··· 28 28 actor: actor, 29 29 cursor: cursor, 30 30 limit: limit, 31 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 31 + $headers: _appViewContext.appBskyHeadersForEndpoint( 32 + 'app.bsky.graph.getActorStarterPacks', 33 + await _moderationService?.headersForRequest(), 34 + ), 32 35 ); 33 36 34 37 return ActorStarterPacksResult(starterPacks: response.data.starterPacks, cursor: response.data.cursor); ··· 37 40 Future<StarterPackView> getStarterPack({required AtUri starterPackUri}) async { 38 41 final response = await _bluesky.graph.getStarterPack( 39 42 starterPack: starterPackUri, 40 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 43 + $headers: _appViewContext.appBskyHeadersForEndpoint( 44 + 'app.bsky.graph.getStarterPack', 45 + await _moderationService?.headersForRequest(), 46 + ), 41 47 ); 42 48 43 49 return response.data.starterPack; ··· 46 52 Future<List<GeneratorView>> getSuggestedFeeds({int limit = 50}) async { 47 53 final response = await _bluesky.feed.getSuggestedFeeds( 48 54 limit: limit, 49 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 55 + $headers: _appViewContext.appBskyHeadersForEndpoint( 56 + 'app.bsky.feed.getSuggestedFeeds', 57 + await _moderationService?.headersForRequest(), 58 + ), 50 59 ); 51 60 return response.data.feeds as List<GeneratorView>; 52 61 } ··· 148 157 list: referenceListUri, 149 158 cursor: cursor, 150 159 limit: 100, 151 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 160 + $headers: _appViewContext.appBskyHeadersForEndpoint( 161 + 'app.bsky.graph.getList', 162 + await _moderationService?.headersForRequest(), 163 + ), 152 164 ); 153 165 154 166 for (final item in response.data.items as List) {
+4 -1
lib/features/typeahead/data/typeahead_repository.dart
··· 107 107 final response = await bluesky.actor.searchActorsTypeahead( 108 108 q: query, 109 109 limit: limit, 110 - $headers: _appViewContext.appBskyHeaders(await _moderationService?.headersForRequest()), 110 + $headers: _appViewContext.appBskyHeadersForEndpoint( 111 + 'app.bsky.actor.searchActorsTypeahead', 112 + await _moderationService?.headersForRequest(), 113 + ), 111 114 ); 112 115 113 116 final results = (response.data.actors as List)
+58
test/core/network/app_bsky_routing_policy_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/core/network/app_bsky_routing_policy.dart'; 3 + 4 + void main() { 5 + group('AppBskyRoutingPolicy', () { 6 + test('bypasses proxy for known non-essential endpoints', () { 7 + const endpoints = <String>[ 8 + 'app.bsky.actor.getProfile', 9 + 'app.bsky.actor.getProfiles', 10 + 'app.bsky.actor.searchActorsTypeahead', 11 + 'app.bsky.graph.getList', 12 + 'app.bsky.graph.getLists', 13 + 'app.bsky.feed.getActorLikes', 14 + 'app.bsky.feed.getPosts', 15 + 'app.bsky.unspecced.getTopicFeed', 16 + ]; 17 + 18 + for (final endpoint in endpoints) { 19 + expect( 20 + AppBskyRoutingPolicy.modeForEndpoint(endpoint), 21 + AppBskyProxyMode.bypassProxy, 22 + reason: 'Expected bypass policy for $endpoint', 23 + ); 24 + expect(AppBskyRoutingPolicy.shouldUseProxy(endpoint), isFalse); 25 + } 26 + }); 27 + 28 + test('uses proxy for explicit provider-sensitive and service-routed endpoints', () { 29 + const endpoints = <String>[ 30 + 'app.bsky.feed.getTimeline', 31 + 'app.bsky.feed.getFeed', 32 + 'app.bsky.feed.searchPosts', 33 + 'app.bsky.feed.getPostThread', 34 + 'app.bsky.feed.getAuthorFeed', 35 + 'app.bsky.notification.listNotifications', 36 + 'app.bsky.notification.getUnreadCount', 37 + 'app.bsky.notification.updateSeen', 38 + 'app.bsky.labeler.getServices', 39 + 'app.bsky.feed.sendInteractions', 40 + 'chat.bsky.convo.listConvos', 41 + ]; 42 + 43 + for (final endpoint in endpoints) { 44 + expect( 45 + AppBskyRoutingPolicy.modeForEndpoint(endpoint), 46 + AppBskyProxyMode.useProxy, 47 + reason: 'Expected useProxy policy for $endpoint', 48 + ); 49 + expect(AppBskyRoutingPolicy.shouldUseProxy(endpoint), isTrue); 50 + } 51 + }); 52 + 53 + test('uses proxy for unknown endpoints by default', () { 54 + expect(AppBskyRoutingPolicy.modeForEndpoint('app.bsky.unknown.operation'), AppBskyProxyMode.useProxy); 55 + expect(AppBskyRoutingPolicy.modeForEndpoint(' '), AppBskyProxyMode.useProxy); 56 + }); 57 + }); 58 + }
+15
test/features/profile/data/profile_repository_test.dart
··· 99 99 expect(result.handle, profile.handle); 100 100 expect(result.displayName, profile.displayName); 101 101 }); 102 + 103 + test('returns fresh network profile even when cache write fails', () async { 104 + final profile = _buildProfile(); 105 + final repository = ProfileRepository( 106 + database: database, 107 + bluesky: _FakeBlueskyClient(actor: _FakeActorService(onGetProfile: (_) async => _FakeResponse(profile))), 108 + ); 109 + 110 + // Force cache operations to fail while keeping network response successful. 111 + await database.close(); 112 + 113 + final result = await repository.getProfile(profile.did); 114 + expect(result.did, profile.did); 115 + expect(result.followersCount, profile.followersCount); 116 + }); 102 117 }); 103 118 } 104 119
+1 -1
test/features/typeahead/data/typeahead_repository_test.dart
··· 52 52 expect(actorService.lastQuery, 'keep'); 53 53 expect(actorService.lastLimit, 5); 54 54 expect(actorService.lastHeaders?['x-test'], 'moderation'); 55 - expect(actorService.lastHeaders?['atproto-proxy'], 'did:web:api.bsky.app#bsky_appview'); 55 + expect(actorService.lastHeaders?['atproto-proxy'], isNull); 56 56 }); 57 57 58 58 test('community provider makes HTTP request, parses JSON, and applies local moderation', () async {